From a1e60e383e3cd197c53766ea1e9f30869bcee669 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Mon, 1 Sep 2025 12:41:42 +0530 Subject: [PATCH 01/36] feat: migrate the tenants BE repo and update various things --- package-lock.json | 65 +++ .../src/recipeImplementation.ts | 480 ++++++++++++++++++ 2 files changed, 545 insertions(+) create mode 100644 packages/tenants-nodejs/src/recipeImplementation.ts diff --git a/package-lock.json b/package-lock.json index 9856f6a..9c2a895 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4728,6 +4728,17 @@ "@types/node": "*" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.1.tgz", + "integrity": "sha512-UfHAghPmGZVzaL8x9y+mKZMWyHC399+iq0MOmya5tIyenWX3lcdSb60vOmp0DocR6gCDTYTozv/ULQnREyyjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aws-sdk/client-sesv2": "^3.839.0", + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4791,6 +4802,13 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", @@ -15615,6 +15633,20 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -21619,6 +21651,39 @@ } } }, + "packages/tenants-nodejs": { + "name": "@supertokens-plugins/tenants-nodejs", + "version": "0.0.1", + "devDependencies": { + "@shared/eslint": "*", + "@shared/nodejs": "*", + "@shared/tenants": "*", + "@shared/tsconfig": "*", + "@types/nodemailer": "^7.0.1", + "@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": { + "nodemailer": "^6.0.0", + "supertokens-node": ">=23.0.0" + } + }, + "packages/tenants-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/user-banning-nodejs": { "name": "@supertokens-plugins/user-banning-nodejs", "version": "0.2.1", diff --git a/packages/tenants-nodejs/src/recipeImplementation.ts b/packages/tenants-nodejs/src/recipeImplementation.ts new file mode 100644 index 0000000..8d7bb92 --- /dev/null +++ b/packages/tenants-nodejs/src/recipeImplementation.ts @@ -0,0 +1,480 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import supertokens from "supertokens-node"; +import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; +import MultiTenancy from "supertokens-node/recipe/multitenancy"; +import { InviteeDetails, ROLES, TenantList } from "@shared/tenants"; +import { User } from "supertokens-node/types"; +import { + ErrorResponse, + MetadataType, + NonOkResponse, + OverrideableTenantFunctionImplementation, + SuperTokensPluginTenantPluginConfig, + TenantCreationRequestMetadataType, +} from "./types"; +import { logDebugMessage } from "supertokens-node/lib/build/logger"; +import UserRoles from "supertokens-node/recipe/userroles"; +import { LoginMethod } from "supertokens-node/lib/build/user"; +import { assignAdminToUserInTenant, getUserIdsInTenantWithRole } from "./roles"; +import { TENANT_CREATE_METADATA_REQUESTS_KEY } from "./constants"; + +export const getOverrideableTenantFunctionImplementation = ( + pluginConfig: SuperTokensPluginTenantPluginConfig, +): OverrideableTenantFunctionImplementation => { + const implementation: OverrideableTenantFunctionImplementation = { + getTenants: async ( + sessionOrUserId: SessionContainerInterface | string, + ): Promise<({ status: "OK" } & TenantList) | { status: "ERROR"; message: string }> => { + const userId = typeof sessionOrUserId === "string" ? sessionOrUserId : sessionOrUserId.getUserId(); + + const userDetails = await supertokens.getUser(userId); + if (!userDetails) { + return { + status: "ERROR", + message: "User not found", + }; + } + + const tenantDetails = await MultiTenancy.listAllTenants(); + + // Return the tenants that the user is not a member of + return { + ...tenantDetails, + joinedTenantIds: userDetails.tenantIds, + }; + }, + getTenantUsers: async (tenantId: string): Promise<{ status: "OK"; users: (User & { roles?: string[] })[] }> => { + const getUsersResponse = await supertokens.getUsersOldestFirst({ + tenantId: tenantId, + }); + + // Find all the users that have a role in the tenant + // and return details. + // Iterate through all the the available roles and find users. + const userIdToRoleMap: Record = {}; + for (const role of Object.values(ROLES)) { + const users = await getUserIdsInTenantWithRole(tenantId, role); + for (const user of users) { + userIdToRoleMap[user] = [...(userIdToRoleMap[user] || []), role]; + } + } + + return { + status: "OK", + users: getUsersResponse.users.map((user) => ({ + ...user, + roles: userIdToRoleMap[user.id] ?? [], + })), + }; + }, + addInvitation: async ( + email: string, + tenantId: string, + metadata: MetadataType, + ): Promise<{ status: "OK"; code: string } | NonOkResponse | ErrorResponse> => { + // Check if the user: + // 1. is already associated with the tenant + // 2. is already invited to the tenant + + const getUsersResponse = await supertokens.getUsersOldestFirst({ + tenantId: tenantId, + }); + + // TODO: Add support for role + + // We will have to find whether the user is already associated + // by searching with the email. + const userDetails = getUsersResponse.users.find((user) => user.emails.some((userEmail) => userEmail === email)); + if (userDetails) { + return { + status: "USER_ALREADY_ASSOCIATED", + message: "User already associated with tenant", + }; + } + + // Check if the user is already invited to the tenant + let tenantMetadata = await metadata.get(tenantId); + if (tenantMetadata?.invitees.some((invitee) => invitee.email === email)) { + return { + status: "USER_ALREADY_INVITED", + message: "User already invited to tenant", + }; + } + + if (tenantMetadata === undefined) { + tenantMetadata = { + invitees: [], + }; + } + + // Generate a random string for the code + const code = Math.random().toString(36).substring(2, 15); + + // Invite the user to the tenant + await metadata.set(tenantId, { + ...tenantMetadata, + invitees: [...tenantMetadata.invitees, { email, role: "user", code }], + }); + + return { + status: "OK", + message: "User invited to tenant", + code, + }; + }, + removeInvitation: async ( + email: string, + tenantId: string, + metadata: MetadataType, + ): Promise<{ status: "OK" } | NonOkResponse | ErrorResponse> => { + // Check if the user is invited to the tenant + const tenantMetadata = await metadata.get(tenantId); + if (!tenantMetadata) { + return { + status: "ERROR", + message: "Tenant not found", + }; + } + + // Check if the user is invited to the tenant + const isInvited = tenantMetadata.invitees.some((invitee) => invitee.email === email && invitee.role === "user"); + if (!isInvited) { + return { + status: "ERROR", + message: "User not invited to tenant", + }; + } + + // Remove the invitation from the tenants's metadata. + await metadata.set(tenantId, { + ...tenantMetadata, + invitees: tenantMetadata.invitees.filter((invitee) => invitee.email !== email), + }); + + return { + status: "OK", + message: "Invitation removed from tenant", + }; + }, + getInvitations: async ( + tenantId: string, + metadata: MetadataType, + ): Promise<{ status: "OK"; invitees: InviteeDetails[] } | NonOkResponse | ErrorResponse> => { + const tenantMetadata = await metadata.get(tenantId); + if (!tenantMetadata) { + return { + status: "ERROR", + message: "Tenant not found", + }; + } + + return { + status: "OK", + invitees: tenantMetadata.invitees, + }; + }, + acceptInvitation: async ( + code: string, + tenantId: string, + session: SessionContainerInterface, + metadata: MetadataType, + ): Promise<{ status: "OK" } | NonOkResponse | ErrorResponse> => { + // Check if the user is invited to the tenant + const tenantMetadata = await metadata.get(tenantId); + if (!tenantMetadata) { + return { + status: "ERROR", + message: "Tenant not found", + }; + } + + // Find the invitation details + const inviteeDetails = tenantMetadata.invitees.find((invitee) => invitee.code === code); + if (!inviteeDetails) { + return { + status: "ERROR", + message: "Invitation not found", + }; + } + + await implementation.associateAllLoginMethodsOfUserWithTenant( + tenantId, + session.getUserId(), + (loginMethod) => loginMethod.email === inviteeDetails.email, + ); + + // Remove the invitation from the tenants's metadata. + await metadata.set(tenantId, { + ...tenantMetadata, + invitees: tenantMetadata.invitees.filter((invitee) => invitee.email !== inviteeDetails.email), + }); + logDebugMessage(`Removed invitation from tenant ${tenantId}`); + + // TODO: Add the user with the role + + return { + status: "OK", + message: "Invitation accepted", + }; + }, + isAllowedToJoinTenant: async (user: User, session: SessionContainerInterface) => { + // By default we will allow all users to join a tenant. + return true; + }, + isAllowedToCreateTenant: async (session: SessionContainerInterface) => { + // By default we will allow all users to create a tenant. + return true; + }, + canCreateInvitation: async (user: User, role: string, session: SessionContainerInterface) => { + // By default, only owners can create invitations. + return role === ROLES.ADMIN; + }, + canApproveJoinRequest: async (user: User, role: string, session: SessionContainerInterface) => { + // By default, only owners can approve join requests. + return role === ROLES.ADMIN; + }, + canApproveTenantCreationRequest: async (user: User, role: string, session: SessionContainerInterface) => { + // By default, only owners can approve tenant creation requests. + return role === ROLES.ADMIN; + }, + canRemoveUserFromTenant: async (user: User, role: string, session: SessionContainerInterface) => { + // By default, only owners can remove users from a tenant. + return role === ROLES.ADMIN; + }, + associateAllLoginMethodsOfUserWithTenant: async ( + tenantId: string, + userId: string, + loginMethodFilter?: (loginMethod: LoginMethod) => boolean, + ) => { + const userDetails = await supertokens.getUser(userId); + if (!userDetails) { + throw new Error(`User ${userId} not found`); + } + + // Find all the loginMethods for the user that match the email for the + // invitation. + const loginMethods = userDetails.loginMethods.filter(loginMethodFilter ?? (() => true)); + logDebugMessage(`loginMethods: ${JSON.stringify(loginMethods)}`); + + // For each of the loginMethods, associate the user with the tenant + for (const loginMethod of loginMethods) { + await MultiTenancy.associateUserToTenant(tenantId, loginMethod.recipeUserId); + logDebugMessage(`Associated user ${userDetails.id} with tenant ${tenantId}`); + } + }, + doesTenantCreationRequireApproval: async (session: SessionContainerInterface) => { + // By default, tenant creation does not require approval. + return pluginConfig.requireTenantCreationRequestApproval ?? true; + }, + addTenantCreationRequest: async (session, tenantDetails, metadata, appUrl, userContext, sendEmail) => { + // Add tenant creation request to metadata + let tenantCreateRequestMetadata = await metadata.get(TENANT_CREATE_METADATA_REQUESTS_KEY); + + if (tenantCreateRequestMetadata === undefined) { + // Initialize it + tenantCreateRequestMetadata = { + requests: [], + }; + } + + // Add the new creation request + const requestId = Math.random().toString(36).substring(2, 15); + await metadata.set(TENANT_CREATE_METADATA_REQUESTS_KEY, { + ...tenantCreateRequestMetadata, + requests: [ + ...(tenantCreateRequestMetadata.requests ?? []), + { ...tenantDetails, userId: session.getUserId(), requestId }, + ], + }); + + // Extract the email of the user that is creating the tenant + const creatorUserId = session.getUserId(); + const userDetails = await supertokens.getUser(creatorUserId); + const creatorEmail = userDetails?.emails[0]; + + // Notify app admins + await implementation.sendTenantCreationRequestEmail( + tenantDetails.name, + creatorEmail ?? creatorUserId, + appUrl, + userContext, + sendEmail, + ); + + return { + status: "OK", + requestId, + }; + }, + getTenantCreationRequests: async (metadata: TenantCreationRequestMetadataType) => { + const tenantCreateRequestMetadata = await metadata.get(TENANT_CREATE_METADATA_REQUESTS_KEY); + return { + status: "OK", + requests: tenantCreateRequestMetadata?.requests ?? [], + }; + }, + acceptTenantCreationRequest: async (requestId, session, metadata) => { + /** + * Mark the request as accepted by creating the tenant + * and remove the create request. + * + * @param requestId - The id of the request to accept + * @param session - The session of the user accepting the request + * @param metadata - The metadata of the tenant + * @returns The status of the request + */ + const tenantCreateRequestMetadata = await metadata.get(TENANT_CREATE_METADATA_REQUESTS_KEY); + if (!tenantCreateRequestMetadata) { + return { + status: "ERROR", + message: "Tenant creation request not found", + }; + } + + // Find the request + const request = tenantCreateRequestMetadata.requests.find((request) => request.requestId === requestId); + if (!request) { + return { + status: "ERROR", + message: "Tenant creation request not found", + }; + } + + // Create the tenant and assign admin to the user that added the request. + const createResponse = await implementation.createTenantAndAssignAdmin( + { + name: request.name, + firstFactors: request.firstFactors, + }, + request.userId, + ); + + if (createResponse.status !== "OK") { + return createResponse; + } + + // Remove the request from the metadata + await metadata.set(TENANT_CREATE_METADATA_REQUESTS_KEY, { + ...tenantCreateRequestMetadata, + requests: tenantCreateRequestMetadata.requests.filter((request) => request.requestId !== requestId), + }); + + return { + status: "OK", + }; + }, + createTenantAndAssignAdmin: async (tenantDetails, userId) => { + const createResponse = await MultiTenancy.createOrUpdateTenant(tenantDetails.name, { + firstFactors: tenantDetails.firstFactors, + }); + + // Add the user as the admin of the tenant + await assignAdminToUserInTenant(tenantDetails.name, userId); + + return createResponse; + }, + rejectTenantCreationRequest: async (requestId, session, metadata) => { + const tenantCreateRequestMetadata = await metadata.get(TENANT_CREATE_METADATA_REQUESTS_KEY); + if (!tenantCreateRequestMetadata) { + return { + status: "ERROR", + message: "Tenant creation request not found", + }; + } + + // Remove the request from the metadata + await metadata.set(TENANT_CREATE_METADATA_REQUESTS_KEY, { + ...tenantCreateRequestMetadata, + requests: tenantCreateRequestMetadata.requests.filter((request) => request.requestId !== requestId), + }); + + return { + status: "OK", + }; + }, + sendTenantCreationRequestEmail: async (tenantId, creatorEmail, appUrl, userContext, sendEmail) => { + /** + * Send an email to all the admins of the app. + * + * @param tenantId - The id of the tenant that is being created + * @param creatorEmail - The email of the user that is creating the tenant + * @param appUrl - The url of the app + */ + const adminUsers = await getUserIdsInTenantWithRole("public", ROLES.APP_ADMIN); + + // For each of the users, we will need to find their email address. + const adminEmails = await Promise.all( + adminUsers.map(async (userId) => { + const userDetails = await supertokens.getUser(userId); + return userDetails?.emails[0]; + }), + ); + + // Send emails to all tenant admins using Promise.all + await Promise.all( + adminEmails + .filter((email) => email !== undefined) + .map(async (email) => { + await sendEmail( + { + type: "TENANT_CREATE_APPROVAL", + email, + tenantId, + creatorEmail, + appUrl, + }, + userContext, + ); + }), + ); + }, + getAppUrl: (appInfo, request, userContext) => { + /** + * Get the App URL using the app info, request and user context. + */ + const websiteDomain = appInfo.getTopLevelWebsiteDomain({ + request, + userContext, + }); + return `${websiteDomain ? "https://" : "http://"}${websiteDomain ?? "localhost"}${appInfo.websiteBasePath ?? ""}`; + }, + }; + + return implementation; +}; + +export const rejectRequestToJoinTenant = async ( + tenantId: string, + userId: string, +): Promise<{ status: "OK" } | NonOkResponse | ErrorResponse> => { + // We need to check that the user doesn't have an existing role, in which + // case we cannot "accept" the request. + const role = await UserRoles.getRolesForUser(tenantId, userId); + if (role.roles.length > 0) { + return { + status: "ERROR", + message: "Request already accepted", + }; + } + + // Find all the recipeUserIds for the user + // Remove the user from the tenant + const userDetails = await supertokens.getUser(userId); + if (!userDetails) { + return { + status: "ERROR", + message: "User not found", + }; + } + + // For each of the loginMethods, associate the user with the tenant + for (const loginMethod of userDetails.loginMethods) { + await MultiTenancy.disassociateUserFromTenant(tenantId, loginMethod.recipeUserId); + logDebugMessage(`Disassociated user ${userDetails.id} from tenant ${tenantId}`); + } + + return { + status: "OK", + message: "Request rejected", + }; +}; From 185c59108842896994828cf74d740debf6532ba7 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Mon, 1 Sep 2025 14:43:36 +0530 Subject: [PATCH 02/36] feat: migrate the tenants react repo --- .../components/details/details-wrapper.tsx | 52 ++++++ .../components/details/details.module.scss | 96 +++++++++++ .../tenants-react/src/components/index.ts | 5 +- .../components/invitations/invitations.tsx | 162 ++++++++++++++++++ .../src/components/tenant-management/index.ts | 1 + .../tenant-management.module.scss | 71 ++++++++ .../tenant-management/tenant-management.tsx | 157 +++++++++++++++++ .../src/invitation-accept-wrapper.tsx | 15 ++ .../tenants-react/src/select-tenant-page.tsx | 17 ++ .../src/tenant-details-wrapper.tsx | 21 +++ packages/tenants-react/src/tenant-wrapper.tsx | 84 +++++++++ 11 files changed, 679 insertions(+), 2 deletions(-) create mode 100644 packages/tenants-react/src/components/details/details-wrapper.tsx create mode 100644 packages/tenants-react/src/components/details/details.module.scss create mode 100644 packages/tenants-react/src/components/invitations/invitations.tsx create mode 100644 packages/tenants-react/src/components/tenant-management/index.ts create mode 100644 packages/tenants-react/src/components/tenant-management/tenant-management.module.scss create mode 100644 packages/tenants-react/src/components/tenant-management/tenant-management.tsx create mode 100644 packages/tenants-react/src/invitation-accept-wrapper.tsx create mode 100644 packages/tenants-react/src/select-tenant-page.tsx create mode 100644 packages/tenants-react/src/tenant-details-wrapper.tsx create mode 100644 packages/tenants-react/src/tenant-wrapper.tsx diff --git a/packages/tenants-react/src/components/details/details-wrapper.tsx b/packages/tenants-react/src/components/details/details-wrapper.tsx new file mode 100644 index 0000000..bc37160 --- /dev/null +++ b/packages/tenants-react/src/components/details/details-wrapper.tsx @@ -0,0 +1,52 @@ +import classNames from 'classnames/bind'; +import style from './details.module.scss'; +import { BaseFormSection } from '@supertokens-plugin-profile/common-details-shared'; +import { User } from 'supertokens-web-js/types'; +import { useCallback, useEffect, useState } from 'react'; + +const cx = classNames.bind(style); + +export const DetailsWrapper = ({ + section, + onFetch, +}: { + section: BaseFormSection; + onFetch: () => Promise<{ users: User[] }>; +}) => { + const [users, setUsers] = useState([]); + + const loadDetails = useCallback(async () => { + const details = await onFetch(); + setUsers(details.users); + }, [onFetch]); + + useEffect(() => { + loadDetails(); + }, [loadDetails]); + + return ( +
+
+

{section.label}

+

{section.description}

+
+ +
+ {users.length > 0 ? ( +
+ {users.map((user) => ( +
+
{user.emails[0]?.charAt(0).toUpperCase() || 'U'}
+
{user.emails[0]}
+
+ ))} +
+ ) : ( +
+

No users found

+
+ )} +
+
+ ); +}; diff --git a/packages/tenants-react/src/components/details/details.module.scss b/packages/tenants-react/src/components/details/details.module.scss new file mode 100644 index 0000000..12159c4 --- /dev/null +++ b/packages/tenants-react/src/components/details/details.module.scss @@ -0,0 +1,96 @@ +:global(.pluginProfile) { + .tenantDetailsSection { + display: flex; + flex-direction: column; + max-width: 800px; + margin: 0 auto; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, + Helvetica Neue, sans-serif; + color: #333; + width: 100%; + } + + .tenantDetailsHeader { + position: relative; + padding-bottom: 24px; + margin-bottom: 24px; + border-bottom: 1px solid #eee; + + h3 { + margin-bottom: 12px; + } + } + + .tenantDetailsContent { + display: flex; + flex-direction: column; + gap: 12px; + } + + .tenantDetailsUsers { + display: flex; + flex-direction: column; + gap: 8px; + } + + .userRow { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border: 1px solid #eee; + border-radius: 6px; + background-color: #fafafa; + } + + .userAvatar { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: #007bff; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; + flex-shrink: 0; + } + + .userEmail { + flex: 1; + font-size: 14px; + color: #333; + } + + .removeButton { + width: 24px; + height: 24px; + border: none; + background-color: #dc3545; + color: white; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: bold; + flex-shrink: 0; + transition: background-color 0.2s; + + &:hover { + background-color: #c82333; + } + } + + .tenantDetailsNoUsers { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + border: 1px solid #eee; + border-radius: 4px; + } +} diff --git a/packages/tenants-react/src/components/index.ts b/packages/tenants-react/src/components/index.ts index 16eeb02..ceaba10 100644 --- a/packages/tenants-react/src/components/index.ts +++ b/packages/tenants-react/src/components/index.ts @@ -1,2 +1,3 @@ -export * from './tenant-card'; -export * from './page-wrapper'; +export * from "./tenant-card"; +export * from "./page-wrapper"; +export * from "./tenant-management"; diff --git a/packages/tenants-react/src/components/invitations/invitations.tsx b/packages/tenants-react/src/components/invitations/invitations.tsx new file mode 100644 index 0000000..a22d427 --- /dev/null +++ b/packages/tenants-react/src/components/invitations/invitations.tsx @@ -0,0 +1,162 @@ +import classNames from 'classnames/bind'; +import style from './invitations.module.scss'; +import { BaseFormSection } from '@supertokens-plugin-profile/common-details-shared'; +import { useCallback, useEffect, useState } from 'react'; +import { InviteeDetails } from '@supertokens-plugin-profile/tenants-shared'; + +const cx = classNames.bind(style); + +export const InvitationsWrapper = ({ + section, + onFetch, + onRemove, + onCreate, + selectedTenantId, +}: { + section: BaseFormSection; + onFetch: (tenantId?: string) => Promise<{ invitations: InviteeDetails[] }>; + onRemove: (email: string, tenantId?: string) => Promise; + onCreate?: (email: string, tenantId: string) => Promise; + selectedTenantId: string; +}) => { + const [invitations, setInvitations] = useState([]); + const [showInviteForm, setShowInviteForm] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showCode, setShowCode] = useState(null); + const loadDetails = useCallback( + async (tenantId?: string) => { + const details = await onFetch(tenantId || selectedTenantId); + setInvitations(details.invitations); + }, + [onFetch, selectedTenantId], + ); + + const handleShowCode = (code: string) => { + setShowCode(code); + }; + + useEffect(() => { + if (selectedTenantId) { + loadDetails(selectedTenantId); + } + }, [selectedTenantId, loadDetails]); + + const handleInviteSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!onCreate || !inviteEmail.trim()) return; + + setIsSubmitting(true); + try { + await onCreate(inviteEmail.trim(), selectedTenantId); + setInviteEmail(''); + setShowInviteForm(false); + // Reload the invitations list + await loadDetails(selectedTenantId); + } catch (error) { + console.error('Failed to create invitation:', error); + } finally { + setIsSubmitting(false); + } + }; + + const handleRemoveInvitation = async (email: string, tenantId: string) => { + await onRemove(email, tenantId); + await loadDetails(tenantId); + }; + + const handleCancelInvite = () => { + setShowInviteForm(false); + setInviteEmail(''); + }; + + return ( +
+
+

{section.label}

+

{section.description}

+ {onCreate && ( + + )} +
+ + {showInviteForm && onCreate && ( +
+
+
+ setInviteEmail(e.currentTarget.value)} + className={cx('inviteEmailInput')} + required + disabled={isSubmitting} + /> +
+ + +
+
+
+
+ )} + +
+ {invitations.length > 0 ? ( +
+ {invitations.map((invitation) => ( +
+
{invitation.email.charAt(0).toUpperCase() || 'U'}
+
{invitation.email}
+ + +
+ ))} +
+ ) : ( +
+

No invitations found

+
+ )} +
+ + {showCode && ( +
+

{showCode}

+ +
+ )} +
+ ); +}; diff --git a/packages/tenants-react/src/components/tenant-management/index.ts b/packages/tenants-react/src/components/tenant-management/index.ts new file mode 100644 index 0000000..e489b8c --- /dev/null +++ b/packages/tenants-react/src/components/tenant-management/index.ts @@ -0,0 +1 @@ +export { TenantManagement } from './tenant-management'; \ No newline at end of file diff --git a/packages/tenants-react/src/components/tenant-management/tenant-management.module.scss b/packages/tenants-react/src/components/tenant-management/tenant-management.module.scss new file mode 100644 index 0000000..f97233d --- /dev/null +++ b/packages/tenants-react/src/components/tenant-management/tenant-management.module.scss @@ -0,0 +1,71 @@ +.tenantManagement { + display: flex; + flex-direction: column; + max-width: 800px; + margin: 0 auto; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, + sans-serif; + color: #333; + width: 100%; +} + +.tenantManagementHeader { + padding-bottom: 24px; + margin-bottom: 24px; + border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + margin-bottom: 12px; + font-size: 24px; + font-weight: 600; + } + + p { + margin-bottom: 16px; + color: #666; + } +} + +.tenantSwitcherWrapper wa-select::part(form-control) { + display: flex; + align-items: center; +} + +.tabNavigation { + display: flex; + gap: 0; + margin-bottom: 24px; + border-bottom: 1px solid #eee; +} + +.tabButton { + padding: 12px 24px; + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: #666; + transition: all 0.2s; + + &:hover { + color: #333; + background-color: #f8f9fa; + } + + &.active { + color: #007bff; + border-bottom-color: #007bff; + background-color: #f8f9fa; + } +} + +.tabContent { + flex: 1; + min-height: 400px; +} diff --git a/packages/tenants-react/src/components/tenant-management/tenant-management.tsx b/packages/tenants-react/src/components/tenant-management/tenant-management.tsx new file mode 100644 index 0000000..3a07310 --- /dev/null +++ b/packages/tenants-react/src/components/tenant-management/tenant-management.tsx @@ -0,0 +1,157 @@ +// DEBUG: Modified at 17:30 on 2025-07-28 - CHECK IF THIS APPEARS IN BROWSER +import { useState, useEffect, useCallback } from 'react'; +import classNames from 'classnames/bind'; +import style from './tenant-management.module.scss'; +import { BaseFormSection } from '@supertokens-plugin-profile/common-details-shared'; +import { usePlugin } from '../../use-plugin'; +import { TenantDetails } from '@supertokens-plugin-profile/tenants-shared'; +import { DetailsWrapper } from '../details/details-wrapper'; +import { InvitationsWrapper } from '../invitations/invitations'; +import { SelectInput } from '@supertokens-plugin-profile/common-frontend'; + +const cx = classNames.bind(style); + +export const TenantManagement = ({ section }: { section: BaseFormSection }) => { + const { getUsers, getInvitations, removeInvitation, addInvitation, fetchTenants, switchTenant } = usePlugin(); + const [tenants, setTenants] = useState([]); + const [selectedTenantId, setSelectedTenantId] = useState('public'); + const [activeTab, setActiveTab] = useState<'users' | 'invitations'>('users'); + + // Load tenants on component mount + useEffect(() => { + const loadTenants = async () => { + const response = await fetchTenants(); + if (response.status === 'OK') { + setTenants(response.tenants); + if (response.tenants.length > 0) { + setSelectedTenantId(response.tenants[0].tenantId); + } + } + }; + loadTenants(); + }, [fetchTenants]); + + // Users tab functions + const onFetchUsers = useCallback(async () => { + const response = await getUsers(selectedTenantId); + if (response.status === 'ERROR') { + throw new Error(response.message); + } + return { users: response.users }; + }, [getUsers, selectedTenantId]); + + // Invitations tab functions + const onFetchInvitations = useCallback( + async (tenantId?: string) => { + const response = await getInvitations(tenantId || selectedTenantId); + if (response.status === 'ERROR') { + throw new Error(response.message); + } + return { invitations: response.invitees }; + }, + [getInvitations, selectedTenantId], + ); + + const onRemoveInvite = useCallback( + async (email: string, tenantId?: string) => { + const response = await removeInvitation(email, tenantId || selectedTenantId); + if (response.status === 'ERROR') { + throw new Error(response.message); + } + }, + [removeInvitation, selectedTenantId], + ); + + const onCreateInvite = useCallback( + async (email: string, tenantId: string) => { + const response = await addInvitation(email, tenantId); + if (response.status === 'ERROR') { + throw new Error(response.message); + } + }, + [addInvitation], + ); + + const handleTenantSwitch = useCallback( + async (tenantId: string) => { + const response = await switchTenant(tenantId); + if (response.status === 'OK') { + setSelectedTenantId(tenantId); + } else { + console.error('Failed to switch tenant:', response.message); + } + }, + [switchTenant], + ); + + return ( +
+
+
+

{section.label}

+

{section.description}

+
+ + {/* Tenant Switcher */} + {tenants.length > 0 && ( +
+ handleTenantSwitch(e.target.value)} + name="Tenant Switcher" + options={tenants.map(({ tenantId }) => ({ + label: tenantId === 'public' ? 'Public' : tenantId, + value: tenantId, + }))} + /> +
+ )} +
+ + {/* Tab Navigation */} +
+ + +
+ + {/* Tab Content */} +
+ {activeTab === 'users' && selectedTenantId && ( + + )} + + {activeTab === 'invitations' && selectedTenantId && ( + + )} +
+
+ ); +}; diff --git a/packages/tenants-react/src/invitation-accept-wrapper.tsx b/packages/tenants-react/src/invitation-accept-wrapper.tsx new file mode 100644 index 0000000..e6010ac --- /dev/null +++ b/packages/tenants-react/src/invitation-accept-wrapper.tsx @@ -0,0 +1,15 @@ +import { SuperTokensWrapper } from "supertokens-auth-react"; + +import { AcceptInvitation } from "./components/invitations/accept"; +import { usePluginContext } from "./plugin"; +// import { SessionAuth } from 'supertokens-auth-react/recipe/session'; + +export const InvitationAcceptWrapper = () => { + const { api } = usePluginContext(); + + return ( + + + + ); +}; diff --git a/packages/tenants-react/src/select-tenant-page.tsx b/packages/tenants-react/src/select-tenant-page.tsx new file mode 100644 index 0000000..02e9acf --- /dev/null +++ b/packages/tenants-react/src/select-tenant-page.tsx @@ -0,0 +1,17 @@ +import { SuperTokensWrapper } from "supertokens-auth-react"; +import { SessionAuth } from "supertokens-auth-react/recipe/session"; + +import { PageWrapper } from "./components"; +import TenantCardWrapper from "./tenant-wrapper"; + +export const SelectTenantPage = () => { + return ( + + + + + + + + ); +}; diff --git a/packages/tenants-react/src/tenant-details-wrapper.tsx b/packages/tenants-react/src/tenant-details-wrapper.tsx new file mode 100644 index 0000000..18a7210 --- /dev/null +++ b/packages/tenants-react/src/tenant-details-wrapper.tsx @@ -0,0 +1,21 @@ +import { BaseFormSection } from "@supertokens-plugin-profile/common-details-shared"; +import { useCallback } from "react"; + +import { DetailsWrapper } from "./components/details/details-wrapper"; +import { usePluginContext } from "./plugin"; + +export const TenantDetailsWrapper = ({ section }: { section: BaseFormSection }) => { + const { api } = usePluginContext(); + + const onFetch = useCallback(async () => { + // Use the `tid` from the users access token payload. + + const response = await api.getUsers(); + if (response.status === "ERROR") { + throw new Error(response.message); + } + return { users: response.users }; + }, [api.getUsers, section.id]); + + return ; +}; diff --git a/packages/tenants-react/src/tenant-wrapper.tsx b/packages/tenants-react/src/tenant-wrapper.tsx new file mode 100644 index 0000000..1b88192 --- /dev/null +++ b/packages/tenants-react/src/tenant-wrapper.tsx @@ -0,0 +1,84 @@ +import { TenantCreateData, TenantJoinData, TenantList } from "@shared/tenants"; +import { ToastProvider, ToastContainer } from "@shared/ui"; +import { useEffect, useState } from "react"; + +import { TenantCard } from "./components"; +import { logDebugMessage } from "./logger"; +import { usePluginContext } from "./plugin"; + +const TenantCardWrapper = () => { + const { api } = usePluginContext(); + const { fetchTenants, joinTenant, createTenant } = api; + const [data, setData] = useState({ + tenants: [], + joinedTenantIds: [], + }); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + fetchTenants().then((result) => { + if (result.status === "OK") { + setData(result); + } + setIsLoading(false); + }); + }, []); + + const handleOnJoin = async (data: TenantJoinData) => { + setIsLoading(true); + try { + const result = await joinTenant(data); + + // If there was an error, show that + if (result.status === "ERROR") { + console.error(result.message); + return result; + } + + // If it was successful, redirect the user to `/user/profile`. + if (result.status === "OK") { + logDebugMessage("Successfully joined tenant"); + if (result.wasSessionRefreshed) { + logDebugMessage("Session was refreshed"); + } else { + logDebugMessage("Please go to `/user/profile` to continue"); + } + } + + return result; + } finally { + setIsLoading(false); + } + }; + + const handleOnCreate = async (data: TenantCreateData) => { + setIsLoading(true); + try { + const result = await createTenant(data); + + // If there was an error, show that + if (result.status === "ERROR") { + console.error(result.message); + return result; + } + + return result; + } finally { + setIsLoading(false); + } + }; + + return ; +}; + +const TenantCardWrapperWithToast = () => { + return ( + + + + + ); +}; + +export default TenantCardWrapperWithToast; From 5e7c9701d91858e1b0e01c2ec36edb4d165a1b42 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 5 Sep 2025 11:55:26 +0530 Subject: [PATCH 03/36] feat: add changes to the tenants tab --- package-lock.json | 299 ++++++++++++++++++ .../components/details/details-wrapper.tsx | 36 +-- .../invitations/invitations.module.scss | 66 ---- .../components/invitations/invitations.tsx | 84 ++--- .../components/tenant-card/tenant-card.tsx | 2 +- ...agement.module.scss => styles.module.scss} | 11 +- .../tenant-management/tenant-management.tsx | 90 +++--- .../src/tenant-details-wrapper.tsx | 4 +- 8 files changed, 411 insertions(+), 181 deletions(-) delete mode 100644 packages/tenants-react/src/components/invitations/invitations.module.scss rename packages/tenants-react/src/components/tenant-management/{tenant-management.module.scss => styles.module.scss} (87%) diff --git a/package-lock.json b/package-lock.json index 9c2a895..ef5f690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21684,6 +21684,305 @@ "csstype": "^3.0.2" } }, + "packages/tenants-react": { + "name": "@supertokens-plugins/tenants-react", + "version": "0.0.1", + "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/tenants-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/tenants-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/tenants-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/tenants-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/tenants-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/tenants-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/tenants-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/tenants-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/tenants-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/tenants-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/tenants-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/tenants-react/node_modules/vitest/node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "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.2.1", diff --git a/packages/tenants-react/src/components/details/details-wrapper.tsx b/packages/tenants-react/src/components/details/details-wrapper.tsx index bc37160..1e5f3e7 100644 --- a/packages/tenants-react/src/components/details/details-wrapper.tsx +++ b/packages/tenants-react/src/components/details/details-wrapper.tsx @@ -1,18 +1,14 @@ -import classNames from 'classnames/bind'; -import style from './details.module.scss'; -import { BaseFormSection } from '@supertokens-plugin-profile/common-details-shared'; -import { User } from 'supertokens-web-js/types'; -import { useCallback, useEffect, useState } from 'react'; +import classNames from "classnames/bind"; +import { useCallback, useEffect, useState } from "react"; +import { User } from "supertokens-web-js/types"; + +import style from "./details.module.scss"; + +// import { BaseFormSection } from '@supertokens-plugin-profile/common-details-shared'; const cx = classNames.bind(style); -export const DetailsWrapper = ({ - section, - onFetch, -}: { - section: BaseFormSection; - onFetch: () => Promise<{ users: User[] }>; -}) => { +export const DetailsWrapper = ({ section, onFetch }: { section: any; onFetch: () => Promise<{ users: User[] }> }) => { const [users, setUsers] = useState([]); const loadDetails = useCallback(async () => { @@ -25,24 +21,24 @@ export const DetailsWrapper = ({ }, [loadDetails]); return ( -
-
+
+

{section.label}

{section.description}

-
+
{users.length > 0 ? ( -
+
{users.map((user) => ( -
-
{user.emails[0]?.charAt(0).toUpperCase() || 'U'}
-
{user.emails[0]}
+
+
{user.emails[0]?.charAt(0).toUpperCase() || "U"}
+
{user.emails[0]}
))}
) : ( -
+

No users found

)} diff --git a/packages/tenants-react/src/components/invitations/invitations.module.scss b/packages/tenants-react/src/components/invitations/invitations.module.scss deleted file mode 100644 index ad5a9d0..0000000 --- a/packages/tenants-react/src/components/invitations/invitations.module.scss +++ /dev/null @@ -1,66 +0,0 @@ -.invitationDetailsChild { - border-radius: 12px; - box-shadow: 0px 1.5px 2px 0px rgba(0, 0, 0, 0.133) inset; - border: 1px solid #dddde3; - background-color: #f9f9f8; - - .invitationDetailsChildHeader { - padding: 12px; - color: #60646c; - font-weight: var(--wa-font-weight-normal); - font-style: Regular; - font-size: 14px; - line-height: 20px; - letter-spacing: 0px; - - .tenantName { - font-weight: var(--wa-font-weight-semibold); - font-style: Medium; - font-size: 14px; - line-height: 20px; - letter-spacing: 0px; - } - } - - .invitationDetailsCodeContainer { - display: flex; - align-items: center; - color: #60646c; - padding: 12px; - background-color: #f2f2f0; - border-top: 1px solid #dddde3; - border-bottom-left-radius: 12px; - border-bottom-right-radius: 12px; - - .invitationCodeContainer { - margin-left: 10px; - padding: 2px 8px; - border-radius: 9999px; - box-shadow: 0px 1.5px 2px 0px rgba(0, 0, 0, 0.133) inset; - background: #00003b0d; - color: #0007139f; - } - } -} - -.invitationDetailsFooter { - display: flex; - justify-content: end; -} - -.invitationAcceptHeader { - font-weight: var(--wa-font-weight-extrabold); - font-style: Bold; - font-size: 28px; - line-height: 36px; - letter-spacing: -0.12px; - color: #1c2024; -} - -.invitationDetailsChild::part(header) { - padding: 0 !important; -} - -.invitationDetailsChild::part(body) { - padding: 0 !important; -} diff --git a/packages/tenants-react/src/components/invitations/invitations.tsx b/packages/tenants-react/src/components/invitations/invitations.tsx index a22d427..8daec9f 100644 --- a/packages/tenants-react/src/components/invitations/invitations.tsx +++ b/packages/tenants-react/src/components/invitations/invitations.tsx @@ -1,10 +1,6 @@ -import classNames from 'classnames/bind'; -import style from './invitations.module.scss'; -import { BaseFormSection } from '@supertokens-plugin-profile/common-details-shared'; -import { useCallback, useEffect, useState } from 'react'; -import { InviteeDetails } from '@supertokens-plugin-profile/tenants-shared'; - -const cx = classNames.bind(style); +import { InviteeDetails } from "@shared/tenants"; +// import { BaseFormSection } from "@supertokens-plugin-profile/common-details-shared"; +import { useCallback, useEffect, useState } from "react"; export const InvitationsWrapper = ({ section, @@ -13,7 +9,7 @@ export const InvitationsWrapper = ({ onCreate, selectedTenantId, }: { - section: BaseFormSection; + section: any; onFetch: (tenantId?: string) => Promise<{ invitations: InviteeDetails[] }>; onRemove: (email: string, tenantId?: string) => Promise; onCreate?: (email: string, tenantId: string) => Promise; @@ -21,7 +17,7 @@ export const InvitationsWrapper = ({ }) => { const [invitations, setInvitations] = useState([]); const [showInviteForm, setShowInviteForm] = useState(false); - const [inviteEmail, setInviteEmail] = useState(''); + const [inviteEmail, setInviteEmail] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [showCode, setShowCode] = useState(null); const loadDetails = useCallback( @@ -44,17 +40,19 @@ export const InvitationsWrapper = ({ const handleInviteSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!onCreate || !inviteEmail.trim()) return; + if (!onCreate || !inviteEmail.trim()) { + return; + } setIsSubmitting(true); try { await onCreate(inviteEmail.trim(), selectedTenantId); - setInviteEmail(''); + setInviteEmail(""); setShowInviteForm(false); // Reload the invitations list await loadDetails(selectedTenantId); } catch (error) { - console.error('Failed to create invitation:', error); + console.error("Failed to create invitation:", error); } finally { setIsSubmitting(false); } @@ -67,53 +65,39 @@ export const InvitationsWrapper = ({ const handleCancelInvite = () => { setShowInviteForm(false); - setInviteEmail(''); + setInviteEmail(""); }; return ( -
-
+
+

{section.label}

{section.description}

{onCreate && ( - )}
{showInviteForm && onCreate && ( -
+
-
+
setInviteEmail(e.currentTarget.value)} - className={cx('inviteEmailInput')} + className="" required disabled={isSubmitting} /> -
- -
@@ -122,39 +106,37 @@ export const InvitationsWrapper = ({
)} -
+
{invitations.length > 0 ? ( -
- {invitations.map((invitation) => ( -
-
{invitation.email.charAt(0).toUpperCase() || 'U'}
-
{invitation.email}
-
- ))} + ))} */}
) : ( -
+

No invitations found

)}
{showCode && ( -
+

{showCode}

- +
)}
diff --git a/packages/tenants-react/src/components/tenant-card/tenant-card.tsx b/packages/tenants-react/src/components/tenant-card/tenant-card.tsx index e24b97c..7460130 100644 --- a/packages/tenants-react/src/components/tenant-card/tenant-card.tsx +++ b/packages/tenants-react/src/components/tenant-card/tenant-card.tsx @@ -105,7 +105,7 @@ export const TenantCard = ({ onJoin, onCreate, isLoading }: TenantCardProps) =>
{validationError &&
{validationError}
} -
+
*/} ); }; diff --git a/packages/tenants-react/src/components/tenant-management/tenant-management.module.scss b/packages/tenants-react/src/components/tenant-management/styles.module.scss similarity index 87% rename from packages/tenants-react/src/components/tenant-management/tenant-management.module.scss rename to packages/tenants-react/src/components/tenant-management/styles.module.scss index f97233d..9c7626e 100644 --- a/packages/tenants-react/src/components/tenant-management/tenant-management.module.scss +++ b/packages/tenants-react/src/components/tenant-management/styles.module.scss @@ -4,7 +4,16 @@ max-width: 800px; margin: 0 auto; padding: 0; - font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, + font-family: + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Roboto, + Oxygen, + Ubuntu, + Cantarell, + Open Sans, + Helvetica Neue, sans-serif; color: #333; width: 100%; diff --git a/packages/tenants-react/src/components/tenant-management/tenant-management.tsx b/packages/tenants-react/src/components/tenant-management/tenant-management.tsx index 3a07310..fd23396 100644 --- a/packages/tenants-react/src/components/tenant-management/tenant-management.tsx +++ b/packages/tenants-react/src/components/tenant-management/tenant-management.tsx @@ -1,27 +1,29 @@ -// DEBUG: Modified at 17:30 on 2025-07-28 - CHECK IF THIS APPEARS IN BROWSER -import { useState, useEffect, useCallback } from 'react'; -import classNames from 'classnames/bind'; -import style from './tenant-management.module.scss'; -import { BaseFormSection } from '@supertokens-plugin-profile/common-details-shared'; -import { usePlugin } from '../../use-plugin'; -import { TenantDetails } from '@supertokens-plugin-profile/tenants-shared'; -import { DetailsWrapper } from '../details/details-wrapper'; -import { InvitationsWrapper } from '../invitations/invitations'; -import { SelectInput } from '@supertokens-plugin-profile/common-frontend'; +import { TenantDetails } from "@shared/tenants"; +import { SelectInput, TabGroup, Tab } from "@shared/ui"; +// import { BaseFormSection } from "@supertokens-plugin-profile/common-details-shared"; +import classNames from "classnames/bind"; +import { useState, useEffect, useCallback } from "react"; + +import { usePluginContext } from "../../plugin"; +import { DetailsWrapper } from "../details/details-wrapper"; +import { InvitationsWrapper } from "../invitations/invitations"; + +import style from "./styles.module.scss"; const cx = classNames.bind(style); -export const TenantManagement = ({ section }: { section: BaseFormSection }) => { - const { getUsers, getInvitations, removeInvitation, addInvitation, fetchTenants, switchTenant } = usePlugin(); +export const TenantManagement = ({ section }: { section: any }) => { + const { api } = usePluginContext(); + const { getUsers, getInvitations, removeInvitation, addInvitation, fetchTenants, switchTenant } = api; const [tenants, setTenants] = useState([]); - const [selectedTenantId, setSelectedTenantId] = useState('public'); - const [activeTab, setActiveTab] = useState<'users' | 'invitations'>('users'); + const [selectedTenantId, setSelectedTenantId] = useState("public"); + const [activeTab, setActiveTab] = useState<"users" | "invitations">("users"); // Load tenants on component mount useEffect(() => { const loadTenants = async () => { const response = await fetchTenants(); - if (response.status === 'OK') { + if (response.status === "OK") { setTenants(response.tenants); if (response.tenants.length > 0) { setSelectedTenantId(response.tenants[0].tenantId); @@ -34,7 +36,7 @@ export const TenantManagement = ({ section }: { section: BaseFormSection }) => { // Users tab functions const onFetchUsers = useCallback(async () => { const response = await getUsers(selectedTenantId); - if (response.status === 'ERROR') { + if (response.status === "ERROR") { throw new Error(response.message); } return { users: response.users }; @@ -43,8 +45,8 @@ export const TenantManagement = ({ section }: { section: BaseFormSection }) => { // Invitations tab functions const onFetchInvitations = useCallback( async (tenantId?: string) => { - const response = await getInvitations(tenantId || selectedTenantId); - if (response.status === 'ERROR') { + const response = await getInvitations(); + if (response.status === "ERROR") { throw new Error(response.message); } return { invitations: response.invitees }; @@ -55,7 +57,7 @@ export const TenantManagement = ({ section }: { section: BaseFormSection }) => { const onRemoveInvite = useCallback( async (email: string, tenantId?: string) => { const response = await removeInvitation(email, tenantId || selectedTenantId); - if (response.status === 'ERROR') { + if (response.status === "ERROR") { throw new Error(response.message); } }, @@ -65,7 +67,7 @@ export const TenantManagement = ({ section }: { section: BaseFormSection }) => { const onCreateInvite = useCallback( async (email: string, tenantId: string) => { const response = await addInvitation(email, tenantId); - if (response.status === 'ERROR') { + if (response.status === "ERROR") { throw new Error(response.message); } }, @@ -75,18 +77,18 @@ export const TenantManagement = ({ section }: { section: BaseFormSection }) => { const handleTenantSwitch = useCallback( async (tenantId: string) => { const response = await switchTenant(tenantId); - if (response.status === 'OK') { + if (response.status === "OK") { setSelectedTenantId(tenantId); } else { - console.error('Failed to switch tenant:', response.message); + console.error("Failed to switch tenant:", response.message); } }, [switchTenant], ); return ( -
-
+
+

{section.label}

{section.description}

@@ -94,7 +96,7 @@ export const TenantManagement = ({ section }: { section: BaseFormSection }) => { {/* Tenant Switcher */} {tenants.length > 0 && ( -
+
{ onChange={(e: any) => handleTenantSwitch(e.target.value)} name="Tenant Switcher" options={tenants.map(({ tenantId }) => ({ - label: tenantId === 'public' ? 'Public' : tenantId, + label: tenantId === "public" ? "Public" : tenantId, value: tenantId, }))} /> @@ -111,38 +113,46 @@ export const TenantManagement = ({ section }: { section: BaseFormSection }) => {
{/* Tab Navigation */} -
-
{/* Tab Content */} -
- {activeTab === 'users' && selectedTenantId && ( +
+ {activeTab === "users" && selectedTenantId && ( )} - {activeTab === 'invitations' && selectedTenantId && ( + {activeTab === "invitations" && selectedTenantId && ( { +export const TenantDetailsWrapper = ({ section }: { section: any }) => { const { api } = usePluginContext(); const onFetch = useCallback(async () => { From a5565e5840053ece3c2ec2f691f52d4b012be719 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 5 Sep 2025 14:18:37 +0530 Subject: [PATCH 04/36] feat: update tenant management with UI components --- .../tenant-management/tenant-management.tsx | 96 ++++++++----------- 1 file changed, 39 insertions(+), 57 deletions(-) diff --git a/packages/tenants-react/src/components/tenant-management/tenant-management.tsx b/packages/tenants-react/src/components/tenant-management/tenant-management.tsx index fd23396..8f6d1ad 100644 --- a/packages/tenants-react/src/components/tenant-management/tenant-management.tsx +++ b/packages/tenants-react/src/components/tenant-management/tenant-management.tsx @@ -13,11 +13,10 @@ import style from "./styles.module.scss"; const cx = classNames.bind(style); export const TenantManagement = ({ section }: { section: any }) => { - const { api } = usePluginContext(); + const { api, t } = usePluginContext(); const { getUsers, getInvitations, removeInvitation, addInvitation, fetchTenants, switchTenant } = api; const [tenants, setTenants] = useState([]); const [selectedTenantId, setSelectedTenantId] = useState("public"); - const [activeTab, setActiveTab] = useState<"users" | "invitations">("users"); // Load tenants on component mount useEffect(() => { @@ -25,8 +24,10 @@ export const TenantManagement = ({ section }: { section: any }) => { const response = await fetchTenants(); if (response.status === "OK") { setTenants(response.tenants); + + // TODO: Set the selected tenant from the user details if (response.tenants.length > 0) { - setSelectedTenantId(response.tenants[0].tenantId); + setSelectedTenantId(response.tenants[0]!.tenantId); } } }; @@ -35,38 +36,38 @@ export const TenantManagement = ({ section }: { section: any }) => { // Users tab functions const onFetchUsers = useCallback(async () => { - const response = await getUsers(selectedTenantId); + const response = await getUsers(); if (response.status === "ERROR") { throw new Error(response.message); } return { users: response.users }; - }, [getUsers, selectedTenantId]); + }, [getUsers]); // Invitations tab functions const onFetchInvitations = useCallback( - async (tenantId?: string) => { + async () => { const response = await getInvitations(); if (response.status === "ERROR") { throw new Error(response.message); } return { invitations: response.invitees }; }, - [getInvitations, selectedTenantId], + [getInvitations], ); const onRemoveInvite = useCallback( - async (email: string, tenantId?: string) => { - const response = await removeInvitation(email, tenantId || selectedTenantId); + async (email: string) => { + const response = await removeInvitation(email); if (response.status === "ERROR") { throw new Error(response.message); } }, - [removeInvitation, selectedTenantId], + [removeInvitation], ); const onCreateInvite = useCallback( - async (email: string, tenantId: string) => { - const response = await addInvitation(email, tenantId); + async (email: string) => { + const response = await addInvitation(email); if (response.status === "ERROR") { throw new Error(response.message); } @@ -113,55 +114,36 @@ export const TenantManagement = ({ section }: { section: any }) => {
{/* Tab Navigation */} -
- - - + + + + + + +
- -
- - -
- - {/* Tab Content */} -
- {activeTab === "users" && selectedTenantId && ( - - )} - - {activeTab === "invitations" && selectedTenantId && ( - - )} -
); }; From 33b1650de72cfee98e1842dc3493c2ca4276dc22 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 5 Sep 2025 15:54:59 +0530 Subject: [PATCH 05/36] feat: add support for tenants tab/table structure and use in users tab --- .../components/details/details-wrapper.tsx | 48 ------ .../components/details/details.module.scss | 96 ----------- .../tenants-react/src/components/index.ts | 1 - .../src/components/tenant-management/index.ts | 1 - .../tenant-management/styles.module.scss | 80 ---------- .../tenant-management/tenant-management.tsx | 149 ------------------ .../src/tenant-details-wrapper.tsx | 8 +- 7 files changed, 4 insertions(+), 379 deletions(-) delete mode 100644 packages/tenants-react/src/components/details/details-wrapper.tsx delete mode 100644 packages/tenants-react/src/components/details/details.module.scss delete mode 100644 packages/tenants-react/src/components/tenant-management/index.ts delete mode 100644 packages/tenants-react/src/components/tenant-management/styles.module.scss delete mode 100644 packages/tenants-react/src/components/tenant-management/tenant-management.tsx diff --git a/packages/tenants-react/src/components/details/details-wrapper.tsx b/packages/tenants-react/src/components/details/details-wrapper.tsx deleted file mode 100644 index 1e5f3e7..0000000 --- a/packages/tenants-react/src/components/details/details-wrapper.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import classNames from "classnames/bind"; -import { useCallback, useEffect, useState } from "react"; -import { User } from "supertokens-web-js/types"; - -import style from "./details.module.scss"; - -// import { BaseFormSection } from '@supertokens-plugin-profile/common-details-shared'; - -const cx = classNames.bind(style); - -export const DetailsWrapper = ({ section, onFetch }: { section: any; onFetch: () => Promise<{ users: User[] }> }) => { - const [users, setUsers] = useState([]); - - const loadDetails = useCallback(async () => { - const details = await onFetch(); - setUsers(details.users); - }, [onFetch]); - - useEffect(() => { - loadDetails(); - }, [loadDetails]); - - return ( -
-
-

{section.label}

-

{section.description}

-
- -
- {users.length > 0 ? ( -
- {users.map((user) => ( -
-
{user.emails[0]?.charAt(0).toUpperCase() || "U"}
-
{user.emails[0]}
-
- ))} -
- ) : ( -
-

No users found

-
- )} -
-
- ); -}; diff --git a/packages/tenants-react/src/components/details/details.module.scss b/packages/tenants-react/src/components/details/details.module.scss deleted file mode 100644 index 12159c4..0000000 --- a/packages/tenants-react/src/components/details/details.module.scss +++ /dev/null @@ -1,96 +0,0 @@ -:global(.pluginProfile) { - .tenantDetailsSection { - display: flex; - flex-direction: column; - max-width: 800px; - margin: 0 auto; - padding: 0; - font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, - Helvetica Neue, sans-serif; - color: #333; - width: 100%; - } - - .tenantDetailsHeader { - position: relative; - padding-bottom: 24px; - margin-bottom: 24px; - border-bottom: 1px solid #eee; - - h3 { - margin-bottom: 12px; - } - } - - .tenantDetailsContent { - display: flex; - flex-direction: column; - gap: 12px; - } - - .tenantDetailsUsers { - display: flex; - flex-direction: column; - gap: 8px; - } - - .userRow { - display: flex; - align-items: center; - gap: 12px; - padding: 8px 12px; - border: 1px solid #eee; - border-radius: 6px; - background-color: #fafafa; - } - - .userAvatar { - width: 32px; - height: 32px; - border-radius: 50%; - background-color: #007bff; - color: white; - display: flex; - align-items: center; - justify-content: center; - font-weight: 600; - font-size: 14px; - flex-shrink: 0; - } - - .userEmail { - flex: 1; - font-size: 14px; - color: #333; - } - - .removeButton { - width: 24px; - height: 24px; - border: none; - background-color: #dc3545; - color: white; - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - font-weight: bold; - flex-shrink: 0; - transition: background-color 0.2s; - - &:hover { - background-color: #c82333; - } - } - - .tenantDetailsNoUsers { - display: flex; - flex-direction: column; - gap: 12px; - padding: 12px; - border: 1px solid #eee; - border-radius: 4px; - } -} diff --git a/packages/tenants-react/src/components/index.ts b/packages/tenants-react/src/components/index.ts index ceaba10..8904c56 100644 --- a/packages/tenants-react/src/components/index.ts +++ b/packages/tenants-react/src/components/index.ts @@ -1,3 +1,2 @@ export * from "./tenant-card"; export * from "./page-wrapper"; -export * from "./tenant-management"; diff --git a/packages/tenants-react/src/components/tenant-management/index.ts b/packages/tenants-react/src/components/tenant-management/index.ts deleted file mode 100644 index e489b8c..0000000 --- a/packages/tenants-react/src/components/tenant-management/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TenantManagement } from './tenant-management'; \ No newline at end of file diff --git a/packages/tenants-react/src/components/tenant-management/styles.module.scss b/packages/tenants-react/src/components/tenant-management/styles.module.scss deleted file mode 100644 index 9c7626e..0000000 --- a/packages/tenants-react/src/components/tenant-management/styles.module.scss +++ /dev/null @@ -1,80 +0,0 @@ -.tenantManagement { - display: flex; - flex-direction: column; - max-width: 800px; - margin: 0 auto; - padding: 0; - font-family: - -apple-system, - BlinkMacSystemFont, - Segoe UI, - Roboto, - Oxygen, - Ubuntu, - Cantarell, - Open Sans, - Helvetica Neue, - sans-serif; - color: #333; - width: 100%; -} - -.tenantManagementHeader { - padding-bottom: 24px; - margin-bottom: 24px; - border-bottom: 1px solid #eee; - display: flex; - justify-content: space-between; - align-items: center; - - h3 { - margin-bottom: 12px; - font-size: 24px; - font-weight: 600; - } - - p { - margin-bottom: 16px; - color: #666; - } -} - -.tenantSwitcherWrapper wa-select::part(form-control) { - display: flex; - align-items: center; -} - -.tabNavigation { - display: flex; - gap: 0; - margin-bottom: 24px; - border-bottom: 1px solid #eee; -} - -.tabButton { - padding: 12px 24px; - background: none; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - font-size: 14px; - font-weight: 500; - color: #666; - transition: all 0.2s; - - &:hover { - color: #333; - background-color: #f8f9fa; - } - - &.active { - color: #007bff; - border-bottom-color: #007bff; - background-color: #f8f9fa; - } -} - -.tabContent { - flex: 1; - min-height: 400px; -} diff --git a/packages/tenants-react/src/components/tenant-management/tenant-management.tsx b/packages/tenants-react/src/components/tenant-management/tenant-management.tsx deleted file mode 100644 index 8f6d1ad..0000000 --- a/packages/tenants-react/src/components/tenant-management/tenant-management.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { TenantDetails } from "@shared/tenants"; -import { SelectInput, TabGroup, Tab } from "@shared/ui"; -// import { BaseFormSection } from "@supertokens-plugin-profile/common-details-shared"; -import classNames from "classnames/bind"; -import { useState, useEffect, useCallback } from "react"; - -import { usePluginContext } from "../../plugin"; -import { DetailsWrapper } from "../details/details-wrapper"; -import { InvitationsWrapper } from "../invitations/invitations"; - -import style from "./styles.module.scss"; - -const cx = classNames.bind(style); - -export const TenantManagement = ({ section }: { section: any }) => { - const { api, t } = usePluginContext(); - const { getUsers, getInvitations, removeInvitation, addInvitation, fetchTenants, switchTenant } = api; - const [tenants, setTenants] = useState([]); - const [selectedTenantId, setSelectedTenantId] = useState("public"); - - // Load tenants on component mount - useEffect(() => { - const loadTenants = async () => { - const response = await fetchTenants(); - if (response.status === "OK") { - setTenants(response.tenants); - - // TODO: Set the selected tenant from the user details - if (response.tenants.length > 0) { - setSelectedTenantId(response.tenants[0]!.tenantId); - } - } - }; - loadTenants(); - }, [fetchTenants]); - - // Users tab functions - const onFetchUsers = useCallback(async () => { - const response = await getUsers(); - if (response.status === "ERROR") { - throw new Error(response.message); - } - return { users: response.users }; - }, [getUsers]); - - // Invitations tab functions - const onFetchInvitations = useCallback( - async () => { - const response = await getInvitations(); - if (response.status === "ERROR") { - throw new Error(response.message); - } - return { invitations: response.invitees }; - }, - [getInvitations], - ); - - const onRemoveInvite = useCallback( - async (email: string) => { - const response = await removeInvitation(email); - if (response.status === "ERROR") { - throw new Error(response.message); - } - }, - [removeInvitation], - ); - - const onCreateInvite = useCallback( - async (email: string) => { - const response = await addInvitation(email); - if (response.status === "ERROR") { - throw new Error(response.message); - } - }, - [addInvitation], - ); - - const handleTenantSwitch = useCallback( - async (tenantId: string) => { - const response = await switchTenant(tenantId); - if (response.status === "OK") { - setSelectedTenantId(tenantId); - } else { - console.error("Failed to switch tenant:", response.message); - } - }, - [switchTenant], - ); - - return ( -
-
-
-

{section.label}

-

{section.description}

-
- - {/* Tenant Switcher */} - {tenants.length > 0 && ( -
- handleTenantSwitch(e.target.value)} - name="Tenant Switcher" - options={tenants.map(({ tenantId }) => ({ - label: tenantId === "public" ? "Public" : tenantId, - value: tenantId, - }))} - /> -
- )} -
- - {/* Tab Navigation */} -
- - - - - - - - - -
-
- ); -}; diff --git a/packages/tenants-react/src/tenant-details-wrapper.tsx b/packages/tenants-react/src/tenant-details-wrapper.tsx index 31fd996..cf7f939 100644 --- a/packages/tenants-react/src/tenant-details-wrapper.tsx +++ b/packages/tenants-react/src/tenant-details-wrapper.tsx @@ -1,8 +1,8 @@ // import { BaseFormSection } from "@supertokens-plugin-profile/common-details-shared"; -import { useCallback } from "react"; +import { useCallback } from 'react'; -import { DetailsWrapper } from "./components/details/details-wrapper"; -import { usePluginContext } from "./plugin"; +import { DetailsWrapper } from './components/users/TenantUsers'; +import { usePluginContext } from './plugin'; export const TenantDetailsWrapper = ({ section }: { section: any }) => { const { api } = usePluginContext(); @@ -11,7 +11,7 @@ export const TenantDetailsWrapper = ({ section }: { section: any }) => { // Use the `tid` from the users access token payload. const response = await api.getUsers(); - if (response.status === "ERROR") { + if (response.status === 'ERROR') { throw new Error(response.message); } return { users: response.users }; From 969b0083cc0d2f039ac17a34ad190c232dce9e77 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Mon, 8 Sep 2025 16:21:41 +0530 Subject: [PATCH 06/36] fix: styling issues with wa tab components --- package-lock.json | 1 + .../tenants-react/src/components/tenant-card/tenant-card.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index ef5f690..34c74a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21704,6 +21704,7 @@ "rollup-plugin-peer-deps-external": "^2.2.4", "typescript": "^5.8.3", "vite": "^6.3.5", + "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-dts": "^4.5.4", "vitest": "^1.3.1" }, diff --git a/packages/tenants-react/src/components/tenant-card/tenant-card.tsx b/packages/tenants-react/src/components/tenant-card/tenant-card.tsx index 7460130..e24b97c 100644 --- a/packages/tenants-react/src/components/tenant-card/tenant-card.tsx +++ b/packages/tenants-react/src/components/tenant-card/tenant-card.tsx @@ -105,7 +105,7 @@ export const TenantCard = ({ onJoin, onCreate, isLoading }: TenantCardProps) =>
{validationError &&
{validationError}
} -
*/} +
); }; From 4645236bb37c58126735cb702ec2fc6a6858e904 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Tue, 9 Sep 2025 13:59:09 +0530 Subject: [PATCH 07/36] feat: add UI changes for tenant table and users management --- packages/tenants-nodejs/src/recipeImplementation.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tenants-nodejs/src/recipeImplementation.ts b/packages/tenants-nodejs/src/recipeImplementation.ts index 8d7bb92..15c4ea3 100644 --- a/packages/tenants-nodejs/src/recipeImplementation.ts +++ b/packages/tenants-nodejs/src/recipeImplementation.ts @@ -39,7 +39,8 @@ export const getOverrideableTenantFunctionImplementation = ( // Return the tenants that the user is not a member of return { - ...tenantDetails, + status: "OK", + tenants: tenantDetails.tenants.map((tenant) => ({ tenantId: tenant.tenantId, displayName: tenant.tenantId })), joinedTenantIds: userDetails.tenantIds, }; }, From a6240429f14276b719492bad51fac79e722e7f6e Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Thu, 11 Sep 2025 12:43:00 +0530 Subject: [PATCH 08/36] feat: add support for adding invites with the new UI --- .../components/invitations/invitations.tsx | 158 +++++------------- .../components/invitations/invitationsOld.tsx | 144 ++++++++++++++++ shared/tenants/src/types.ts | 1 - 3 files changed, 182 insertions(+), 121 deletions(-) create mode 100644 packages/tenants-react/src/components/invitations/invitationsOld.tsx diff --git a/packages/tenants-react/src/components/invitations/invitations.tsx b/packages/tenants-react/src/components/invitations/invitations.tsx index 8daec9f..415dcc1 100644 --- a/packages/tenants-react/src/components/invitations/invitations.tsx +++ b/packages/tenants-react/src/components/invitations/invitations.tsx @@ -1,25 +1,23 @@ import { InviteeDetails } from "@shared/tenants"; -// import { BaseFormSection } from "@supertokens-plugin-profile/common-details-shared"; import { useCallback, useEffect, useState } from "react"; -export const InvitationsWrapper = ({ - section, - onFetch, - onRemove, - onCreate, - selectedTenantId, -}: { - section: any; +import { usePluginContext } from "../../plugin"; +import { TenantTab } from "../tab/TenantTab"; + +import { AddInvitation } from "./AddInvitation"; +import { InvitedUsers } from "./InvitedUsers"; + +type InvitationsProps = { onFetch: (tenantId?: string) => Promise<{ invitations: InviteeDetails[] }>; - onRemove: (email: string, tenantId?: string) => Promise; - onCreate?: (email: string, tenantId: string) => Promise; selectedTenantId: string; -}) => { +}; + +export const Invitations: React.FC = ({ selectedTenantId, onFetch }) => { + const { api } = usePluginContext(); + const { addInvitation } = api; + const [invitations, setInvitations] = useState([]); - const [showInviteForm, setShowInviteForm] = useState(false); - const [inviteEmail, setInviteEmail] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [showCode, setShowCode] = useState(null); + const loadDetails = useCallback( async (tenantId?: string) => { const details = await onFetch(tenantId || selectedTenantId); @@ -28,117 +26,37 @@ export const InvitationsWrapper = ({ [onFetch, selectedTenantId], ); - const handleShowCode = (code: string) => { - setShowCode(code); - }; - useEffect(() => { if (selectedTenantId) { loadDetails(selectedTenantId); } }, [selectedTenantId, loadDetails]); - const handleInviteSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!onCreate || !inviteEmail.trim()) { - return; - } - - setIsSubmitting(true); - try { - await onCreate(inviteEmail.trim(), selectedTenantId); - setInviteEmail(""); - setShowInviteForm(false); - // Reload the invitations list - await loadDetails(selectedTenantId); - } catch (error) { - console.error("Failed to create invitation:", error); - } finally { - setIsSubmitting(false); - } - }; - - const handleRemoveInvitation = async (email: string, tenantId: string) => { - await onRemove(email, tenantId); - await loadDetails(tenantId); - }; - - const handleCancelInvite = () => { - setShowInviteForm(false); - setInviteEmail(""); - }; + const onCreateInvite = useCallback( + async (email: string) => { + const response = await addInvitation(email); + if (response.status === "ERROR") { + throw new Error(response.message); + } + + // If `OK` status, add the newly added invitation to the + // list of invitations. + setInvitations((currentInvitations) => [ + ...currentInvitations, + { + email, + code: response.code, + }, + ]); + }, + [addInvitation], + ); return ( -
-
-

{section.label}

-

{section.description}

- {onCreate && ( - - )} -
- - {showInviteForm && onCreate && ( -
- -
- setInviteEmail(e.currentTarget.value)} - className="" - required - disabled={isSubmitting} - /> -
- - -
-
- -
- )} - -
- {invitations.length > 0 ? ( -
- {/* {invitations.map((invitation) => ( -
-
-
- - -
- ))} */} -
- ) : ( -
-

No invitations found

-
- )} -
- - {showCode && ( -
-

{showCode}

- -
- )} -
+ }> + + ); }; diff --git a/packages/tenants-react/src/components/invitations/invitationsOld.tsx b/packages/tenants-react/src/components/invitations/invitationsOld.tsx new file mode 100644 index 0000000..8daec9f --- /dev/null +++ b/packages/tenants-react/src/components/invitations/invitationsOld.tsx @@ -0,0 +1,144 @@ +import { InviteeDetails } from "@shared/tenants"; +// import { BaseFormSection } from "@supertokens-plugin-profile/common-details-shared"; +import { useCallback, useEffect, useState } from "react"; + +export const InvitationsWrapper = ({ + section, + onFetch, + onRemove, + onCreate, + selectedTenantId, +}: { + section: any; + onFetch: (tenantId?: string) => Promise<{ invitations: InviteeDetails[] }>; + onRemove: (email: string, tenantId?: string) => Promise; + onCreate?: (email: string, tenantId: string) => Promise; + selectedTenantId: string; +}) => { + const [invitations, setInvitations] = useState([]); + const [showInviteForm, setShowInviteForm] = useState(false); + const [inviteEmail, setInviteEmail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showCode, setShowCode] = useState(null); + const loadDetails = useCallback( + async (tenantId?: string) => { + const details = await onFetch(tenantId || selectedTenantId); + setInvitations(details.invitations); + }, + [onFetch, selectedTenantId], + ); + + const handleShowCode = (code: string) => { + setShowCode(code); + }; + + useEffect(() => { + if (selectedTenantId) { + loadDetails(selectedTenantId); + } + }, [selectedTenantId, loadDetails]); + + const handleInviteSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!onCreate || !inviteEmail.trim()) { + return; + } + + setIsSubmitting(true); + try { + await onCreate(inviteEmail.trim(), selectedTenantId); + setInviteEmail(""); + setShowInviteForm(false); + // Reload the invitations list + await loadDetails(selectedTenantId); + } catch (error) { + console.error("Failed to create invitation:", error); + } finally { + setIsSubmitting(false); + } + }; + + const handleRemoveInvitation = async (email: string, tenantId: string) => { + await onRemove(email, tenantId); + await loadDetails(tenantId); + }; + + const handleCancelInvite = () => { + setShowInviteForm(false); + setInviteEmail(""); + }; + + return ( +
+
+

{section.label}

+

{section.description}

+ {onCreate && ( + + )} +
+ + {showInviteForm && onCreate && ( +
+
+
+ setInviteEmail(e.currentTarget.value)} + className="" + required + disabled={isSubmitting} + /> +
+ + +
+
+
+
+ )} + +
+ {invitations.length > 0 ? ( +
+ {/* {invitations.map((invitation) => ( +
+
+
+ + +
+ ))} */} +
+ ) : ( +
+

No invitations found

+
+ )} +
+ + {showCode && ( +
+

{showCode}

+ +
+ )} +
+ ); +}; diff --git a/shared/tenants/src/types.ts b/shared/tenants/src/types.ts index 2720152..9356e60 100644 --- a/shared/tenants/src/types.ts +++ b/shared/tenants/src/types.ts @@ -20,7 +20,6 @@ export type TenantList = { export type InviteeDetails = { email: string; - role: string; code: string; }; From d3eb04aaf93b3eb64166b7a37ca25b76e21f8fbd Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Thu, 11 Sep 2025 13:18:49 +0530 Subject: [PATCH 09/36] feat: add support for removing invitations --- .../components/invitations/invitations.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/tenants-react/src/components/invitations/invitations.tsx b/packages/tenants-react/src/components/invitations/invitations.tsx index 415dcc1..fdfbb40 100644 --- a/packages/tenants-react/src/components/invitations/invitations.tsx +++ b/packages/tenants-react/src/components/invitations/invitations.tsx @@ -1,4 +1,5 @@ import { InviteeDetails } from "@shared/tenants"; +import { usePrettyAction } from "@shared/ui"; import { useCallback, useEffect, useState } from "react"; import { usePluginContext } from "../../plugin"; @@ -14,7 +15,7 @@ type InvitationsProps = { export const Invitations: React.FC = ({ selectedTenantId, onFetch }) => { const { api } = usePluginContext(); - const { addInvitation } = api; + const { addInvitation, removeInvitation } = api; const [invitations, setInvitations] = useState([]); @@ -52,11 +53,26 @@ export const Invitations: React.FC = ({ selectedTenantId, onFe [addInvitation], ); + const onRemoveInvite = usePrettyAction( + async (email: string) => { + const response = await removeInvitation(email); + if (response.status === "ERROR") { + throw new Error(response.message); + } + + // If it was successful, remove the invitation from the + // list. + setInvitations((currentInvitations) => currentInvitations.filter((invitation) => invitation.email !== email)); + }, + [removeInvitation], + { errorMessage: "Failed to remove invitation, please try again" } + ); + return ( }> - + ); }; From b02201ad125bb1e4ab91b449a11715b8f7b3d14b Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 12 Sep 2025 12:51:29 +0530 Subject: [PATCH 10/36] feat: add support for viewing code and copying it for invitation --- .../tenants-react/src/components/invitations/invitations.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tenants-react/src/components/invitations/invitations.tsx b/packages/tenants-react/src/components/invitations/invitations.tsx index fdfbb40..578229c 100644 --- a/packages/tenants-react/src/components/invitations/invitations.tsx +++ b/packages/tenants-react/src/components/invitations/invitations.tsx @@ -65,14 +65,14 @@ export const Invitations: React.FC = ({ selectedTenantId, onFe setInvitations((currentInvitations) => currentInvitations.filter((invitation) => invitation.email !== email)); }, [removeInvitation], - { errorMessage: "Failed to remove invitation, please try again" } + { errorMessage: "Failed to remove invitation, please try again" }, ); return ( }> - + ); }; From 7bcb04b43f14dac9745af16d34356f84f0c0d95f Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Mon, 15 Sep 2025 14:36:09 +0530 Subject: [PATCH 11/36] feat: add BE support for removing user from tenant --- packages/tenants-nodejs/src/recipeImplementation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tenants-nodejs/src/recipeImplementation.ts b/packages/tenants-nodejs/src/recipeImplementation.ts index 15c4ea3..d2c3718 100644 --- a/packages/tenants-nodejs/src/recipeImplementation.ts +++ b/packages/tenants-nodejs/src/recipeImplementation.ts @@ -238,9 +238,9 @@ export const getOverrideableTenantFunctionImplementation = ( // By default, only owners can approve tenant creation requests. return role === ROLES.ADMIN; }, - canRemoveUserFromTenant: async (user: User, role: string, session: SessionContainerInterface) => { + canRemoveUserFromTenant: async (user: User, roles: string[], session: SessionContainerInterface) => { // By default, only owners can remove users from a tenant. - return role === ROLES.ADMIN; + return roles.includes(ROLES.ADMIN); }, associateAllLoginMethodsOfUserWithTenant: async ( tenantId: string, From b4e1dc3b3ba3cf144139c8bbc721e6a855521066 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Tue, 16 Sep 2025 12:51:42 +0530 Subject: [PATCH 12/36] feat: add init UI structure for tenant requests --- .../components/invitations/invitationsOld.tsx | 144 ------------------ .../components/requests/TenantRequests.tsx | 34 +++++ 2 files changed, 34 insertions(+), 144 deletions(-) delete mode 100644 packages/tenants-react/src/components/invitations/invitationsOld.tsx create mode 100644 packages/tenants-react/src/components/requests/TenantRequests.tsx diff --git a/packages/tenants-react/src/components/invitations/invitationsOld.tsx b/packages/tenants-react/src/components/invitations/invitationsOld.tsx deleted file mode 100644 index 8daec9f..0000000 --- a/packages/tenants-react/src/components/invitations/invitationsOld.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { InviteeDetails } from "@shared/tenants"; -// import { BaseFormSection } from "@supertokens-plugin-profile/common-details-shared"; -import { useCallback, useEffect, useState } from "react"; - -export const InvitationsWrapper = ({ - section, - onFetch, - onRemove, - onCreate, - selectedTenantId, -}: { - section: any; - onFetch: (tenantId?: string) => Promise<{ invitations: InviteeDetails[] }>; - onRemove: (email: string, tenantId?: string) => Promise; - onCreate?: (email: string, tenantId: string) => Promise; - selectedTenantId: string; -}) => { - const [invitations, setInvitations] = useState([]); - const [showInviteForm, setShowInviteForm] = useState(false); - const [inviteEmail, setInviteEmail] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [showCode, setShowCode] = useState(null); - const loadDetails = useCallback( - async (tenantId?: string) => { - const details = await onFetch(tenantId || selectedTenantId); - setInvitations(details.invitations); - }, - [onFetch, selectedTenantId], - ); - - const handleShowCode = (code: string) => { - setShowCode(code); - }; - - useEffect(() => { - if (selectedTenantId) { - loadDetails(selectedTenantId); - } - }, [selectedTenantId, loadDetails]); - - const handleInviteSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!onCreate || !inviteEmail.trim()) { - return; - } - - setIsSubmitting(true); - try { - await onCreate(inviteEmail.trim(), selectedTenantId); - setInviteEmail(""); - setShowInviteForm(false); - // Reload the invitations list - await loadDetails(selectedTenantId); - } catch (error) { - console.error("Failed to create invitation:", error); - } finally { - setIsSubmitting(false); - } - }; - - const handleRemoveInvitation = async (email: string, tenantId: string) => { - await onRemove(email, tenantId); - await loadDetails(tenantId); - }; - - const handleCancelInvite = () => { - setShowInviteForm(false); - setInviteEmail(""); - }; - - return ( -
-
-

{section.label}

-

{section.description}

- {onCreate && ( - - )} -
- - {showInviteForm && onCreate && ( -
-
-
- setInviteEmail(e.currentTarget.value)} - className="" - required - disabled={isSubmitting} - /> -
- - -
-
-
-
- )} - -
- {invitations.length > 0 ? ( -
- {/* {invitations.map((invitation) => ( -
-
-
- - -
- ))} */} -
- ) : ( -
-

No invitations found

-
- )} -
- - {showCode && ( -
-

{showCode}

- -
- )} -
- ); -}; diff --git a/packages/tenants-react/src/components/requests/TenantRequests.tsx b/packages/tenants-react/src/components/requests/TenantRequests.tsx new file mode 100644 index 0000000..3c818a2 --- /dev/null +++ b/packages/tenants-react/src/components/requests/TenantRequests.tsx @@ -0,0 +1,34 @@ +import { TabGroup, Tab, TabPanel } from "@shared/ui"; +import classNames from "classnames/bind"; + +import { usePluginContext } from "../../plugin"; + +import style from "./requests.module.scss"; +import { TenantTab } from "../tab/TenantTab"; + +const cx = classNames.bind(style); + +export const TenantRequests = () => { + const { t } = usePluginContext(); + + return ( +
+ + {t("PL_TB_TENANT_REQUESTS_ONBOARDING_TAB_LABEL")} + {t("PL_TB_TENANT_REQUESTS_CREATION_TAB_LABEL")} + + {/* Tab Content */} + + +
+
+
+ + +
+
+
+
+
+ ); +}; From c4c59832d4f837f56e5ae5f599790c445260422c Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Wed, 17 Sep 2025 12:50:32 +0530 Subject: [PATCH 13/36] feat: add support for tenant requests - onboarding accept/decline --- packages/tenants-nodejs/src/plugin.ts | 1 + .../tenants-react/src/components/requests/TenantRequests.tsx | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/tenants-nodejs/src/plugin.ts b/packages/tenants-nodejs/src/plugin.ts index e8ae81c..18e58ff 100644 --- a/packages/tenants-nodejs/src/plugin.ts +++ b/packages/tenants-nodejs/src/plugin.ts @@ -8,6 +8,7 @@ import { PermissionClaim } from "supertokens-node/recipe/userroles"; import { createPluginInitFunction } from "@shared/js"; import { pluginUserMetadata, withRequestHandler } from "@shared/nodejs"; +import { SessionClaimValidator } from "supertokens-node/recipe/session"; import { OverrideableTenantFunctionImplementation, diff --git a/packages/tenants-react/src/components/requests/TenantRequests.tsx b/packages/tenants-react/src/components/requests/TenantRequests.tsx index 3c818a2..cbe5752 100644 --- a/packages/tenants-react/src/components/requests/TenantRequests.tsx +++ b/packages/tenants-react/src/components/requests/TenantRequests.tsx @@ -2,9 +2,10 @@ import { TabGroup, Tab, TabPanel } from "@shared/ui"; import classNames from "classnames/bind"; import { usePluginContext } from "../../plugin"; +import { TenantTab } from "../tab/TenantTab"; +import { OnboardingRequests } from "./OnboardingRequests"; import style from "./requests.module.scss"; -import { TenantTab } from "../tab/TenantTab"; const cx = classNames.bind(style); @@ -20,7 +21,7 @@ export const TenantRequests = () => { {/* Tab Content */} -
+
From d1bc1339951023f42d15719d5283288c71ff113d Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Thu, 18 Sep 2025 12:42:54 +0530 Subject: [PATCH 14/36] feat: add support for rendering tenant creation requests --- .../src/recipeImplementation.ts | 25 ++++++++++++++++--- .../components/requests/TenantRequests.tsx | 3 ++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/tenants-nodejs/src/recipeImplementation.ts b/packages/tenants-nodejs/src/recipeImplementation.ts index d2c3718..7e76ae4 100644 --- a/packages/tenants-nodejs/src/recipeImplementation.ts +++ b/packages/tenants-nodejs/src/recipeImplementation.ts @@ -2,8 +2,8 @@ import supertokens from "supertokens-node"; import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; import MultiTenancy from "supertokens-node/recipe/multitenancy"; -import { InviteeDetails, ROLES, TenantList } from "@shared/tenants"; -import { User } from "supertokens-node/types"; +import { InviteeDetails, ROLES, TenantCreationRequestWithUser, TenantList } from "@shared/tenants"; +import { User, UserContext } from "supertokens-node/types"; import { ErrorResponse, MetadataType, @@ -307,11 +307,28 @@ export const getOverrideableTenantFunctionImplementation = ( requestId, }; }, - getTenantCreationRequests: async (metadata: TenantCreationRequestMetadataType) => { + getTenantCreationRequests: async (metadata: TenantCreationRequestMetadataType, userContext: UserContext) => { const tenantCreateRequestMetadata = await metadata.get(TENANT_CREATE_METADATA_REQUESTS_KEY); + + // Fetch the user details for each user + const requestsWithUserId = tenantCreateRequestMetadata.requests; + const requestsWithUser: TenantCreationRequestWithUser[] = []; + + for (const request of tenantCreateRequestMetadata.requests) { + const userDetails = await supertokens.getUser(request.userId, userContext); + if (!userDetails) { + logDebugMessage( + `Couldn't find user details for tenant request ${request.requestId} and user: ${request.userId}`, + ); + continue; + } + + requestsWithUser.push({ ...request, user: userDetails }); + } + return { status: "OK", - requests: tenantCreateRequestMetadata?.requests ?? [], + requests: requestsWithUser, }; }, acceptTenantCreationRequest: async (requestId, session, metadata) => { diff --git a/packages/tenants-react/src/components/requests/TenantRequests.tsx b/packages/tenants-react/src/components/requests/TenantRequests.tsx index cbe5752..de5c9ec 100644 --- a/packages/tenants-react/src/components/requests/TenantRequests.tsx +++ b/packages/tenants-react/src/components/requests/TenantRequests.tsx @@ -4,6 +4,7 @@ import classNames from "classnames/bind"; import { usePluginContext } from "../../plugin"; import { TenantTab } from "../tab/TenantTab"; +import { CreationRequests } from "./CreationRequests"; import { OnboardingRequests } from "./OnboardingRequests"; import style from "./requests.module.scss"; @@ -26,7 +27,7 @@ export const TenantRequests = () => { -
+
From 553f5f6809ae5627d190fbfc793a28b7488c649e Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 19 Sep 2025 08:07:19 +0530 Subject: [PATCH 15/36] fix: various issues with tenant invite --- .../invitations/invitations.module.scss | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/tenants-react/src/components/invitations/invitations.module.scss diff --git a/packages/tenants-react/src/components/invitations/invitations.module.scss b/packages/tenants-react/src/components/invitations/invitations.module.scss new file mode 100644 index 0000000..ad5a9d0 --- /dev/null +++ b/packages/tenants-react/src/components/invitations/invitations.module.scss @@ -0,0 +1,66 @@ +.invitationDetailsChild { + border-radius: 12px; + box-shadow: 0px 1.5px 2px 0px rgba(0, 0, 0, 0.133) inset; + border: 1px solid #dddde3; + background-color: #f9f9f8; + + .invitationDetailsChildHeader { + padding: 12px; + color: #60646c; + font-weight: var(--wa-font-weight-normal); + font-style: Regular; + font-size: 14px; + line-height: 20px; + letter-spacing: 0px; + + .tenantName { + font-weight: var(--wa-font-weight-semibold); + font-style: Medium; + font-size: 14px; + line-height: 20px; + letter-spacing: 0px; + } + } + + .invitationDetailsCodeContainer { + display: flex; + align-items: center; + color: #60646c; + padding: 12px; + background-color: #f2f2f0; + border-top: 1px solid #dddde3; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + + .invitationCodeContainer { + margin-left: 10px; + padding: 2px 8px; + border-radius: 9999px; + box-shadow: 0px 1.5px 2px 0px rgba(0, 0, 0, 0.133) inset; + background: #00003b0d; + color: #0007139f; + } + } +} + +.invitationDetailsFooter { + display: flex; + justify-content: end; +} + +.invitationAcceptHeader { + font-weight: var(--wa-font-weight-extrabold); + font-style: Bold; + font-size: 28px; + line-height: 36px; + letter-spacing: -0.12px; + color: #1c2024; +} + +.invitationDetailsChild::part(header) { + padding: 0 !important; +} + +.invitationDetailsChild::part(body) { + padding: 0 !important; +} From d53e34d149ab8999ad7cb01c042c22c820a5558f Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Tue, 23 Sep 2025 13:02:57 +0530 Subject: [PATCH 16/36] feat: add init port for tenant enrollment plugin --- .../tenant-enrollment-nodejs/.eslintrc.js | 10 + .../tenant-enrollment-nodejs/.prettierrc.js | 4 + .../tenant-enrollment-nodejs/CHANGELOG.md | 5 + .../tenant-enrollment-nodejs/package.json | 42 +++ .../tenant-enrollment-nodejs/src/constants.ts | 6 + .../tenant-enrollment-nodejs/src/index.ts | 4 + .../tenant-enrollment-nodejs/src/logger.ts | 4 + .../tenant-enrollment-nodejs/src/plugin.ts | 342 ++++++++++++++++++ .../src/recipeImplementation.ts | 170 +++++++++ .../tenant-enrollment-nodejs/src/types.ts | 62 ++++ .../tenant-enrollment-nodejs/tsconfig.json | 13 + packages/tenant-enrollment-react/.eslintrc.js | 14 + .../tenant-enrollment-react/.prettierrc.js | 4 + packages/tenant-enrollment-react/CHANGELOG.md | 5 + packages/tenant-enrollment-react/package.json | 54 +++ packages/tenant-enrollment-react/src/api.ts | 5 + .../tenant-enrollment-react/src/constants.ts | 3 + packages/tenant-enrollment-react/src/index.ts | 4 + .../tenant-enrollment-react/src/logger.ts | 5 + .../tenant-enrollment-react/src/plugin.tsx | 97 +++++ .../src/translations.ts | 4 + packages/tenant-enrollment-react/src/types.ts | 5 + .../tenant-enrollment-react/tsconfig.json | 13 + .../tenant-enrollment-react/vite.config.ts | 38 ++ .../tenant-enrollment-react/vitest.config.ts | 9 + 25 files changed, 922 insertions(+) create mode 100644 packages/tenant-enrollment-nodejs/.eslintrc.js create mode 100644 packages/tenant-enrollment-nodejs/.prettierrc.js create mode 100644 packages/tenant-enrollment-nodejs/CHANGELOG.md create mode 100644 packages/tenant-enrollment-nodejs/package.json create mode 100644 packages/tenant-enrollment-nodejs/src/constants.ts create mode 100644 packages/tenant-enrollment-nodejs/src/index.ts create mode 100644 packages/tenant-enrollment-nodejs/src/logger.ts create mode 100644 packages/tenant-enrollment-nodejs/src/plugin.ts create mode 100644 packages/tenant-enrollment-nodejs/src/recipeImplementation.ts create mode 100644 packages/tenant-enrollment-nodejs/src/types.ts create mode 100644 packages/tenant-enrollment-nodejs/tsconfig.json create mode 100644 packages/tenant-enrollment-react/.eslintrc.js create mode 100644 packages/tenant-enrollment-react/.prettierrc.js create mode 100644 packages/tenant-enrollment-react/CHANGELOG.md create mode 100644 packages/tenant-enrollment-react/package.json create mode 100644 packages/tenant-enrollment-react/src/api.ts create mode 100644 packages/tenant-enrollment-react/src/constants.ts create mode 100644 packages/tenant-enrollment-react/src/index.ts create mode 100644 packages/tenant-enrollment-react/src/logger.ts create mode 100644 packages/tenant-enrollment-react/src/plugin.tsx create mode 100644 packages/tenant-enrollment-react/src/translations.ts create mode 100644 packages/tenant-enrollment-react/src/types.ts create mode 100644 packages/tenant-enrollment-react/tsconfig.json create mode 100644 packages/tenant-enrollment-react/vite.config.ts create mode 100644 packages/tenant-enrollment-react/vitest.config.ts diff --git a/packages/tenant-enrollment-nodejs/.eslintrc.js b/packages/tenant-enrollment-nodejs/.eslintrc.js new file mode 100644 index 0000000..26db86f --- /dev/null +++ b/packages/tenant-enrollment-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/tenant-enrollment-nodejs/.prettierrc.js b/packages/tenant-enrollment-nodejs/.prettierrc.js new file mode 100644 index 0000000..8986fc5 --- /dev/null +++ b/packages/tenant-enrollment-nodejs/.prettierrc.js @@ -0,0 +1,4 @@ +/** @type {import("prettier").Config} */ +module.exports = { + ...require("@shared/eslint/prettier"), +}; diff --git a/packages/tenant-enrollment-nodejs/CHANGELOG.md b/packages/tenant-enrollment-nodejs/CHANGELOG.md new file mode 100644 index 0000000..490e78b --- /dev/null +++ b/packages/tenant-enrollment-nodejs/CHANGELOG.md @@ -0,0 +1,5 @@ +# @supertokens-plugins/tenant-enrollment-nodejs + +## 0.1.0 + +- Add the initial node tenant enrollment plugin diff --git a/packages/tenant-enrollment-nodejs/package.json b/packages/tenant-enrollment-nodejs/package.json new file mode 100644 index 0000000..15274e4 --- /dev/null +++ b/packages/tenant-enrollment-nodejs/package.json @@ -0,0 +1,42 @@ +{ + "name": "@supertokens-plugins/tenant-enrollment-nodejs", + "version": "0.1.0", + "description": "Tenant Enrollment Plugin for SuperTokens", + "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/tenant-enrollment-nodejs/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/supertokens/supertokens-plugins.git", + "directory": "packages/tenant-enrollment-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 --passWithNoTests" + }, + "keywords": [ + "tenant-enrollment", + "plugin", + "supertokens" + ], + "dependencies": {}, + "peerDependencies": { + "supertokens-node": ">=23.0.0" + }, + "devDependencies": { + "@shared/eslint": "*", + "@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", + "@shared/nodejs": "*" + }, + "browser": { + "fs": false + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/packages/tenant-enrollment-nodejs/src/constants.ts b/packages/tenant-enrollment-nodejs/src/constants.ts new file mode 100644 index 0000000..059159c --- /dev/null +++ b/packages/tenant-enrollment-nodejs/src/constants.ts @@ -0,0 +1,6 @@ +export const PLUGIN_ID = "supertokens-plugin-tenant-enrollment"; +export const PLUGIN_VERSION = "0.0.1"; + +export const PLUGIN_SDK_VERSION = ["23.0.0", "23.0.1", ">=23.0.1"]; + +export const HANDLE_BASE_PATH = `/plugin/${PLUGIN_ID}`; diff --git a/packages/tenant-enrollment-nodejs/src/index.ts b/packages/tenant-enrollment-nodejs/src/index.ts new file mode 100644 index 0000000..4080b43 --- /dev/null +++ b/packages/tenant-enrollment-nodejs/src/index.ts @@ -0,0 +1,4 @@ +import { init } from "./plugin"; +export { init }; +export { PLUGIN_ID } from "./constants"; +export default { init }; \ No newline at end of file diff --git a/packages/tenant-enrollment-nodejs/src/logger.ts b/packages/tenant-enrollment-nodejs/src/logger.ts new file mode 100644 index 0000000..5943019 --- /dev/null +++ b/packages/tenant-enrollment-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/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts new file mode 100644 index 0000000..53d4d25 --- /dev/null +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -0,0 +1,342 @@ +import { SuperTokensPlugin } from 'supertokens-node'; +import { createPluginInitFunction } from '@shared/js'; +import { PLUGIN_ID, PLUGIN_SDK_VERSION } from './constants'; +import { + OverrideableTenantFunctionImplementation, + SuperTokensPluginTenantEnrollmentPluginConfig, + SuperTokensPluginTenantEnrollmentPluginNormalisedConfig, +} from './types'; +import { getOverrideableTenantFunctionImplementation } from './recipeImplementation'; +import { logDebugMessage } from 'supertokens-node/lib/build/logger'; +import { + AssociateAllLoginMethodsOfUserWithTenant, + PLUGIN_ID as TENANTS_PLUGIN_ID, + SendPluginEmail, + GetAppUrl, +} from '@supertokens-plugins/tenants-nodejs'; +import { listUsersByAccountInfo } from 'supertokens-node'; +import { NormalisedAppinfo } from 'supertokens-node/types'; +import { enableDebugLogs } from './logger'; + +export const init = createPluginInitFunction< + SuperTokensPlugin, + SuperTokensPluginTenantEnrollmentPluginConfig, + OverrideableTenantFunctionImplementation, + SuperTokensPluginTenantEnrollmentPluginNormalisedConfig +>( + (pluginConfig, implementation) => { + let associateLoginMethodDef: AssociateAllLoginMethodsOfUserWithTenant; + let sendEmail: SendPluginEmail; + let appInfo: NormalisedAppinfo; + let getAppUrlDef: GetAppUrl; + return { + id: PLUGIN_ID, + compatibleSDKVersions: PLUGIN_SDK_VERSION, + init: (appConfig, plugins) => { + if (appConfig.debug) { + enableDebugLogs(); + } + + const tenantsPlugin = plugins.find((plugin: any) => plugin.id === TENANTS_PLUGIN_ID); + if (!tenantsPlugin) { + throw new Error('Base Tenants plugin not initialized, cannot continue.'); + } + + if (!tenantsPlugin.exports) { + throw new Error('Base Tenants plugin does not export, cannot continue.'); + } + + const associateAllLoginMethodsOfUserWithTenant = + tenantsPlugin.exports?.associateAllLoginMethodsOfUserWithTenant; + if (!associateAllLoginMethodsOfUserWithTenant) { + throw new Error('Tenants plugin does not export associateAllLoginMethodsOfUserWithTenant, cannot continue.'); + } + + const sendPluginEmail = tenantsPlugin.exports?.sendEmail; + if (!sendPluginEmail) { + throw new Error('Tenants plugin does not export sendEmail, cannot continue.'); + } + + const getUserIdsInTenantWithRole = tenantsPlugin.exports?.getUserIdsInTenantWithRole; + if (!getUserIdsInTenantWithRole) { + throw new Error('Tenants plugin does not export getUserIdsInTenantWithRole, cannot continue.'); + } + + associateLoginMethodDef = associateAllLoginMethodsOfUserWithTenant; + sendEmail = sendPluginEmail; + implementation.getUserIdsInTenantWithRole = getUserIdsInTenantWithRole; + + const getAppUrl = tenantsPlugin.exports?.getAppUrl; + if (!getAppUrl) { + throw new Error('Tenants plugin does not export getAppUrl, cannot continue'); + } + + getAppUrlDef = getAppUrl; + appInfo = appConfig.appInfo; + }, + routeHandlers: () => { + return { + status: 'OK', + routeHandlers: [], + }; + }, + overrideMap: { + emailpassword: { + functions: (originalImplementation) => { + return { + ...originalImplementation, + signUp: async (input) => { + const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { + type: 'email', + email: input.email, + }); + logDebugMessage('Reason: ' + reason); + if (!canJoin) { + return { + status: 'LINKING_TO_SESSION_USER_FAILED', + reason: 'EMAIL_VERIFICATION_REQUIRED', + }; + } + + const response = await originalImplementation.signUp(input); + if (response.status !== 'OK') { + return response; + } + + const { wasAddedToTenant, reason: tenantJoiningReason } = + await implementation.handleTenantJoiningApproval( + response.user, + input.tenantId, + associateLoginMethodDef, + sendEmail, + getAppUrlDef(appInfo, undefined, input.userContext), + input.userContext, + ); + return { + ...response, + wasAddedToTenant, + reason: tenantJoiningReason, + }; + }, + }; + }, + apis: (originalImplementation) => { + return { + ...originalImplementation, + signUpPOST: async (input) => { + const response = await originalImplementation.signUpPOST!(input); + if (response.status === 'SIGN_UP_NOT_ALLOWED' && response.reason.includes('ERR_CODE_013')) { + // There is a possibility that the user is not allowed + // to signup to the tenant so we will have to update the message + // accordingly. + return { + ...response, + reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", + }; + } + + return response; + }, + }; + }, + }, + thirdparty: { + apis: (originalImplementation) => { + return { + ...originalImplementation, + signInUpPOST: async (input) => { + const response = await originalImplementation.signInUpPOST!(input); + if (response.status === 'SIGN_IN_UP_NOT_ALLOWED' && response.reason.includes('ERR_CODE_020')) { + return { + ...response, + reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", + }; + } + + return response; + }, + }; + }, + functions: (originalImplementation) => { + return { + ...originalImplementation, + signInUp: async (input) => { + // Check if the user is signing up (i.e doesn't exist already) + // and only then apply the checks. Otherwise, we can skip. + const accountInfoResponse = await listUsersByAccountInfo(input.tenantId, { + thirdParty: { + id: input.thirdPartyId, + userId: input.thirdPartyUserId, + }, + }); + const isSignUp = accountInfoResponse.length === 0; + + if (!isSignUp) { + return originalImplementation.signInUp(input); + } + + const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { + type: 'thirdParty', + thirdPartyId: input.thirdPartyId, + }); + logDebugMessage('Reason: ' + reason); + if (!canJoin) { + return { + status: 'LINKING_TO_SESSION_USER_FAILED', + reason: 'EMAIL_VERIFICATION_REQUIRED', + }; + } + + const response = await originalImplementation.signInUp(input); + if (response.status !== 'OK') { + return response; + } + + const { wasAddedToTenant, reason: tenantJoiningReason } = + await implementation.handleTenantJoiningApproval( + response.user, + input.tenantId, + associateLoginMethodDef, + sendEmail, + getAppUrlDef(appInfo, undefined, input.userContext), + input.userContext, + ); + return { + ...response, + wasAddedToTenant, + reason: tenantJoiningReason, + }; + }, + }; + }, + }, + passwordless: { + apis: (originalImplementation) => { + return { + ...originalImplementation, + createCodePOST: async (input) => { + const response = await originalImplementation.createCodePOST!(input); + if (response.status === 'SIGN_IN_UP_NOT_ALLOWED' && response.reason.includes('ERR_CODE_002')) { + return { + ...response, + reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", + } as any; + } + + return response; + }, + consumeCodePOST: async (input) => { + const response = await originalImplementation.consumeCodePOST!(input); + if (response.status === 'SIGN_IN_UP_NOT_ALLOWED' && response.reason.includes('ERR_CODE_002')) { + return { + ...response, + reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", + } as any; + } + + return response; + }, + }; + }, + functions: (originalImplementation) => { + return { + ...originalImplementation, + createCode: async (input) => { + // If this is a signup, we need to check if the user + // can signup to the tenant. + const accountInfoResponse = await listUsersByAccountInfo(input.tenantId, { + email: 'email' in input ? input.email : undefined, + phoneNumber: 'phoneNumber' in input ? input.phoneNumber : undefined, + }); + const isSignUp = accountInfoResponse.length === 0; + + if (!isSignUp) { + return originalImplementation.createCode(input); + } + + // If this is a signup but its through phone number, we cannot + // restrict it so we will let it go through. + if ('phoneNumber' in input) { + return originalImplementation.createCode(input); + } + + const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { + type: 'email', + email: input.email, + }); + logDebugMessage('Reason: ' + reason); + + if (!canJoin) { + return { + status: 'SIGN_IN_UP_NOT_ALLOWED', + } as any; + } + + return originalImplementation.createCode(input); + }, + consumeCode: async (input) => { + // If this is a signup, we need to check if the user + // can signup to the tenant. + // We will need to fetch the details of the user from the + // deviceId. + const deviceInfo = await originalImplementation.listCodesByPreAuthSessionId({ + tenantId: input.tenantId, + preAuthSessionId: input.preAuthSessionId, + userContext: input.userContext, + }); + + if (!deviceInfo) { + // This is handled in the consumeCode but we can handle + // it here as well + return { + status: 'RESTART_FLOW_ERROR', + }; + } + + const accountInfoResponse = await listUsersByAccountInfo( + input.tenantId, + deviceInfo.phoneNumber !== undefined + ? { + phoneNumber: deviceInfo.phoneNumber!, + } + : { + email: deviceInfo.email!, + }, + ); + const isSignUp = accountInfoResponse.length === 0; + + // If this is a signup or its through phone number, we cannot + // restrict it so we will let it go through. + if (!isSignUp || deviceInfo.phoneNumber !== undefined) { + return originalImplementation.consumeCode(input); + } + + // Since this is a signup, we need to check if the user + // can signup to the tenant. + const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { + type: 'email', + email: deviceInfo.email!, + }); + logDebugMessage('Reason: ' + reason); + + if (!canJoin) { + return { + status: 'SIGN_IN_UP_NOT_ALLOWED', + } as any; + } + + return originalImplementation.consumeCode(input); + }, + }; + }, + }, + }, + }; + }, + getOverrideableTenantFunctionImplementation, + (config) => ({ + emailDomainToTenantIdMap: config.emailDomainToTenantIdMap, + inviteOnlyTenants: config.inviteOnlyTenants ?? [], + requiresApprovalTenants: config.requiresApprovalTenants ?? [], + }), +); diff --git a/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts b/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts new file mode 100644 index 0000000..cbe8bb6 --- /dev/null +++ b/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts @@ -0,0 +1,170 @@ +import { User } from 'supertokens-node'; +import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from './types'; +import { + assignRoleToUserInTenant, + AssociateAllLoginMethodsOfUserWithTenant, + SendPluginEmail, +} from '@supertokens-plugins/tenants-nodejs'; +import { ROLES } from '@shared/tenants'; +import SuperTokens from 'supertokens-node'; +import { UserContext } from 'supertokens-node/lib/build/types'; + +export const getOverrideableTenantFunctionImplementation = ( + config: SuperTokensPluginTenantEnrollmentPluginConfig, +): OverrideableTenantFunctionImplementation => { + const implementation: OverrideableTenantFunctionImplementation = { + canUserJoinTenant: async (tenantId, emailOrThirdPartyId) => { + /** + * Check if the user can join the tenant based on the email domain + * + * @param email - The email of the user + * @param tenantId - The id of the tenant + * @returns true if the user can join the tenant, false otherwise + */ + + // Skip this for the public tenant + if (tenantId === 'public') { + return { + canJoin: true, + reason: undefined, + }; + } + + // Check if the tenant is invite only in which case we + // can't allow the user to join + if (implementation.isTenantInviteOnly(tenantId)) { + return { + canJoin: false, + reason: 'INVITE_ONLY', + }; + } + + let canJoin = false; + let reason = undefined; + if (emailOrThirdPartyId.type === 'email') { + canJoin = implementation.isMatchingEmailDomain(tenantId, emailOrThirdPartyId.email); + if (!canJoin) { + reason = 'EMAIL_DOMAIN_NOT_ALLOWED'; + } + } else if (emailOrThirdPartyId.type === 'thirdParty') { + canJoin = implementation.isApprovedIdPProvider(tenantId, emailOrThirdPartyId.thirdPartyId); + if (!canJoin) { + reason = 'IDP_NOT_ALLOWED'; + } + } + + return { + canJoin, + reason, + }; + }, + handleTenantJoiningApproval: async ( + user: User, + tenantId: string, + associateLoginMethodDef: AssociateAllLoginMethodsOfUserWithTenant, + sendEmail: SendPluginEmail, + appUrl: string, + userContext: UserContext, + ) => { + /** + * Handle the tenant joining functionality for the user. + * + * If the tenant requires approval, we will add a request for the + * user. + * If the tenant doesn't require approval, we will add them as a member + * right away. + * + * @param user - The user to handle the tenant joining for + * @param tenantId - The id of the tenant to handle the tenant joining for + * @param associateLoginMethodDef - The function to associate the login methods of the user with the tenant + */ + // Skip this for the public tenant + if (tenantId === 'public') { + return { + wasAddedToTenant: true, + reason: undefined, + }; + } + + // If the tenant doesn't require approval, add the user as a member + // and return. + if (!implementation.doesTenantRequireApproval(tenantId)) { + await assignRoleToUserInTenant(tenantId, user.id, ROLES.MEMBER); + return { + wasAddedToTenant: true, + }; + } + + // If the tenant requires approval, add a request for the user + // and return. + await associateLoginMethodDef(tenantId, user.id); + + await implementation.sendTenantJoiningRequestEmail(tenantId, user, appUrl, sendEmail, userContext); + + return { + wasAddedToTenant: false, + reason: 'REQUIRES_APPROVAL', + }; + }, + isTenantInviteOnly: (tenantId) => { + return config.inviteOnlyTenants?.includes(tenantId) ?? false; + }, + doesTenantRequireApproval: (tenantId) => { + return config.requiresApprovalTenants?.includes(tenantId) ?? false; + }, + isApprovedIdPProvider: (thirdPartyId) => { + return thirdPartyId.startsWith('boxy-saml'); + }, + isMatchingEmailDomain: (tenantId, email) => { + const emailDomain = email.split('@'); + if (emailDomain.length !== 2) { + return false; + } + + const parsedTenantId = config.emailDomainToTenantIdMap[emailDomain[1]!.toLowerCase()]; + return parsedTenantId === tenantId; + }, + sendTenantJoiningRequestEmail: async (tenantId, user, appUrl, sendEmail, userContext) => { + /** + * Send an email to all the admins of the tenant + * + * @param tenantId - The id of the tenant to send the email to + * @param user - The user who is requesting to join the tenant + * @param sendEmail - The function to send the email + */ + const adminUsers = await implementation.getUserIdsInTenantWithRole(tenantId, ROLES.ADMIN); + + // For each of the users, we will need to find their email address. + const adminEmails = await Promise.all( + adminUsers.map(async (userId) => { + const userDetails = await SuperTokens.getUser(userId); + // TODO: Handle multiple emails? + return userDetails?.emails[0]; + }), + ); + + // Send emails to all tenant admins using Promise.all + await Promise.all( + adminEmails + .filter((email) => email !== undefined) + .map(async (email) => { + await sendEmail( + { + type: 'TENANT_REQUEST_APPROVAL', + email, + tenantId, + senderEmail: user.emails[0], + appUrl, + }, + userContext, + ); + }), + ); + }, + getUserIdsInTenantWithRole: async (tenantId, role) => { + throw new Error('Not implemented'); + }, + }; + + return implementation; +}; diff --git a/packages/tenant-enrollment-nodejs/src/types.ts b/packages/tenant-enrollment-nodejs/src/types.ts new file mode 100644 index 0000000..0c7fd73 --- /dev/null +++ b/packages/tenant-enrollment-nodejs/src/types.ts @@ -0,0 +1,62 @@ +import { User } from 'supertokens-node'; +import { + AssociateAllLoginMethodsOfUserWithTenant, + GetUserIdsInTenantWithRole, + SendPluginEmail, +} from '@supertokens-plugins/tenants-nodejs'; +import { UserContext } from 'supertokens-node/lib/build/types'; + +export type SuperTokensPluginTenantEnrollmentPluginConfig = { + emailDomainToTenantIdMap: Record; + inviteOnlyTenants?: string[]; + requiresApprovalTenants?: string[]; +}; + +export type SuperTokensPluginTenantEnrollmentPluginNormalisedConfig = { + emailDomainToTenantIdMap: Record; + inviteOnlyTenants: string[]; + requiresApprovalTenants: string[]; +}; + +export type EmailOrThirdPartyId = + | { + type: 'email'; + email: string; + } + | { + type: 'thirdParty'; + thirdPartyId: string; + }; + +export type OverrideableTenantFunctionImplementation = { + canUserJoinTenant: ( + tenantId: string, + emailOrThirdPartyId: EmailOrThirdPartyId, + ) => Promise<{ + canJoin: boolean; + reason?: string; + }>; + handleTenantJoiningApproval: ( + user: User, + tenantId: string, + associateLoginMethodDef: AssociateAllLoginMethodsOfUserWithTenant, + sendEmail: SendPluginEmail, + appUrl: string, + userContext: UserContext, + ) => Promise<{ + wasAddedToTenant: boolean; + reason?: string; + }>; + isTenantInviteOnly: (tenantId: string) => boolean; + doesTenantRequireApproval: (tenantId: string) => boolean; + isApprovedIdPProvider: (tenantId: string, thirdPartyId: string) => boolean; + isMatchingEmailDomain: (tenantId: string, email: string) => boolean; + sendTenantJoiningRequestEmail: ( + tenantId: string, + user: User, + appUrl: string, + sendEmail: SendPluginEmail, + userContext: UserContext, + ) => Promise; + getUserIdsInTenantWithRole: GetUserIdsInTenantWithRole; +}; diff --git a/packages/tenant-enrollment-nodejs/tsconfig.json b/packages/tenant-enrollment-nodejs/tsconfig.json new file mode 100644 index 0000000..06f8f7b --- /dev/null +++ b/packages/tenant-enrollment-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/tenant-enrollment-react/.eslintrc.js b/packages/tenant-enrollment-react/.eslintrc.js new file mode 100644 index 0000000..6a19d70 --- /dev/null +++ b/packages/tenant-enrollment-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/tenant-enrollment-react/.prettierrc.js b/packages/tenant-enrollment-react/.prettierrc.js new file mode 100644 index 0000000..8986fc5 --- /dev/null +++ b/packages/tenant-enrollment-react/.prettierrc.js @@ -0,0 +1,4 @@ +/** @type {import("prettier").Config} */ +module.exports = { + ...require("@shared/eslint/prettier"), +}; diff --git a/packages/tenant-enrollment-react/CHANGELOG.md b/packages/tenant-enrollment-react/CHANGELOG.md new file mode 100644 index 0000000..3a37a45 --- /dev/null +++ b/packages/tenant-enrollment-react/CHANGELOG.md @@ -0,0 +1,5 @@ +# @supertokens-plugins/tenant-enrollment-react + +## 0.1.0 + +- Add the initial tenant enrollment react plugin diff --git a/packages/tenant-enrollment-react/package.json b/packages/tenant-enrollment-react/package.json new file mode 100644 index 0000000..5cf82f0 --- /dev/null +++ b/packages/tenant-enrollment-react/package.json @@ -0,0 +1,54 @@ +{ + "name": "@supertokens-plugins/tenant-enrollment-react", + "version": "0.1.0", + "description": "Tenant Enrollment Plugin for SuperTokens", + "homepage": "https://github.com/supertokens/supertokens-plugins/blob/main/packages/tenant-enrollment-react/README.md", + "repository": { + "type": "git", + "url": "git+https://github.com/supertokens/supertokens-plugins.git", + "directory": "packages/tenant-enrollment-react" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest --watch", + "build": "vite build && npm run pretty", + "pretty": "npx pretty-quick .", + "pretty-check": "npx pretty-quick --check ." + }, + "keywords": [ + "tenant-enrollment", + "plugin", + "supertokens" + ], + "dependencies": { + "supertokens-js-override": "^0.0.4" + }, + "peerDependencies": { + "react": ">=18.3.1", + "react-dom": ">=18.3.1", + "supertokens-auth-react": ">=0.50.0", + "supertokens-web-js": ">=0.16.0" + }, + "devDependencies": { + "@shared/eslint": "*", + "@shared/tsconfig": "*", + "@testing-library/jest-dom": "^6.1.0", + "@types/react": "^17.0.20", + "jsdom": "^26.1.0", + "prettier": "3.6.2", + "pretty-quick": "^4.2.2", + "typescript": "^5.8.3", + "vitest": "^1.3.1", + "@shared/js": "*", + "@shared/react": "*", + "vite": "^6.3.5", + "@vitejs/plugin-react": "^4.5.2", + "vite-plugin-dts": "^4.5.4", + "rollup-plugin-peer-deps-external": "^2.2.4" + }, + "browser": { + "fs": false + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/packages/tenant-enrollment-react/src/api.ts b/packages/tenant-enrollment-react/src/api.ts new file mode 100644 index 0000000..4568a66 --- /dev/null +++ b/packages/tenant-enrollment-react/src/api.ts @@ -0,0 +1,5 @@ +import { getQuerier } from "@shared/react"; + +export const getApi = (querier: ReturnType) => { + return {}; +}; diff --git a/packages/tenant-enrollment-react/src/constants.ts b/packages/tenant-enrollment-react/src/constants.ts new file mode 100644 index 0000000..dcfb201 --- /dev/null +++ b/packages/tenant-enrollment-react/src/constants.ts @@ -0,0 +1,3 @@ +export const PLUGIN_ID = "supertokens-plugin-tenant-enrollment"; +export const PLUGIN_VERSION = "0.0.1"; +export const API_PATH = `plugin/${PLUGIN_ID}`; diff --git a/packages/tenant-enrollment-react/src/index.ts b/packages/tenant-enrollment-react/src/index.ts new file mode 100644 index 0000000..6f85b0d --- /dev/null +++ b/packages/tenant-enrollment-react/src/index.ts @@ -0,0 +1,4 @@ +import { PLUGIN_ID } from "./constants"; +import { init, usePluginContext } from "./plugin"; +export { init, PLUGIN_ID, usePluginContext }; +export default { init }; diff --git a/packages/tenant-enrollment-react/src/logger.ts b/packages/tenant-enrollment-react/src/logger.ts new file mode 100644 index 0000000..37cdd77 --- /dev/null +++ b/packages/tenant-enrollment-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/tenant-enrollment-react/src/plugin.tsx b/packages/tenant-enrollment-react/src/plugin.tsx new file mode 100644 index 0000000..f62a87e --- /dev/null +++ b/packages/tenant-enrollment-react/src/plugin.tsx @@ -0,0 +1,97 @@ +import { createPluginInitFunction } from "@shared/js"; +import { buildContext, getQuerier } from "@shared/react"; +import { useState } from "react"; +import { + SuperTokensPlugin, + SuperTokensPublicConfig, + SuperTokensPublicPlugin, + getTranslationFunction, +} from "supertokens-auth-react"; + +import { getApi } from "./api"; +import { PLUGIN_ID, API_PATH } from "./constants"; +import { enableDebugLogs } from "./logger"; +import { defaultTranslationsTenantEnrollment } from "./translations"; +import { SuperTokensPluginTenantEnrollmentPluginConfig, TranslationKeys } from "./types"; + + +const { usePluginContext, setContext } = buildContext<{ + plugins: SuperTokensPublicPlugin[]; + sdkVersion: string; + appConfig: SuperTokensPublicConfig; + pluginConfig: SuperTokensPluginTenantEnrollmentPluginConfig; + querier: ReturnType; + api: ReturnType; + t: (key: TranslationKeys) => string; + functions: null; +}>(); +export { usePluginContext }; + + +export const init = createPluginInitFunction< + SuperTokensPlugin, + SuperTokensPluginTenantEnrollmentPluginConfig, + {}, + // NOTE: Update the following type if we update the type to accept any values + SuperTokensPluginTenantEnrollmentPluginConfig +>((pluginConfig) => { + return { + id: PLUGIN_ID, + init: (config, plugins, sdkVersion) => { + if (config.enableDebugLogs) { + enableDebugLogs(); + } + + const querier = getQuerier(new URL(API_PATH, config.appInfo.apiDomain.getAsStringDangerous()).toString()); + const api = getApi(querier); + + // Set up the usePlugin hook + const apiBasePath = new URL(API_PATH, config.appInfo.apiDomain.getAsStringDangerous()).toString(); + const translations = getTranslationFunction(defaultTranslationsTenantEnrollment); + + setContext({ + plugins, + sdkVersion, + appConfig: config, + pluginConfig, + querier, + api, + t: translations, + functions: null, + }); + }, + routeHandlers: (appConfig: any, plugins: any, sdkVersion: any) => { + return { + status: "OK", + routeHandlers: [ + // Add route handlers here + // Example: + // { + // path: '/example-page', + // handler: () => ExamplePage.call(null), + // }, + ], + }; + }, + overrideMap: { + // Add recipe overrides here + // Example: + // emailpassword: { + // functions: (originalImplementation) => ({ + // ...originalImplementation, + // // Override functions here + // }), + // }, + }, + generalAuthRecipeComponentOverrides: { + // Add component overrides here + // Example: + // AuthPageHeader_Override: ({ DefaultComponent, ...props }) => { + // return ; + // }, + }, + }; +}, +{}, +(pluginConfig) => pluginConfig +); diff --git a/packages/tenant-enrollment-react/src/translations.ts b/packages/tenant-enrollment-react/src/translations.ts new file mode 100644 index 0000000..cb29933 --- /dev/null +++ b/packages/tenant-enrollment-react/src/translations.ts @@ -0,0 +1,4 @@ +export const defaultTranslationsTenantEnrollment = { + en: { + }, +} as const; diff --git a/packages/tenant-enrollment-react/src/types.ts b/packages/tenant-enrollment-react/src/types.ts new file mode 100644 index 0000000..c976b18 --- /dev/null +++ b/packages/tenant-enrollment-react/src/types.ts @@ -0,0 +1,5 @@ +import { defaultTranslationsTenantEnrollment } from "./translations"; + +export type SuperTokensPluginTenantEnrollmentPluginConfig = {}; + +export type TranslationKeys = keyof (typeof defaultTranslationsTenantEnrollment)["en"]; \ No newline at end of file diff --git a/packages/tenant-enrollment-react/tsconfig.json b/packages/tenant-enrollment-react/tsconfig.json new file mode 100644 index 0000000..9ce578d --- /dev/null +++ b/packages/tenant-enrollment-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/tenant-enrollment-react/vite.config.ts b/packages/tenant-enrollment-react/vite.config.ts new file mode 100644 index 0000000..7d43469 --- /dev/null +++ b/packages/tenant-enrollment-react/vite.config.ts @@ -0,0 +1,38 @@ +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'; + +export default defineConfig(() => { + return { + root: __dirname, + plugins: [ + react(), + dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.json') }), + peerDepsExternal(), + ], + + 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/tenant-enrollment-react/vitest.config.ts b/packages/tenant-enrollment-react/vitest.config.ts new file mode 100644 index 0000000..b45b161 --- /dev/null +++ b/packages/tenant-enrollment-react/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./tests/setup.ts"], + }, +}); \ No newline at end of file From 8dc698c240ea1081798c024745518aa4d05af9b2 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Wed, 24 Sep 2025 11:48:27 +0530 Subject: [PATCH 17/36] fix: formatting of plugin --- .../tenant-enrollment-nodejs/src/plugin.ts | 90 +++++++++---------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/packages/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts index 53d4d25..15ef5da 100644 --- a/packages/tenant-enrollment-nodejs/src/plugin.ts +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -1,22 +1,22 @@ -import { SuperTokensPlugin } from 'supertokens-node'; -import { createPluginInitFunction } from '@shared/js'; -import { PLUGIN_ID, PLUGIN_SDK_VERSION } from './constants'; +import { SuperTokensPlugin } from "supertokens-node"; +import { createPluginInitFunction } from "@shared/js"; +import { PLUGIN_ID, PLUGIN_SDK_VERSION } from "./constants"; import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig, SuperTokensPluginTenantEnrollmentPluginNormalisedConfig, -} from './types'; -import { getOverrideableTenantFunctionImplementation } from './recipeImplementation'; -import { logDebugMessage } from 'supertokens-node/lib/build/logger'; +} from "./types"; +import { getOverrideableTenantFunctionImplementation } from "./recipeImplementation"; +import { logDebugMessage } from "supertokens-node/lib/build/logger"; import { AssociateAllLoginMethodsOfUserWithTenant, PLUGIN_ID as TENANTS_PLUGIN_ID, SendPluginEmail, GetAppUrl, -} from '@supertokens-plugins/tenants-nodejs'; -import { listUsersByAccountInfo } from 'supertokens-node'; -import { NormalisedAppinfo } from 'supertokens-node/types'; -import { enableDebugLogs } from './logger'; +} from "@supertokens-plugins/tenants-nodejs"; +import { listUsersByAccountInfo } from "supertokens-node"; +import { NormalisedAppinfo } from "supertokens-node/types"; +import { enableDebugLogs } from "./logger"; export const init = createPluginInitFunction< SuperTokensPlugin, @@ -39,27 +39,27 @@ export const init = createPluginInitFunction< const tenantsPlugin = plugins.find((plugin: any) => plugin.id === TENANTS_PLUGIN_ID); if (!tenantsPlugin) { - throw new Error('Base Tenants plugin not initialized, cannot continue.'); + throw new Error("Base Tenants plugin not initialized, cannot continue."); } if (!tenantsPlugin.exports) { - throw new Error('Base Tenants plugin does not export, cannot continue.'); + throw new Error("Base Tenants plugin does not export, cannot continue."); } const associateAllLoginMethodsOfUserWithTenant = tenantsPlugin.exports?.associateAllLoginMethodsOfUserWithTenant; if (!associateAllLoginMethodsOfUserWithTenant) { - throw new Error('Tenants plugin does not export associateAllLoginMethodsOfUserWithTenant, cannot continue.'); + throw new Error("Tenants plugin does not export associateAllLoginMethodsOfUserWithTenant, cannot continue."); } const sendPluginEmail = tenantsPlugin.exports?.sendEmail; if (!sendPluginEmail) { - throw new Error('Tenants plugin does not export sendEmail, cannot continue.'); + throw new Error("Tenants plugin does not export sendEmail, cannot continue."); } const getUserIdsInTenantWithRole = tenantsPlugin.exports?.getUserIdsInTenantWithRole; if (!getUserIdsInTenantWithRole) { - throw new Error('Tenants plugin does not export getUserIdsInTenantWithRole, cannot continue.'); + throw new Error("Tenants plugin does not export getUserIdsInTenantWithRole, cannot continue."); } associateLoginMethodDef = associateAllLoginMethodsOfUserWithTenant; @@ -68,7 +68,7 @@ export const init = createPluginInitFunction< const getAppUrl = tenantsPlugin.exports?.getAppUrl; if (!getAppUrl) { - throw new Error('Tenants plugin does not export getAppUrl, cannot continue'); + throw new Error("Tenants plugin does not export getAppUrl, cannot continue"); } getAppUrlDef = getAppUrl; @@ -76,7 +76,7 @@ export const init = createPluginInitFunction< }, routeHandlers: () => { return { - status: 'OK', + status: "OK", routeHandlers: [], }; }, @@ -87,19 +87,19 @@ export const init = createPluginInitFunction< ...originalImplementation, signUp: async (input) => { const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { - type: 'email', + type: "email", email: input.email, }); - logDebugMessage('Reason: ' + reason); + logDebugMessage("Reason: " + reason); if (!canJoin) { return { - status: 'LINKING_TO_SESSION_USER_FAILED', - reason: 'EMAIL_VERIFICATION_REQUIRED', + status: "LINKING_TO_SESSION_USER_FAILED", + reason: "EMAIL_VERIFICATION_REQUIRED", }; } const response = await originalImplementation.signUp(input); - if (response.status !== 'OK') { + if (response.status !== "OK") { return response; } @@ -125,7 +125,7 @@ export const init = createPluginInitFunction< ...originalImplementation, signUpPOST: async (input) => { const response = await originalImplementation.signUpPOST!(input); - if (response.status === 'SIGN_UP_NOT_ALLOWED' && response.reason.includes('ERR_CODE_013')) { + if (response.status === "SIGN_UP_NOT_ALLOWED" && response.reason.includes("ERR_CODE_013")) { // There is a possibility that the user is not allowed // to signup to the tenant so we will have to update the message // accordingly. @@ -146,7 +146,7 @@ export const init = createPluginInitFunction< ...originalImplementation, signInUpPOST: async (input) => { const response = await originalImplementation.signInUpPOST!(input); - if (response.status === 'SIGN_IN_UP_NOT_ALLOWED' && response.reason.includes('ERR_CODE_020')) { + if (response.status === "SIGN_IN_UP_NOT_ALLOWED" && response.reason.includes("ERR_CODE_020")) { return { ...response, reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", @@ -176,19 +176,19 @@ export const init = createPluginInitFunction< } const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { - type: 'thirdParty', + type: "thirdParty", thirdPartyId: input.thirdPartyId, }); - logDebugMessage('Reason: ' + reason); + logDebugMessage("Reason: " + reason); if (!canJoin) { return { - status: 'LINKING_TO_SESSION_USER_FAILED', - reason: 'EMAIL_VERIFICATION_REQUIRED', + status: "LINKING_TO_SESSION_USER_FAILED", + reason: "EMAIL_VERIFICATION_REQUIRED", }; } const response = await originalImplementation.signInUp(input); - if (response.status !== 'OK') { + if (response.status !== "OK") { return response; } @@ -216,7 +216,7 @@ export const init = createPluginInitFunction< ...originalImplementation, createCodePOST: async (input) => { const response = await originalImplementation.createCodePOST!(input); - if (response.status === 'SIGN_IN_UP_NOT_ALLOWED' && response.reason.includes('ERR_CODE_002')) { + if (response.status === "SIGN_IN_UP_NOT_ALLOWED" && response.reason.includes("ERR_CODE_002")) { return { ...response, reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", @@ -227,7 +227,7 @@ export const init = createPluginInitFunction< }, consumeCodePOST: async (input) => { const response = await originalImplementation.consumeCodePOST!(input); - if (response.status === 'SIGN_IN_UP_NOT_ALLOWED' && response.reason.includes('ERR_CODE_002')) { + if (response.status === "SIGN_IN_UP_NOT_ALLOWED" && response.reason.includes("ERR_CODE_002")) { return { ...response, reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", @@ -245,8 +245,8 @@ export const init = createPluginInitFunction< // If this is a signup, we need to check if the user // can signup to the tenant. const accountInfoResponse = await listUsersByAccountInfo(input.tenantId, { - email: 'email' in input ? input.email : undefined, - phoneNumber: 'phoneNumber' in input ? input.phoneNumber : undefined, + email: "email" in input ? input.email : undefined, + phoneNumber: "phoneNumber" in input ? input.phoneNumber : undefined, }); const isSignUp = accountInfoResponse.length === 0; @@ -256,19 +256,19 @@ export const init = createPluginInitFunction< // If this is a signup but its through phone number, we cannot // restrict it so we will let it go through. - if ('phoneNumber' in input) { + if ("phoneNumber" in input) { return originalImplementation.createCode(input); } const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { - type: 'email', + type: "email", email: input.email, }); - logDebugMessage('Reason: ' + reason); + logDebugMessage("Reason: " + reason); if (!canJoin) { return { - status: 'SIGN_IN_UP_NOT_ALLOWED', + status: "SIGN_IN_UP_NOT_ALLOWED", } as any; } @@ -289,7 +289,7 @@ export const init = createPluginInitFunction< // This is handled in the consumeCode but we can handle // it here as well return { - status: 'RESTART_FLOW_ERROR', + status: "RESTART_FLOW_ERROR", }; } @@ -297,11 +297,11 @@ export const init = createPluginInitFunction< input.tenantId, deviceInfo.phoneNumber !== undefined ? { - phoneNumber: deviceInfo.phoneNumber!, - } + phoneNumber: deviceInfo.phoneNumber!, + } : { - email: deviceInfo.email!, - }, + email: deviceInfo.email!, + }, ); const isSignUp = accountInfoResponse.length === 0; @@ -314,14 +314,14 @@ export const init = createPluginInitFunction< // Since this is a signup, we need to check if the user // can signup to the tenant. const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { - type: 'email', + type: "email", email: deviceInfo.email!, }); - logDebugMessage('Reason: ' + reason); + logDebugMessage("Reason: " + reason); if (!canJoin) { return { - status: 'SIGN_IN_UP_NOT_ALLOWED', + status: "SIGN_IN_UP_NOT_ALLOWED", } as any; } From cb8fcd5cb706d3f90fff779c20645746d778f8d3 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Thu, 25 Sep 2025 15:59:20 +0530 Subject: [PATCH 18/36] feat: add support for showing tenant join approval pending screen --- .../tenant-enrollment-nodejs/package.json | 6 +- .../tenant-enrollment-nodejs/src/plugin.ts | 53 ++++---- .../src/recipeImplementation.ts | 40 +++--- .../tenant-enrollment-nodejs/vite.config.ts | 37 ++++++ packages/tenant-enrollment-react/src/css.d.ts | 29 +++++ .../awaiting-approval.module.scss | 30 +++++ .../awaiting-approval/awaiting-approval.tsx | 23 ++++ .../src/pages/awaiting-approval/index.ts | 1 + .../tenant-enrollment-react/src/plugin.tsx | 121 ++++++++++-------- .../src/translations.ts | 4 + 10 files changed, 246 insertions(+), 98 deletions(-) create mode 100644 packages/tenant-enrollment-nodejs/vite.config.ts create mode 100644 packages/tenant-enrollment-react/src/css.d.ts create mode 100644 packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.module.scss create mode 100644 packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx create mode 100644 packages/tenant-enrollment-react/src/pages/awaiting-approval/index.ts diff --git a/packages/tenant-enrollment-nodejs/package.json b/packages/tenant-enrollment-nodejs/package.json index 15274e4..c8c9ccf 100644 --- a/packages/tenant-enrollment-nodejs/package.json +++ b/packages/tenant-enrollment-nodejs/package.json @@ -9,7 +9,7 @@ "directory": "packages/tenant-enrollment-nodejs" }, "scripts": { - "build": "tsup src/index.ts --format cjs,esm --dts", + "build": "vite build && npm run pretty", "pretty": "npx pretty-quick .", "pretty-check": "npx pretty-quick --check .", "test": "TEST_MODE=testing vitest run --pool=forks --passWithNoTests" @@ -19,9 +19,9 @@ "plugin", "supertokens" ], - "dependencies": {}, "peerDependencies": { - "supertokens-node": ">=23.0.0" + "supertokens-node": ">=23.0.0", + "@supertokens-plugins/tenants-nodejs": "*" }, "devDependencies": { "@shared/eslint": "*", diff --git a/packages/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts index 15ef5da..fdf481d 100644 --- a/packages/tenant-enrollment-nodejs/src/plugin.ts +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -98,25 +98,7 @@ export const init = createPluginInitFunction< }; } - const response = await originalImplementation.signUp(input); - if (response.status !== "OK") { - return response; - } - - const { wasAddedToTenant, reason: tenantJoiningReason } = - await implementation.handleTenantJoiningApproval( - response.user, - input.tenantId, - associateLoginMethodDef, - sendEmail, - getAppUrlDef(appInfo, undefined, input.userContext), - input.userContext, - ); - return { - ...response, - wasAddedToTenant, - reason: tenantJoiningReason, - }; + return originalImplementation.signUp(input); }, }; }, @@ -135,7 +117,30 @@ export const init = createPluginInitFunction< }; } - return response; + logDebugMessage(`Got response status for signup: ${response.status}`); + if (response.status !== "OK") { + return response; + } + + logDebugMessage("Going ahead with checking tenant joining approval"); + const { wasAddedToTenant, reason: tenantJoiningReason } = + await implementation.handleTenantJoiningApproval( + response.user, + input.tenantId, + associateLoginMethodDef, + sendEmail, + getAppUrlDef(appInfo, undefined, input.userContext), + input.userContext, + ); + logDebugMessage(`wasAdded: ${wasAddedToTenant}`); + logDebugMessage(`reason: ${tenantJoiningReason}`); + return { + status: "PENDING_APPROVAL", + wasAddedToTenant, + reason: tenantJoiningReason, + }; + + // return response; }, }; }, @@ -297,11 +302,11 @@ export const init = createPluginInitFunction< input.tenantId, deviceInfo.phoneNumber !== undefined ? { - phoneNumber: deviceInfo.phoneNumber!, - } + phoneNumber: deviceInfo.phoneNumber!, + } : { - email: deviceInfo.email!, - }, + email: deviceInfo.email!, + }, ); const isSignUp = accountInfoResponse.length === 0; diff --git a/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts b/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts index cbe8bb6..3ca464b 100644 --- a/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts +++ b/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts @@ -1,13 +1,13 @@ -import { User } from 'supertokens-node'; -import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from './types'; +import { User } from "supertokens-node"; +import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from "./types"; import { assignRoleToUserInTenant, AssociateAllLoginMethodsOfUserWithTenant, SendPluginEmail, -} from '@supertokens-plugins/tenants-nodejs'; -import { ROLES } from '@shared/tenants'; -import SuperTokens from 'supertokens-node'; -import { UserContext } from 'supertokens-node/lib/build/types'; +} from "@supertokens-plugins/tenants-nodejs"; +import { ROLES } from "@shared/tenants"; +import SuperTokens from "supertokens-node"; +import { UserContext } from "supertokens-node/lib/build/types"; export const getOverrideableTenantFunctionImplementation = ( config: SuperTokensPluginTenantEnrollmentPluginConfig, @@ -23,7 +23,7 @@ export const getOverrideableTenantFunctionImplementation = ( */ // Skip this for the public tenant - if (tenantId === 'public') { + if (tenantId === "public") { return { canJoin: true, reason: undefined, @@ -35,21 +35,21 @@ export const getOverrideableTenantFunctionImplementation = ( if (implementation.isTenantInviteOnly(tenantId)) { return { canJoin: false, - reason: 'INVITE_ONLY', + reason: "INVITE_ONLY", }; } let canJoin = false; let reason = undefined; - if (emailOrThirdPartyId.type === 'email') { + if (emailOrThirdPartyId.type === "email") { canJoin = implementation.isMatchingEmailDomain(tenantId, emailOrThirdPartyId.email); if (!canJoin) { - reason = 'EMAIL_DOMAIN_NOT_ALLOWED'; + reason = "EMAIL_DOMAIN_NOT_ALLOWED"; } - } else if (emailOrThirdPartyId.type === 'thirdParty') { + } else if (emailOrThirdPartyId.type === "thirdParty") { canJoin = implementation.isApprovedIdPProvider(tenantId, emailOrThirdPartyId.thirdPartyId); if (!canJoin) { - reason = 'IDP_NOT_ALLOWED'; + reason = "IDP_NOT_ALLOWED"; } } @@ -79,7 +79,7 @@ export const getOverrideableTenantFunctionImplementation = ( * @param associateLoginMethodDef - The function to associate the login methods of the user with the tenant */ // Skip this for the public tenant - if (tenantId === 'public') { + if (tenantId === "public") { return { wasAddedToTenant: true, reason: undefined, @@ -99,11 +99,11 @@ export const getOverrideableTenantFunctionImplementation = ( // and return. await associateLoginMethodDef(tenantId, user.id); - await implementation.sendTenantJoiningRequestEmail(tenantId, user, appUrl, sendEmail, userContext); + // await implementation.sendTenantJoiningRequestEmail(tenantId, user, appUrl, sendEmail, userContext); return { wasAddedToTenant: false, - reason: 'REQUIRES_APPROVAL', + reason: "REQUIRES_APPROVAL", }; }, isTenantInviteOnly: (tenantId) => { @@ -113,10 +113,10 @@ export const getOverrideableTenantFunctionImplementation = ( return config.requiresApprovalTenants?.includes(tenantId) ?? false; }, isApprovedIdPProvider: (thirdPartyId) => { - return thirdPartyId.startsWith('boxy-saml'); + return thirdPartyId.startsWith("boxy-saml"); }, isMatchingEmailDomain: (tenantId, email) => { - const emailDomain = email.split('@'); + const emailDomain = email.split("@"); if (emailDomain.length !== 2) { return false; } @@ -150,10 +150,10 @@ export const getOverrideableTenantFunctionImplementation = ( .map(async (email) => { await sendEmail( { - type: 'TENANT_REQUEST_APPROVAL', + type: "TENANT_REQUEST_APPROVAL", email, tenantId, - senderEmail: user.emails[0], + senderEmail: user.emails[0]!, appUrl, }, userContext, @@ -162,7 +162,7 @@ export const getOverrideableTenantFunctionImplementation = ( ); }, getUserIdsInTenantWithRole: async (tenantId, role) => { - throw new Error('Not implemented'); + throw new Error("Not implemented"); }, }; diff --git a/packages/tenant-enrollment-nodejs/vite.config.ts b/packages/tenant-enrollment-nodejs/vite.config.ts new file mode 100644 index 0000000..f4625d4 --- /dev/null +++ b/packages/tenant-enrollment-nodejs/vite.config.ts @@ -0,0 +1,37 @@ +/// +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; +import peerDepsExternal from "rollup-plugin-peer-deps-external"; +import * as path from "path"; +import packageJson from "../tenants-nodejs/package.json"; + +export default defineConfig(() => ({ + root: __dirname, + plugins: [ + dts({ + entryRoot: "src", + tsconfigPath: path.join(__dirname, "tsconfig.json"), + }), + peerDepsExternal(), + ], + build: { + outDir: "./dist", + emptyOutDir: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: "src/index.ts", + name: packageJson.name, + fileName: "index", + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ["es" as const, "cjs" as const], + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: [], + }, + }, +})); diff --git a/packages/tenant-enrollment-react/src/css.d.ts b/packages/tenant-enrollment-react/src/css.d.ts new file mode 100644 index 0000000..93c8235 --- /dev/null +++ b/packages/tenant-enrollment-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/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.module.scss b/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.module.scss new file mode 100644 index 0000000..2c14a84 --- /dev/null +++ b/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.module.scss @@ -0,0 +1,30 @@ +.awaitingApprovalMessageContainer { + .header { + font-weight: 700; + font-size: 28px; + line-height: 36px; + letter-spacing: -0.12px; + color: var(--neutral-color-neutral-12); + margin: 0 0 16px 0; + } + + .messageContainer { + box-shadow: 0px 1.5px 2px 0px rgba(0, 0, 0, 0.133) inset; + border: 1px solid var(--neutral-color-neutral-6); + background-color: #f9f9f8; + border-radius: 12px; + padding: 14px; + + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0px; + + b { + font-weight: 600; + font-size: 14px; + line-height: 20px; + color: var(--neutral-color-neutral-11); + } + } +} diff --git a/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx b/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx new file mode 100644 index 0000000..14be0bf --- /dev/null +++ b/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx @@ -0,0 +1,23 @@ +import { Card } from "@shared/ui"; +import classNames from "classnames/bind"; + +import { usePluginContext } from "../../plugin"; + +import style from "./awaiting-approval.module.scss"; +const cx = classNames.bind(style); + +export const AwaitingApproval = () => { + const { t } = usePluginContext(); + + return ( + +
{t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_HEADER")}
+
+
+ {t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE")}{" "} + {t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE_HIGHLIGHT")} +
+
+
+ ); +}; diff --git a/packages/tenant-enrollment-react/src/pages/awaiting-approval/index.ts b/packages/tenant-enrollment-react/src/pages/awaiting-approval/index.ts new file mode 100644 index 0000000..ef943b8 --- /dev/null +++ b/packages/tenant-enrollment-react/src/pages/awaiting-approval/index.ts @@ -0,0 +1 @@ +export { AwaitingApproval } from "./awaiting-approval"; diff --git a/packages/tenant-enrollment-react/src/plugin.tsx b/packages/tenant-enrollment-react/src/plugin.tsx index f62a87e..b08924c 100644 --- a/packages/tenant-enrollment-react/src/plugin.tsx +++ b/packages/tenant-enrollment-react/src/plugin.tsx @@ -10,11 +10,11 @@ import { import { getApi } from "./api"; import { PLUGIN_ID, API_PATH } from "./constants"; -import { enableDebugLogs } from "./logger"; +import { enableDebugLogs, logDebugMessage } from "./logger"; +import { AwaitingApproval } from "./pages/awaiting-approval"; import { defaultTranslationsTenantEnrollment } from "./translations"; import { SuperTokensPluginTenantEnrollmentPluginConfig, TranslationKeys } from "./types"; - const { usePluginContext, setContext } = buildContext<{ plugins: SuperTokensPublicPlugin[]; sdkVersion: string; @@ -27,29 +27,29 @@ const { usePluginContext, setContext } = buildContext<{ }>(); export { usePluginContext }; - export const init = createPluginInitFunction< SuperTokensPlugin, SuperTokensPluginTenantEnrollmentPluginConfig, {}, // NOTE: Update the following type if we update the type to accept any values SuperTokensPluginTenantEnrollmentPluginConfig ->((pluginConfig) => { - return { - id: PLUGIN_ID, - init: (config, plugins, sdkVersion) => { - if (config.enableDebugLogs) { - enableDebugLogs(); - } +>( + (pluginConfig) => { + return { + id: PLUGIN_ID, + init: (config, plugins, sdkVersion) => { + if (config.enableDebugLogs) { + enableDebugLogs(); + } - const querier = getQuerier(new URL(API_PATH, config.appInfo.apiDomain.getAsStringDangerous()).toString()); - const api = getApi(querier); + const querier = getQuerier(new URL(API_PATH, config.appInfo.apiDomain.getAsStringDangerous()).toString()); + const api = getApi(querier); - // Set up the usePlugin hook - const apiBasePath = new URL(API_PATH, config.appInfo.apiDomain.getAsStringDangerous()).toString(); - const translations = getTranslationFunction(defaultTranslationsTenantEnrollment); + // Set up the usePlugin hook + const apiBasePath = new URL(API_PATH, config.appInfo.apiDomain.getAsStringDangerous()).toString(); + const translations = getTranslationFunction(defaultTranslationsTenantEnrollment); - setContext({ + setContext({ plugins, sdkVersion, appConfig: config, @@ -59,39 +59,58 @@ export const init = createPluginInitFunction< t: translations, functions: null, }); - }, - routeHandlers: (appConfig: any, plugins: any, sdkVersion: any) => { - return { - status: "OK", - routeHandlers: [ - // Add route handlers here - // Example: - // { - // path: '/example-page', - // handler: () => ExamplePage.call(null), - // }, - ], - }; - }, - overrideMap: { - // Add recipe overrides here - // Example: - // emailpassword: { - // functions: (originalImplementation) => ({ - // ...originalImplementation, - // // Override functions here - // }), - // }, - }, - generalAuthRecipeComponentOverrides: { - // Add component overrides here - // Example: - // AuthPageHeader_Override: ({ DefaultComponent, ...props }) => { - // return ; - // }, - }, - }; -}, -{}, -(pluginConfig) => pluginConfig + }, + routeHandlers: (appConfig: any, plugins: any, sdkVersion: any) => { + return { + status: "OK", + routeHandlers: [ + { + path: "/awaiting-approval", + handler: () => AwaitingApproval.call(null), + }, + ], + }; + }, + overrideMap: { + emailpassword: { + functions: (originalImplementation) => ({ + ...originalImplementation, + signUp: async (input) => { + const signUpResponse = await originalImplementation.signUp(input); + logDebugMessage(`response: ${signUpResponse}`); + if ((signUpResponse.status as any) !== "PENDING_APPROVAL") { + return signUpResponse; + } + + // If it was okay, check if they were added to tenant or not. + const { wasAddedToTenant, reason } = signUpResponse as any; + if (wasAddedToTenant === true) { + // We don't have to do anything + return signUpResponse; + } + + // Since the tenant was not added, if we got a reason, we will have + // to parse it. + if (reason === undefined) { + return signUpResponse; + } + + // Since reason is defined, parse it and handle accordingly. + if (reason === "REQUIRES_APPROVAL") { + if (typeof window !== "undefined") { + window.location.assign("/awaiting-approval"); + } + } + + // NOTE: Currently we don't have any possibility of reason being any other + // value. If that changes, we can update in the future. + return signUpResponse; + }, + }), + }, + }, + }; + }, + {}, + (pluginConfig) => pluginConfig, ); diff --git a/packages/tenant-enrollment-react/src/translations.ts b/packages/tenant-enrollment-react/src/translations.ts index cb29933..1c0ace6 100644 --- a/packages/tenant-enrollment-react/src/translations.ts +++ b/packages/tenant-enrollment-react/src/translations.ts @@ -1,4 +1,8 @@ export const defaultTranslationsTenantEnrollment = { en: { + PL_TE_JOIN_TENANT_AWAITING_APPROVAL_HEADER: "Awaiting tenant admin approval", + PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE: + "It is essential to obtain the tenant administrator's approval before proceeding with the", + PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE_HIGHLIGHT: "tenant joining process", }, } as const; From 69c06df9ab2967c70be9984357affef16b1d04a5 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 10 Oct 2025 14:15:19 +0530 Subject: [PATCH 19/36] feat: add support for picking up not allowed to signup error in FE --- .../tenant-enrollment-nodejs/src/plugin.ts | 26 +++++++++-------- .../src/recipeImplementation.ts | 8 +++--- .../tenant-enrollment-react/src/plugin.tsx | 28 ++++++++++++++++++- shared/tenants/src/errors.ts | 5 ++++ shared/tenants/src/index.ts | 5 ++-- 5 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 shared/tenants/src/errors.ts diff --git a/packages/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts index fdf481d..76eb73e 100644 --- a/packages/tenant-enrollment-nodejs/src/plugin.ts +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -93,8 +93,10 @@ export const init = createPluginInitFunction< logDebugMessage("Reason: " + reason); if (!canJoin) { return { - status: "LINKING_TO_SESSION_USER_FAILED", - reason: "EMAIL_VERIFICATION_REQUIRED", + // Use the `EMAIL_ALREADY_EXISTS_ERROR` since that is returned + // directly without modification from the `signUpPOST` method. + status: "EMAIL_ALREADY_EXISTS_ERROR", + reason, }; } @@ -107,17 +109,19 @@ export const init = createPluginInitFunction< ...originalImplementation, signUpPOST: async (input) => { const response = await originalImplementation.signUpPOST!(input); - if (response.status === "SIGN_UP_NOT_ALLOWED" && response.reason.includes("ERR_CODE_013")) { - // There is a possibility that the user is not allowed - // to signup to the tenant so we will have to update the message - // accordingly. + + logDebugMessage(`Got response status for signup: ${response.status}`); + + // If the status is `EMAIL_ALREADY_EXISTS_ERROR`, we will have to pick that + // up and return a GENERAL_ERROR instead to make the error passed along to + // the FE + if (response.status === "EMAIL_ALREADY_EXISTS_ERROR") { return { - ...response, - reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", + status: "GENERAL_ERROR", + message: (response as any).reason, }; } - logDebugMessage(`Got response status for signup: ${response.status}`); if (response.status !== "OK") { return response; } @@ -135,12 +139,10 @@ export const init = createPluginInitFunction< logDebugMessage(`wasAdded: ${wasAddedToTenant}`); logDebugMessage(`reason: ${tenantJoiningReason}`); return { - status: "PENDING_APPROVAL", + status: "PENDING_APPROVAL" as any, wasAddedToTenant, reason: tenantJoiningReason, }; - - // return response; }, }; }, diff --git a/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts b/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts index 3ca464b..8177471 100644 --- a/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts +++ b/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts @@ -5,7 +5,7 @@ import { AssociateAllLoginMethodsOfUserWithTenant, SendPluginEmail, } from "@supertokens-plugins/tenants-nodejs"; -import { ROLES } from "@shared/tenants"; +import { NOT_ALLOWED_TO_SIGNUP_REASONS, ROLES } from "@shared/tenants"; import SuperTokens from "supertokens-node"; import { UserContext } from "supertokens-node/lib/build/types"; @@ -35,7 +35,7 @@ export const getOverrideableTenantFunctionImplementation = ( if (implementation.isTenantInviteOnly(tenantId)) { return { canJoin: false, - reason: "INVITE_ONLY", + reason: NOT_ALLOWED_TO_SIGNUP_REASONS.INVITE_ONLY, }; } @@ -44,12 +44,12 @@ export const getOverrideableTenantFunctionImplementation = ( if (emailOrThirdPartyId.type === "email") { canJoin = implementation.isMatchingEmailDomain(tenantId, emailOrThirdPartyId.email); if (!canJoin) { - reason = "EMAIL_DOMAIN_NOT_ALLOWED"; + reason = NOT_ALLOWED_TO_SIGNUP_REASONS.EMAIL_DOMAIN_NOT_ALLOWED; } } else if (emailOrThirdPartyId.type === "thirdParty") { canJoin = implementation.isApprovedIdPProvider(tenantId, emailOrThirdPartyId.thirdPartyId); if (!canJoin) { - reason = "IDP_NOT_ALLOWED"; + reason = NOT_ALLOWED_TO_SIGNUP_REASONS.IDP_NOT_ALLOWED; } } diff --git a/packages/tenant-enrollment-react/src/plugin.tsx b/packages/tenant-enrollment-react/src/plugin.tsx index b08924c..5f62257 100644 --- a/packages/tenant-enrollment-react/src/plugin.tsx +++ b/packages/tenant-enrollment-react/src/plugin.tsx @@ -8,6 +8,8 @@ import { getTranslationFunction, } from "supertokens-auth-react"; +import { NOT_ALLOWED_TO_SIGNUP_REASONS } from "../../../shared/tenants/src"; + import { getApi } from "./api"; import { PLUGIN_ID, API_PATH } from "./constants"; import { enableDebugLogs, logDebugMessage } from "./logger"; @@ -76,8 +78,32 @@ export const init = createPluginInitFunction< functions: (originalImplementation) => ({ ...originalImplementation, signUp: async (input) => { - const signUpResponse = await originalImplementation.signUp(input); + let signUpResponse; + + try { + signUpResponse = await originalImplementation.signUp(input); + } catch (error: any) { + // Check if the error is a STGeneralError + logDebugMessage(`Caught error: ${error}`); + if (error.isSuperTokensGeneralError === true) { + logDebugMessage(`Got general error with reason: ${error.message}`); + + // Check if the message is one of the not allowed defined errors. + if (Object.values(NOT_ALLOWED_TO_SIGNUP_REASONS).includes(error.message)) { + logDebugMessage("Found not-allowed to signup flow, redirecting"); + + // Update the message before re-throwing the error + error.message = "Not allowed to signup to tenant"; + + // TODO: Redirect the user to not allowed to signup view + } + } + + throw error; + } + logDebugMessage(`response: ${signUpResponse}`); + if ((signUpResponse.status as any) !== "PENDING_APPROVAL") { return signUpResponse; } diff --git a/shared/tenants/src/errors.ts b/shared/tenants/src/errors.ts new file mode 100644 index 0000000..695dac3 --- /dev/null +++ b/shared/tenants/src/errors.ts @@ -0,0 +1,5 @@ +export const NOT_ALLOWED_TO_SIGNUP_REASONS = { + INVITE_ONLY: "INVITE_ONLY", + EMAIL_DOMAIN_NOT_ALLOWED: "EMAIL_DOMAIN_NOT_ALLOWED", + IDP_NOT_ALLOWED: "IDP_NOT_ALLOWED", +}; diff --git a/shared/tenants/src/index.ts b/shared/tenants/src/index.ts index 2c6ce5b..91f5a75 100644 --- a/shared/tenants/src/index.ts +++ b/shared/tenants/src/index.ts @@ -1,2 +1,3 @@ -export * from './types'; -export * from './roles'; +export * from "./types"; +export * from "./roles"; +export * from "./errors"; From 14413e968080d04ef9de7bb08f79fed625339fe0 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Mon, 13 Oct 2025 12:26:10 +0530 Subject: [PATCH 20/36] feat: add support for redirecting user to signup blocked if not allowed --- .../src/components/no-access/NoAccess.tsx | 20 +++++++++++++++++++ .../src/components/no-access/index.ts | 1 + .../no-access/no-access.module.scss} | 10 +++++++++- .../awaiting-approval/awaiting-approval.tsx | 17 ++++++---------- .../src/pages/blocked/index.ts | 1 + .../src/pages/blocked/signup-blocked.tsx | 18 +++++++++++++++++ .../tenant-enrollment-react/src/plugin.tsx | 8 +++++++- .../src/translations.ts | 4 ++++ 8 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 packages/tenant-enrollment-react/src/components/no-access/NoAccess.tsx create mode 100644 packages/tenant-enrollment-react/src/components/no-access/index.ts rename packages/tenant-enrollment-react/src/{pages/awaiting-approval/awaiting-approval.module.scss => components/no-access/no-access.module.scss} (78%) create mode 100644 packages/tenant-enrollment-react/src/pages/blocked/index.ts create mode 100644 packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx diff --git a/packages/tenant-enrollment-react/src/components/no-access/NoAccess.tsx b/packages/tenant-enrollment-react/src/components/no-access/NoAccess.tsx new file mode 100644 index 0000000..c1eb780 --- /dev/null +++ b/packages/tenant-enrollment-react/src/components/no-access/NoAccess.tsx @@ -0,0 +1,20 @@ +import { Card } from "@shared/ui"; +import classNames from "classnames/bind"; + +import style from "./no-access.module.scss"; +const cx = classNames.bind(style); + +type NoAccessProps = { + headerText: string; + descriptionComponent: React.ReactNode; + useDangerAccent?: boolean; +}; + +export const NoAccess: React.FC = ({ headerText, descriptionComponent, useDangerAccent = false }) => { + return ( + +
{headerText}
+
{descriptionComponent}
+
+ ); +}; diff --git a/packages/tenant-enrollment-react/src/components/no-access/index.ts b/packages/tenant-enrollment-react/src/components/no-access/index.ts new file mode 100644 index 0000000..7930e5a --- /dev/null +++ b/packages/tenant-enrollment-react/src/components/no-access/index.ts @@ -0,0 +1 @@ +export { NoAccess } from "./NoAccess"; diff --git a/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.module.scss b/packages/tenant-enrollment-react/src/components/no-access/no-access.module.scss similarity index 78% rename from packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.module.scss rename to packages/tenant-enrollment-react/src/components/no-access/no-access.module.scss index 2c14a84..0ec9dcc 100644 --- a/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.module.scss +++ b/packages/tenant-enrollment-react/src/components/no-access/no-access.module.scss @@ -1,4 +1,4 @@ -.awaitingApprovalMessageContainer { +.noAccessMessageContainer { .header { font-weight: 700; font-size: 28px; @@ -26,5 +26,13 @@ line-height: 20px; color: var(--neutral-color-neutral-11); } + + &.danger { + border-color: var(--semantic-colors-error-6); + + b { + color: var(--semantic-colors-error-9); + } + } } } diff --git a/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx b/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx index 14be0bf..d75d322 100644 --- a/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx +++ b/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx @@ -1,23 +1,18 @@ -import { Card } from "@shared/ui"; -import classNames from "classnames/bind"; - +import { NoAccess } from "../../components/no-access/NoAccess"; import { usePluginContext } from "../../plugin"; -import style from "./awaiting-approval.module.scss"; -const cx = classNames.bind(style); - export const AwaitingApproval = () => { const { t } = usePluginContext(); return ( - -
{t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_HEADER")}
-
+ {t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE")}{" "} {t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE_HIGHLIGHT")}
-
- + } + /> ); }; diff --git a/packages/tenant-enrollment-react/src/pages/blocked/index.ts b/packages/tenant-enrollment-react/src/pages/blocked/index.ts new file mode 100644 index 0000000..086f330 --- /dev/null +++ b/packages/tenant-enrollment-react/src/pages/blocked/index.ts @@ -0,0 +1 @@ +export { SignUpBlocked } from "./signup-blocked"; diff --git a/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx b/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx new file mode 100644 index 0000000..8f492c7 --- /dev/null +++ b/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx @@ -0,0 +1,18 @@ +import { NoAccess } from "../../components/no-access/NoAccess"; +import { usePluginContext } from "../../plugin"; + +export const SignUpBlocked = () => { + const { t } = usePluginContext(); + + return ( + + {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT")} {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_SUFFIX")} +
+ } + useDangerAccent + /> + ); +}; diff --git a/packages/tenant-enrollment-react/src/plugin.tsx b/packages/tenant-enrollment-react/src/plugin.tsx index 5f62257..c786b7a 100644 --- a/packages/tenant-enrollment-react/src/plugin.tsx +++ b/packages/tenant-enrollment-react/src/plugin.tsx @@ -14,6 +14,7 @@ import { getApi } from "./api"; import { PLUGIN_ID, API_PATH } from "./constants"; import { enableDebugLogs, logDebugMessage } from "./logger"; import { AwaitingApproval } from "./pages/awaiting-approval"; +import { SignUpBlocked } from "./pages/blocked"; import { defaultTranslationsTenantEnrollment } from "./translations"; import { SuperTokensPluginTenantEnrollmentPluginConfig, TranslationKeys } from "./types"; @@ -70,6 +71,10 @@ export const init = createPluginInitFunction< path: "/awaiting-approval", handler: () => AwaitingApproval.call(null), }, + { + path: "/signup-blocked", + handler: () => SignUpBlocked.call(null), + }, ], }; }, @@ -95,7 +100,8 @@ export const init = createPluginInitFunction< // Update the message before re-throwing the error error.message = "Not allowed to signup to tenant"; - // TODO: Redirect the user to not allowed to signup view + // Redirect the user to not allowed to signup view + window.location.assign("/signup-blocked"); } } diff --git a/packages/tenant-enrollment-react/src/translations.ts b/packages/tenant-enrollment-react/src/translations.ts index 1c0ace6..dd32bd1 100644 --- a/packages/tenant-enrollment-react/src/translations.ts +++ b/packages/tenant-enrollment-react/src/translations.ts @@ -4,5 +4,9 @@ export const defaultTranslationsTenantEnrollment = { PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE: "It is essential to obtain the tenant administrator's approval before proceeding with the", PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE_HIGHLIGHT: "tenant joining process", + PL_TE_SIGN_UP_BLOCKED_HEADER: "Signing up to the tenant is disabled", + PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT: "Signing up to this tenant is currently blocked.", + PL_TE_SIGN_UP_BLOCKED_MESSAGE_SUFFIX: + "If you think this is a mistake, please reach out to tenant administrators or request an invitation to join the tenant.", }, } as const; From 2de0965cbd3a9c36f33464970cef5d4702314e1e Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Tue, 14 Oct 2025 11:35:37 +0530 Subject: [PATCH 21/36] fix: base tenants changes undone --- packages/tenants-nodejs/src/plugin.ts | 1 - .../src/recipeImplementation.ts | 498 ------------------ shared/tenants/src/types.ts | 1 + 3 files changed, 1 insertion(+), 499 deletions(-) delete mode 100644 packages/tenants-nodejs/src/recipeImplementation.ts diff --git a/packages/tenants-nodejs/src/plugin.ts b/packages/tenants-nodejs/src/plugin.ts index 18e58ff..e8ae81c 100644 --- a/packages/tenants-nodejs/src/plugin.ts +++ b/packages/tenants-nodejs/src/plugin.ts @@ -8,7 +8,6 @@ import { PermissionClaim } from "supertokens-node/recipe/userroles"; import { createPluginInitFunction } from "@shared/js"; import { pluginUserMetadata, withRequestHandler } from "@shared/nodejs"; -import { SessionClaimValidator } from "supertokens-node/recipe/session"; import { OverrideableTenantFunctionImplementation, diff --git a/packages/tenants-nodejs/src/recipeImplementation.ts b/packages/tenants-nodejs/src/recipeImplementation.ts deleted file mode 100644 index 7e76ae4..0000000 --- a/packages/tenants-nodejs/src/recipeImplementation.ts +++ /dev/null @@ -1,498 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import supertokens from "supertokens-node"; -import { SessionContainerInterface } from "supertokens-node/recipe/session/types"; -import MultiTenancy from "supertokens-node/recipe/multitenancy"; -import { InviteeDetails, ROLES, TenantCreationRequestWithUser, TenantList } from "@shared/tenants"; -import { User, UserContext } from "supertokens-node/types"; -import { - ErrorResponse, - MetadataType, - NonOkResponse, - OverrideableTenantFunctionImplementation, - SuperTokensPluginTenantPluginConfig, - TenantCreationRequestMetadataType, -} from "./types"; -import { logDebugMessage } from "supertokens-node/lib/build/logger"; -import UserRoles from "supertokens-node/recipe/userroles"; -import { LoginMethod } from "supertokens-node/lib/build/user"; -import { assignAdminToUserInTenant, getUserIdsInTenantWithRole } from "./roles"; -import { TENANT_CREATE_METADATA_REQUESTS_KEY } from "./constants"; - -export const getOverrideableTenantFunctionImplementation = ( - pluginConfig: SuperTokensPluginTenantPluginConfig, -): OverrideableTenantFunctionImplementation => { - const implementation: OverrideableTenantFunctionImplementation = { - getTenants: async ( - sessionOrUserId: SessionContainerInterface | string, - ): Promise<({ status: "OK" } & TenantList) | { status: "ERROR"; message: string }> => { - const userId = typeof sessionOrUserId === "string" ? sessionOrUserId : sessionOrUserId.getUserId(); - - const userDetails = await supertokens.getUser(userId); - if (!userDetails) { - return { - status: "ERROR", - message: "User not found", - }; - } - - const tenantDetails = await MultiTenancy.listAllTenants(); - - // Return the tenants that the user is not a member of - return { - status: "OK", - tenants: tenantDetails.tenants.map((tenant) => ({ tenantId: tenant.tenantId, displayName: tenant.tenantId })), - joinedTenantIds: userDetails.tenantIds, - }; - }, - getTenantUsers: async (tenantId: string): Promise<{ status: "OK"; users: (User & { roles?: string[] })[] }> => { - const getUsersResponse = await supertokens.getUsersOldestFirst({ - tenantId: tenantId, - }); - - // Find all the users that have a role in the tenant - // and return details. - // Iterate through all the the available roles and find users. - const userIdToRoleMap: Record = {}; - for (const role of Object.values(ROLES)) { - const users = await getUserIdsInTenantWithRole(tenantId, role); - for (const user of users) { - userIdToRoleMap[user] = [...(userIdToRoleMap[user] || []), role]; - } - } - - return { - status: "OK", - users: getUsersResponse.users.map((user) => ({ - ...user, - roles: userIdToRoleMap[user.id] ?? [], - })), - }; - }, - addInvitation: async ( - email: string, - tenantId: string, - metadata: MetadataType, - ): Promise<{ status: "OK"; code: string } | NonOkResponse | ErrorResponse> => { - // Check if the user: - // 1. is already associated with the tenant - // 2. is already invited to the tenant - - const getUsersResponse = await supertokens.getUsersOldestFirst({ - tenantId: tenantId, - }); - - // TODO: Add support for role - - // We will have to find whether the user is already associated - // by searching with the email. - const userDetails = getUsersResponse.users.find((user) => user.emails.some((userEmail) => userEmail === email)); - if (userDetails) { - return { - status: "USER_ALREADY_ASSOCIATED", - message: "User already associated with tenant", - }; - } - - // Check if the user is already invited to the tenant - let tenantMetadata = await metadata.get(tenantId); - if (tenantMetadata?.invitees.some((invitee) => invitee.email === email)) { - return { - status: "USER_ALREADY_INVITED", - message: "User already invited to tenant", - }; - } - - if (tenantMetadata === undefined) { - tenantMetadata = { - invitees: [], - }; - } - - // Generate a random string for the code - const code = Math.random().toString(36).substring(2, 15); - - // Invite the user to the tenant - await metadata.set(tenantId, { - ...tenantMetadata, - invitees: [...tenantMetadata.invitees, { email, role: "user", code }], - }); - - return { - status: "OK", - message: "User invited to tenant", - code, - }; - }, - removeInvitation: async ( - email: string, - tenantId: string, - metadata: MetadataType, - ): Promise<{ status: "OK" } | NonOkResponse | ErrorResponse> => { - // Check if the user is invited to the tenant - const tenantMetadata = await metadata.get(tenantId); - if (!tenantMetadata) { - return { - status: "ERROR", - message: "Tenant not found", - }; - } - - // Check if the user is invited to the tenant - const isInvited = tenantMetadata.invitees.some((invitee) => invitee.email === email && invitee.role === "user"); - if (!isInvited) { - return { - status: "ERROR", - message: "User not invited to tenant", - }; - } - - // Remove the invitation from the tenants's metadata. - await metadata.set(tenantId, { - ...tenantMetadata, - invitees: tenantMetadata.invitees.filter((invitee) => invitee.email !== email), - }); - - return { - status: "OK", - message: "Invitation removed from tenant", - }; - }, - getInvitations: async ( - tenantId: string, - metadata: MetadataType, - ): Promise<{ status: "OK"; invitees: InviteeDetails[] } | NonOkResponse | ErrorResponse> => { - const tenantMetadata = await metadata.get(tenantId); - if (!tenantMetadata) { - return { - status: "ERROR", - message: "Tenant not found", - }; - } - - return { - status: "OK", - invitees: tenantMetadata.invitees, - }; - }, - acceptInvitation: async ( - code: string, - tenantId: string, - session: SessionContainerInterface, - metadata: MetadataType, - ): Promise<{ status: "OK" } | NonOkResponse | ErrorResponse> => { - // Check if the user is invited to the tenant - const tenantMetadata = await metadata.get(tenantId); - if (!tenantMetadata) { - return { - status: "ERROR", - message: "Tenant not found", - }; - } - - // Find the invitation details - const inviteeDetails = tenantMetadata.invitees.find((invitee) => invitee.code === code); - if (!inviteeDetails) { - return { - status: "ERROR", - message: "Invitation not found", - }; - } - - await implementation.associateAllLoginMethodsOfUserWithTenant( - tenantId, - session.getUserId(), - (loginMethod) => loginMethod.email === inviteeDetails.email, - ); - - // Remove the invitation from the tenants's metadata. - await metadata.set(tenantId, { - ...tenantMetadata, - invitees: tenantMetadata.invitees.filter((invitee) => invitee.email !== inviteeDetails.email), - }); - logDebugMessage(`Removed invitation from tenant ${tenantId}`); - - // TODO: Add the user with the role - - return { - status: "OK", - message: "Invitation accepted", - }; - }, - isAllowedToJoinTenant: async (user: User, session: SessionContainerInterface) => { - // By default we will allow all users to join a tenant. - return true; - }, - isAllowedToCreateTenant: async (session: SessionContainerInterface) => { - // By default we will allow all users to create a tenant. - return true; - }, - canCreateInvitation: async (user: User, role: string, session: SessionContainerInterface) => { - // By default, only owners can create invitations. - return role === ROLES.ADMIN; - }, - canApproveJoinRequest: async (user: User, role: string, session: SessionContainerInterface) => { - // By default, only owners can approve join requests. - return role === ROLES.ADMIN; - }, - canApproveTenantCreationRequest: async (user: User, role: string, session: SessionContainerInterface) => { - // By default, only owners can approve tenant creation requests. - return role === ROLES.ADMIN; - }, - canRemoveUserFromTenant: async (user: User, roles: string[], session: SessionContainerInterface) => { - // By default, only owners can remove users from a tenant. - return roles.includes(ROLES.ADMIN); - }, - associateAllLoginMethodsOfUserWithTenant: async ( - tenantId: string, - userId: string, - loginMethodFilter?: (loginMethod: LoginMethod) => boolean, - ) => { - const userDetails = await supertokens.getUser(userId); - if (!userDetails) { - throw new Error(`User ${userId} not found`); - } - - // Find all the loginMethods for the user that match the email for the - // invitation. - const loginMethods = userDetails.loginMethods.filter(loginMethodFilter ?? (() => true)); - logDebugMessage(`loginMethods: ${JSON.stringify(loginMethods)}`); - - // For each of the loginMethods, associate the user with the tenant - for (const loginMethod of loginMethods) { - await MultiTenancy.associateUserToTenant(tenantId, loginMethod.recipeUserId); - logDebugMessage(`Associated user ${userDetails.id} with tenant ${tenantId}`); - } - }, - doesTenantCreationRequireApproval: async (session: SessionContainerInterface) => { - // By default, tenant creation does not require approval. - return pluginConfig.requireTenantCreationRequestApproval ?? true; - }, - addTenantCreationRequest: async (session, tenantDetails, metadata, appUrl, userContext, sendEmail) => { - // Add tenant creation request to metadata - let tenantCreateRequestMetadata = await metadata.get(TENANT_CREATE_METADATA_REQUESTS_KEY); - - if (tenantCreateRequestMetadata === undefined) { - // Initialize it - tenantCreateRequestMetadata = { - requests: [], - }; - } - - // Add the new creation request - const requestId = Math.random().toString(36).substring(2, 15); - await metadata.set(TENANT_CREATE_METADATA_REQUESTS_KEY, { - ...tenantCreateRequestMetadata, - requests: [ - ...(tenantCreateRequestMetadata.requests ?? []), - { ...tenantDetails, userId: session.getUserId(), requestId }, - ], - }); - - // Extract the email of the user that is creating the tenant - const creatorUserId = session.getUserId(); - const userDetails = await supertokens.getUser(creatorUserId); - const creatorEmail = userDetails?.emails[0]; - - // Notify app admins - await implementation.sendTenantCreationRequestEmail( - tenantDetails.name, - creatorEmail ?? creatorUserId, - appUrl, - userContext, - sendEmail, - ); - - return { - status: "OK", - requestId, - }; - }, - getTenantCreationRequests: async (metadata: TenantCreationRequestMetadataType, userContext: UserContext) => { - const tenantCreateRequestMetadata = await metadata.get(TENANT_CREATE_METADATA_REQUESTS_KEY); - - // Fetch the user details for each user - const requestsWithUserId = tenantCreateRequestMetadata.requests; - const requestsWithUser: TenantCreationRequestWithUser[] = []; - - for (const request of tenantCreateRequestMetadata.requests) { - const userDetails = await supertokens.getUser(request.userId, userContext); - if (!userDetails) { - logDebugMessage( - `Couldn't find user details for tenant request ${request.requestId} and user: ${request.userId}`, - ); - continue; - } - - requestsWithUser.push({ ...request, user: userDetails }); - } - - return { - status: "OK", - requests: requestsWithUser, - }; - }, - acceptTenantCreationRequest: async (requestId, session, metadata) => { - /** - * Mark the request as accepted by creating the tenant - * and remove the create request. - * - * @param requestId - The id of the request to accept - * @param session - The session of the user accepting the request - * @param metadata - The metadata of the tenant - * @returns The status of the request - */ - const tenantCreateRequestMetadata = await metadata.get(TENANT_CREATE_METADATA_REQUESTS_KEY); - if (!tenantCreateRequestMetadata) { - return { - status: "ERROR", - message: "Tenant creation request not found", - }; - } - - // Find the request - const request = tenantCreateRequestMetadata.requests.find((request) => request.requestId === requestId); - if (!request) { - return { - status: "ERROR", - message: "Tenant creation request not found", - }; - } - - // Create the tenant and assign admin to the user that added the request. - const createResponse = await implementation.createTenantAndAssignAdmin( - { - name: request.name, - firstFactors: request.firstFactors, - }, - request.userId, - ); - - if (createResponse.status !== "OK") { - return createResponse; - } - - // Remove the request from the metadata - await metadata.set(TENANT_CREATE_METADATA_REQUESTS_KEY, { - ...tenantCreateRequestMetadata, - requests: tenantCreateRequestMetadata.requests.filter((request) => request.requestId !== requestId), - }); - - return { - status: "OK", - }; - }, - createTenantAndAssignAdmin: async (tenantDetails, userId) => { - const createResponse = await MultiTenancy.createOrUpdateTenant(tenantDetails.name, { - firstFactors: tenantDetails.firstFactors, - }); - - // Add the user as the admin of the tenant - await assignAdminToUserInTenant(tenantDetails.name, userId); - - return createResponse; - }, - rejectTenantCreationRequest: async (requestId, session, metadata) => { - const tenantCreateRequestMetadata = await metadata.get(TENANT_CREATE_METADATA_REQUESTS_KEY); - if (!tenantCreateRequestMetadata) { - return { - status: "ERROR", - message: "Tenant creation request not found", - }; - } - - // Remove the request from the metadata - await metadata.set(TENANT_CREATE_METADATA_REQUESTS_KEY, { - ...tenantCreateRequestMetadata, - requests: tenantCreateRequestMetadata.requests.filter((request) => request.requestId !== requestId), - }); - - return { - status: "OK", - }; - }, - sendTenantCreationRequestEmail: async (tenantId, creatorEmail, appUrl, userContext, sendEmail) => { - /** - * Send an email to all the admins of the app. - * - * @param tenantId - The id of the tenant that is being created - * @param creatorEmail - The email of the user that is creating the tenant - * @param appUrl - The url of the app - */ - const adminUsers = await getUserIdsInTenantWithRole("public", ROLES.APP_ADMIN); - - // For each of the users, we will need to find their email address. - const adminEmails = await Promise.all( - adminUsers.map(async (userId) => { - const userDetails = await supertokens.getUser(userId); - return userDetails?.emails[0]; - }), - ); - - // Send emails to all tenant admins using Promise.all - await Promise.all( - adminEmails - .filter((email) => email !== undefined) - .map(async (email) => { - await sendEmail( - { - type: "TENANT_CREATE_APPROVAL", - email, - tenantId, - creatorEmail, - appUrl, - }, - userContext, - ); - }), - ); - }, - getAppUrl: (appInfo, request, userContext) => { - /** - * Get the App URL using the app info, request and user context. - */ - const websiteDomain = appInfo.getTopLevelWebsiteDomain({ - request, - userContext, - }); - return `${websiteDomain ? "https://" : "http://"}${websiteDomain ?? "localhost"}${appInfo.websiteBasePath ?? ""}`; - }, - }; - - return implementation; -}; - -export const rejectRequestToJoinTenant = async ( - tenantId: string, - userId: string, -): Promise<{ status: "OK" } | NonOkResponse | ErrorResponse> => { - // We need to check that the user doesn't have an existing role, in which - // case we cannot "accept" the request. - const role = await UserRoles.getRolesForUser(tenantId, userId); - if (role.roles.length > 0) { - return { - status: "ERROR", - message: "Request already accepted", - }; - } - - // Find all the recipeUserIds for the user - // Remove the user from the tenant - const userDetails = await supertokens.getUser(userId); - if (!userDetails) { - return { - status: "ERROR", - message: "User not found", - }; - } - - // For each of the loginMethods, associate the user with the tenant - for (const loginMethod of userDetails.loginMethods) { - await MultiTenancy.disassociateUserFromTenant(tenantId, loginMethod.recipeUserId); - logDebugMessage(`Disassociated user ${userDetails.id} from tenant ${tenantId}`); - } - - return { - status: "OK", - message: "Request rejected", - }; -}; diff --git a/shared/tenants/src/types.ts b/shared/tenants/src/types.ts index 9356e60..2720152 100644 --- a/shared/tenants/src/types.ts +++ b/shared/tenants/src/types.ts @@ -20,6 +20,7 @@ export type TenantList = { export type InviteeDetails = { email: string; + role: string; code: string; }; From 838336113cfcd73bb2bd092081cb39be03a48ae3 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Tue, 14 Oct 2025 11:37:56 +0530 Subject: [PATCH 22/36] op: remove unused files from tenants react kept during rebase --- .../components/invitations/invitations.tsx | 78 ----------------- .../components/requests/TenantRequests.tsx | 36 -------- .../src/invitation-accept-wrapper.tsx | 15 ---- .../tenants-react/src/select-tenant-page.tsx | 17 ---- .../src/tenant-details-wrapper.tsx | 21 ----- packages/tenants-react/src/tenant-wrapper.tsx | 84 ------------------- 6 files changed, 251 deletions(-) delete mode 100644 packages/tenants-react/src/components/invitations/invitations.tsx delete mode 100644 packages/tenants-react/src/components/requests/TenantRequests.tsx delete mode 100644 packages/tenants-react/src/invitation-accept-wrapper.tsx delete mode 100644 packages/tenants-react/src/select-tenant-page.tsx delete mode 100644 packages/tenants-react/src/tenant-details-wrapper.tsx delete mode 100644 packages/tenants-react/src/tenant-wrapper.tsx diff --git a/packages/tenants-react/src/components/invitations/invitations.tsx b/packages/tenants-react/src/components/invitations/invitations.tsx deleted file mode 100644 index 578229c..0000000 --- a/packages/tenants-react/src/components/invitations/invitations.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { InviteeDetails } from "@shared/tenants"; -import { usePrettyAction } from "@shared/ui"; -import { useCallback, useEffect, useState } from "react"; - -import { usePluginContext } from "../../plugin"; -import { TenantTab } from "../tab/TenantTab"; - -import { AddInvitation } from "./AddInvitation"; -import { InvitedUsers } from "./InvitedUsers"; - -type InvitationsProps = { - onFetch: (tenantId?: string) => Promise<{ invitations: InviteeDetails[] }>; - selectedTenantId: string; -}; - -export const Invitations: React.FC = ({ selectedTenantId, onFetch }) => { - const { api } = usePluginContext(); - const { addInvitation, removeInvitation } = api; - - const [invitations, setInvitations] = useState([]); - - const loadDetails = useCallback( - async (tenantId?: string) => { - const details = await onFetch(tenantId || selectedTenantId); - setInvitations(details.invitations); - }, - [onFetch, selectedTenantId], - ); - - useEffect(() => { - if (selectedTenantId) { - loadDetails(selectedTenantId); - } - }, [selectedTenantId, loadDetails]); - - const onCreateInvite = useCallback( - async (email: string) => { - const response = await addInvitation(email); - if (response.status === "ERROR") { - throw new Error(response.message); - } - - // If `OK` status, add the newly added invitation to the - // list of invitations. - setInvitations((currentInvitations) => [ - ...currentInvitations, - { - email, - code: response.code, - }, - ]); - }, - [addInvitation], - ); - - const onRemoveInvite = usePrettyAction( - async (email: string) => { - const response = await removeInvitation(email); - if (response.status === "ERROR") { - throw new Error(response.message); - } - - // If it was successful, remove the invitation from the - // list. - setInvitations((currentInvitations) => currentInvitations.filter((invitation) => invitation.email !== email)); - }, - [removeInvitation], - { errorMessage: "Failed to remove invitation, please try again" }, - ); - - return ( - }> - - - ); -}; diff --git a/packages/tenants-react/src/components/requests/TenantRequests.tsx b/packages/tenants-react/src/components/requests/TenantRequests.tsx deleted file mode 100644 index de5c9ec..0000000 --- a/packages/tenants-react/src/components/requests/TenantRequests.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { TabGroup, Tab, TabPanel } from "@shared/ui"; -import classNames from "classnames/bind"; - -import { usePluginContext } from "../../plugin"; -import { TenantTab } from "../tab/TenantTab"; - -import { CreationRequests } from "./CreationRequests"; -import { OnboardingRequests } from "./OnboardingRequests"; -import style from "./requests.module.scss"; - -const cx = classNames.bind(style); - -export const TenantRequests = () => { - const { t } = usePluginContext(); - - return ( -
- - {t("PL_TB_TENANT_REQUESTS_ONBOARDING_TAB_LABEL")} - {t("PL_TB_TENANT_REQUESTS_CREATION_TAB_LABEL")} - - {/* Tab Content */} - - - - - - - - - - - -
- ); -}; diff --git a/packages/tenants-react/src/invitation-accept-wrapper.tsx b/packages/tenants-react/src/invitation-accept-wrapper.tsx deleted file mode 100644 index e6010ac..0000000 --- a/packages/tenants-react/src/invitation-accept-wrapper.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { SuperTokensWrapper } from "supertokens-auth-react"; - -import { AcceptInvitation } from "./components/invitations/accept"; -import { usePluginContext } from "./plugin"; -// import { SessionAuth } from 'supertokens-auth-react/recipe/session'; - -export const InvitationAcceptWrapper = () => { - const { api } = usePluginContext(); - - return ( - - - - ); -}; diff --git a/packages/tenants-react/src/select-tenant-page.tsx b/packages/tenants-react/src/select-tenant-page.tsx deleted file mode 100644 index 02e9acf..0000000 --- a/packages/tenants-react/src/select-tenant-page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { SuperTokensWrapper } from "supertokens-auth-react"; -import { SessionAuth } from "supertokens-auth-react/recipe/session"; - -import { PageWrapper } from "./components"; -import TenantCardWrapper from "./tenant-wrapper"; - -export const SelectTenantPage = () => { - return ( - - - - - - - - ); -}; diff --git a/packages/tenants-react/src/tenant-details-wrapper.tsx b/packages/tenants-react/src/tenant-details-wrapper.tsx deleted file mode 100644 index cf7f939..0000000 --- a/packages/tenants-react/src/tenant-details-wrapper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// import { BaseFormSection } from "@supertokens-plugin-profile/common-details-shared"; -import { useCallback } from 'react'; - -import { DetailsWrapper } from './components/users/TenantUsers'; -import { usePluginContext } from './plugin'; - -export const TenantDetailsWrapper = ({ section }: { section: any }) => { - const { api } = usePluginContext(); - - const onFetch = useCallback(async () => { - // Use the `tid` from the users access token payload. - - const response = await api.getUsers(); - if (response.status === 'ERROR') { - throw new Error(response.message); - } - return { users: response.users }; - }, [api.getUsers, section.id]); - - return ; -}; diff --git a/packages/tenants-react/src/tenant-wrapper.tsx b/packages/tenants-react/src/tenant-wrapper.tsx deleted file mode 100644 index 1b88192..0000000 --- a/packages/tenants-react/src/tenant-wrapper.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { TenantCreateData, TenantJoinData, TenantList } from "@shared/tenants"; -import { ToastProvider, ToastContainer } from "@shared/ui"; -import { useEffect, useState } from "react"; - -import { TenantCard } from "./components"; -import { logDebugMessage } from "./logger"; -import { usePluginContext } from "./plugin"; - -const TenantCardWrapper = () => { - const { api } = usePluginContext(); - const { fetchTenants, joinTenant, createTenant } = api; - const [data, setData] = useState({ - tenants: [], - joinedTenantIds: [], - }); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - setIsLoading(true); - fetchTenants().then((result) => { - if (result.status === "OK") { - setData(result); - } - setIsLoading(false); - }); - }, []); - - const handleOnJoin = async (data: TenantJoinData) => { - setIsLoading(true); - try { - const result = await joinTenant(data); - - // If there was an error, show that - if (result.status === "ERROR") { - console.error(result.message); - return result; - } - - // If it was successful, redirect the user to `/user/profile`. - if (result.status === "OK") { - logDebugMessage("Successfully joined tenant"); - if (result.wasSessionRefreshed) { - logDebugMessage("Session was refreshed"); - } else { - logDebugMessage("Please go to `/user/profile` to continue"); - } - } - - return result; - } finally { - setIsLoading(false); - } - }; - - const handleOnCreate = async (data: TenantCreateData) => { - setIsLoading(true); - try { - const result = await createTenant(data); - - // If there was an error, show that - if (result.status === "ERROR") { - console.error(result.message); - return result; - } - - return result; - } finally { - setIsLoading(false); - } - }; - - return ; -}; - -const TenantCardWrapperWithToast = () => { - return ( - - - - - ); -}; - -export default TenantCardWrapperWithToast; From 98609c374bb369d693154aa54dd8a65d941c969e Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Tue, 14 Oct 2025 14:37:58 +0530 Subject: [PATCH 23/36] feat: re-use components for base tenants plugin in enrollment --- package-lock.json | 750 ++++++++++++++---- packages/tenant-enrollment-react/package.json | 3 +- .../src/components/no-access/NoAccess.tsx | 20 - .../src/components/no-access/index.ts | 1 - .../no-access/no-access.module.scss | 38 - .../awaiting-approval/awaiting-approval.tsx | 9 +- .../src/pages/blocked/signup-blocked.tsx | 8 +- .../tenant-card/awaiting-approval.tsx | 23 +- .../src/components/tenant-card/index.ts | 3 +- .../tenant-card/tenant-card.module.scss | 8 + packages/tenants-react/src/index.ts | 3 +- 11 files changed, 620 insertions(+), 246 deletions(-) delete mode 100644 packages/tenant-enrollment-react/src/components/no-access/NoAccess.tsx delete mode 100644 packages/tenant-enrollment-react/src/components/no-access/index.ts delete mode 100644 packages/tenant-enrollment-react/src/components/no-access/no-access.module.scss diff --git a/package-lock.json b/package-lock.json index 34c74a0..1c31142 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4026,6 +4026,14 @@ "resolved": "packages/tenant-discovery-react", "link": true }, + "node_modules/@supertokens-plugins/tenant-enrollment-nodejs": { + "resolved": "packages/tenant-enrollment-nodejs", + "link": true + }, + "node_modules/@supertokens-plugins/tenant-enrollment-react": { + "resolved": "packages/tenant-enrollment-react", + "link": true + }, "node_modules/@supertokens-plugins/tenants-nodejs": { "resolved": "packages/tenants-nodejs", "link": true @@ -4728,17 +4736,6 @@ "@types/node": "*" } }, - "node_modules/@types/nodemailer": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.1.tgz", - "integrity": "sha512-UfHAghPmGZVzaL8x9y+mKZMWyHC399+iq0MOmya5tIyenWX3lcdSb60vOmp0DocR6gCDTYTozv/ULQnREyyjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@aws-sdk/client-sesv2": "^3.839.0", - "@types/node": "*" - } - }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4802,13 +4799,6 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", @@ -15633,20 +15623,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -19813,7 +19789,7 @@ }, "packages/progressive-profiling-nodejs": { "name": "@supertokens-plugins/progressive-profiling-nodejs", - "version": "0.2.0", + "version": "0.3.0", "devDependencies": { "@shared/eslint": "*", "@shared/nodejs": "*", @@ -19949,7 +19925,7 @@ }, "packages/progressive-profiling-react": { "name": "@supertokens-plugins/progressive-profiling-react", - "version": "0.2.0", + "version": "0.3.1", "dependencies": { "supertokens-js-override": "^0.0.4" }, @@ -20786,24 +20762,6 @@ "node": ">=12" } }, - "packages/tenant-discovery-nodejs": { - "name": "@supertokens-plugins/tenant-discovery-nodejs", - "version": "0.2.1", - "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-react/node_modules/@esbuild/linux-loong64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", @@ -21316,15 +21274,13 @@ } } }, - "packages/tenants-nodejs": { - "name": "@supertokens-plugins/tenants-nodejs", - "version": "0.0.1", + "packages/tenant-enrollment-nodejs": { + "name": "@supertokens-plugins/tenant-enrollment-nodejs", + "version": "0.1.0", "devDependencies": { "@shared/eslint": "*", "@shared/nodejs": "*", - "@shared/tenants": "*", "@shared/tsconfig": "*", - "@types/nodemailer": "^7.0.1", "@types/react": "^17.0.20", "express": "^5.1.0", "prettier": "3.6.2", @@ -21333,14 +21289,14 @@ "vitest": "^3.2.4" }, "peerDependencies": { - "nodemailer": "^6.0.0", + "@supertokens-plugins/tenants-nodejs": "*", "supertokens-node": ">=23.0.0" } }, - "packages/tenants-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==", + "packages/tenant-enrollment-nodejs/node_modules/@types/react": { + "version": "17.0.89", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.89.tgz", + "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -21349,9 +21305,9 @@ "csstype": "^3.0.2" } }, - "packages/tenants-react": { - "name": "@supertokens-plugins/tenants-react", - "version": "0.0.1", + "packages/tenant-enrollment-react": { + "name": "@supertokens-plugins/tenant-enrollment-react", + "version": "0.1.0", "dependencies": { "supertokens-js-override": "^0.0.4" }, @@ -21360,7 +21316,6 @@ "@shared/js": "*", "@shared/react": "*", "@shared/tsconfig": "*", - "@supertokens-plugins/progressive-profiling-shared": "*", "@testing-library/jest-dom": "^6.1.0", "@types/react": "^17.0.20", "@vitejs/plugin-react": "^4.5.2", @@ -21370,162 +21325,615 @@ "rollup-plugin-peer-deps-external": "^2.2.4", "typescript": "^5.8.3", "vite": "^6.3.5", - "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-dts": "^4.5.4", "vitest": "^1.3.1" }, "peerDependencies": { - "@supertokens-plugins/profile-base-react": ">=0.0.1", + "@supertokens-plugins/tenants-react": ">=0.0.1", "react": ">=18.3.1", "react-dom": ">=18.3.1", "supertokens-auth-react": ">=0.50.0", "supertokens-web-js": ">=0.16.0" } }, - "packages/tenants-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/tenants-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==", + "packages/tenant-enrollment-react/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "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" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": ">=12" } }, - "packages/tenants-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==", + "packages/tenant-enrollment-react/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "packages/tenants-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==", + "packages/tenant-enrollment-react/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=16.17.0" + "node": ">=12" } }, - "packages/tenants-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==", + "packages/tenant-enrollment-react/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "packages/tenants-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==", + "packages/tenant-enrollment-react/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/tenants-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==", + "packages/tenant-enrollment-react/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "packages/tenants-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==", + "packages/tenant-enrollment-react/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/tenants-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==", + "packages/tenant-enrollment-react/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/tenants-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==", + "packages/tenant-enrollment-react/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "packages/tenants-react/node_modules/vitest": { + "packages/tenant-enrollment-react/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/tenant-enrollment-react/node_modules/@types/react": { + "version": "17.0.89", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.89.tgz", + "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "csstype": "^3.0.2" + } + }, + "packages/tenant-enrollment-react/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/tenant-enrollment-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-enrollment-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-enrollment-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-enrollment-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-enrollment-react/node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "packages/tenant-enrollment-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-enrollment-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-enrollment-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-enrollment-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-enrollment-react/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "packages/tenant-enrollment-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-enrollment-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==", @@ -21591,10 +21999,10 @@ } } }, - "packages/tenants-react/node_modules/vitest/node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "packages/tenant-enrollment-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": { @@ -21653,7 +22061,7 @@ }, "packages/tenants-nodejs": { "name": "@supertokens-plugins/tenants-nodejs", - "version": "0.0.1", + "version": "0.2.0", "devDependencies": { "@shared/eslint": "*", "@shared/nodejs": "*", @@ -21686,7 +22094,7 @@ }, "packages/tenants-react": { "name": "@supertokens-plugins/tenants-react", - "version": "0.0.1", + "version": "0.2.0", "dependencies": { "supertokens-js-override": "^0.0.4" }, @@ -21695,6 +22103,7 @@ "@shared/js": "*", "@shared/react": "*", "@shared/tsconfig": "*", + "@supertokens-plugins/progressive-profiling-shared": "*", "@testing-library/jest-dom": "^6.1.0", "@types/react": "^17.0.20", "@vitejs/plugin-react": "^4.5.2", @@ -21709,6 +22118,7 @@ "vitest": "^1.3.1" }, "peerDependencies": { + "@supertokens-plugins/profile-base-react": ">=0.0.1", "react": ">=18.3.1", "react-dom": ">=18.3.1", "supertokens-auth-react": ">=0.50.0", diff --git a/packages/tenant-enrollment-react/package.json b/packages/tenant-enrollment-react/package.json index 5cf82f0..3969037 100644 --- a/packages/tenant-enrollment-react/package.json +++ b/packages/tenant-enrollment-react/package.json @@ -27,7 +27,8 @@ "react": ">=18.3.1", "react-dom": ">=18.3.1", "supertokens-auth-react": ">=0.50.0", - "supertokens-web-js": ">=0.16.0" + "supertokens-web-js": ">=0.16.0", + "@supertokens-plugins/tenants-react": ">=0.0.1" }, "devDependencies": { "@shared/eslint": "*", diff --git a/packages/tenant-enrollment-react/src/components/no-access/NoAccess.tsx b/packages/tenant-enrollment-react/src/components/no-access/NoAccess.tsx deleted file mode 100644 index c1eb780..0000000 --- a/packages/tenant-enrollment-react/src/components/no-access/NoAccess.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Card } from "@shared/ui"; -import classNames from "classnames/bind"; - -import style from "./no-access.module.scss"; -const cx = classNames.bind(style); - -type NoAccessProps = { - headerText: string; - descriptionComponent: React.ReactNode; - useDangerAccent?: boolean; -}; - -export const NoAccess: React.FC = ({ headerText, descriptionComponent, useDangerAccent = false }) => { - return ( - -
{headerText}
-
{descriptionComponent}
-
- ); -}; diff --git a/packages/tenant-enrollment-react/src/components/no-access/index.ts b/packages/tenant-enrollment-react/src/components/no-access/index.ts deleted file mode 100644 index 7930e5a..0000000 --- a/packages/tenant-enrollment-react/src/components/no-access/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { NoAccess } from "./NoAccess"; diff --git a/packages/tenant-enrollment-react/src/components/no-access/no-access.module.scss b/packages/tenant-enrollment-react/src/components/no-access/no-access.module.scss deleted file mode 100644 index 0ec9dcc..0000000 --- a/packages/tenant-enrollment-react/src/components/no-access/no-access.module.scss +++ /dev/null @@ -1,38 +0,0 @@ -.noAccessMessageContainer { - .header { - font-weight: 700; - font-size: 28px; - line-height: 36px; - letter-spacing: -0.12px; - color: var(--neutral-color-neutral-12); - margin: 0 0 16px 0; - } - - .messageContainer { - box-shadow: 0px 1.5px 2px 0px rgba(0, 0, 0, 0.133) inset; - border: 1px solid var(--neutral-color-neutral-6); - background-color: #f9f9f8; - border-radius: 12px; - padding: 14px; - - font-weight: 400; - font-size: 14px; - line-height: 20px; - letter-spacing: 0px; - - b { - font-weight: 600; - font-size: 14px; - line-height: 20px; - color: var(--neutral-color-neutral-11); - } - - &.danger { - border-color: var(--semantic-colors-error-6); - - b { - color: var(--semantic-colors-error-9); - } - } - } -} diff --git a/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx b/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx index d75d322..bc9d55d 100644 --- a/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx +++ b/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx @@ -1,13 +1,14 @@ -import { NoAccess } from "../../components/no-access/NoAccess"; +import { AwaitingApprovalMessage } from "@supertokens-plugins/tenants-react"; + import { usePluginContext } from "../../plugin"; -export const AwaitingApproval = () => { +export const AwaitingAdminApproval = () => { const { t } = usePluginContext(); return ( - {t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE")}{" "} {t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE_HIGHLIGHT")} diff --git a/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx b/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx index 8f492c7..9576191 100644 --- a/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx +++ b/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx @@ -1,18 +1,20 @@ -import { NoAccess } from "../../components/no-access/NoAccess"; +import { AwaitingApprovalMessage } from "@supertokens-plugins/tenants-react"; + import { usePluginContext } from "../../plugin"; export const SignUpBlocked = () => { const { t } = usePluginContext(); return ( - {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT")} {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_SUFFIX")}
} useDangerAccent + hideLogoutButton /> ); }; diff --git a/packages/tenants-react/src/components/tenant-card/awaiting-approval.tsx b/packages/tenants-react/src/components/tenant-card/awaiting-approval.tsx index 327c4a9..6931f29 100644 --- a/packages/tenants-react/src/components/tenant-card/awaiting-approval.tsx +++ b/packages/tenants-react/src/components/tenant-card/awaiting-approval.tsx @@ -10,9 +10,16 @@ const cx = classNames.bind(style); type AwaitingApprovalMessageProps = { headerText: string; messageContent: React.ReactNode; + hideLogoutButton?: boolean; + useDangerAccent?: boolean; }; -export const AwaitingApprovalMessage: React.FC = ({ headerText, messageContent }) => { +export const AwaitingApprovalMessage: React.FC = ({ + headerText, + messageContent, + hideLogoutButton = false, + useDangerAccent = false, +}) => { const { t } = usePluginContext(); const onLogOutClick = async () => { @@ -23,12 +30,14 @@ export const AwaitingApprovalMessage: React.FC = ( return (
{headerText}
-
{messageContent}
-
- -
+
{messageContent}
+ {!hideLogoutButton && ( +
+ +
+ )}
); }; diff --git a/packages/tenants-react/src/components/tenant-card/index.ts b/packages/tenants-react/src/components/tenant-card/index.ts index b35ab18..f7ef212 100644 --- a/packages/tenants-react/src/components/tenant-card/index.ts +++ b/packages/tenants-react/src/components/tenant-card/index.ts @@ -1 +1,2 @@ -export * from './tenant-card'; +export * from "./tenant-card"; +export * from "./awaiting-approval"; diff --git a/packages/tenants-react/src/components/tenant-card/tenant-card.module.scss b/packages/tenants-react/src/components/tenant-card/tenant-card.module.scss index a7f3a09..9cc5b31 100644 --- a/packages/tenants-react/src/components/tenant-card/tenant-card.module.scss +++ b/packages/tenants-react/src/components/tenant-card/tenant-card.module.scss @@ -87,6 +87,14 @@ line-height: 20px; color: var(--neutral-color-neutral-11); } + + &.danger { + border-color: var(--semantic-colors-error-6); + + b { + color: var(--semantic-colors-error-9); + } + } } .logoutBtnContainer { diff --git a/packages/tenants-react/src/index.ts b/packages/tenants-react/src/index.ts index 5ce4ccc..5c28657 100644 --- a/packages/tenants-react/src/index.ts +++ b/packages/tenants-react/src/index.ts @@ -1,5 +1,6 @@ +import { AwaitingApprovalMessage } from "./components"; import { PLUGIN_ID } from "./constants"; import { init } from "./plugin"; -export { init, PLUGIN_ID }; +export { init, PLUGIN_ID, AwaitingApprovalMessage }; export default { init, PLUGIN_ID }; From 44fecbc23d6ac286173b1332ac22710ac0b76769 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Tue, 14 Oct 2025 15:43:04 +0530 Subject: [PATCH 24/36] feat: refactor the overrides to properly return general error --- .../tenant-enrollment-nodejs/src/plugin.ts | 107 ++++++++++++------ ...lementation.ts => pluginImplementation.ts} | 8 +- .../tenant-enrollment-nodejs/src/types.ts | 13 ++- 3 files changed, 87 insertions(+), 41 deletions(-) rename packages/tenant-enrollment-nodejs/src/{recipeImplementation.ts => pluginImplementation.ts} (94%) diff --git a/packages/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts index 76eb73e..f6a4d4d 100644 --- a/packages/tenant-enrollment-nodejs/src/plugin.ts +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -6,7 +6,7 @@ import { SuperTokensPluginTenantEnrollmentPluginConfig, SuperTokensPluginTenantEnrollmentPluginNormalisedConfig, } from "./types"; -import { getOverrideableTenantFunctionImplementation } from "./recipeImplementation"; +import { getOverrideableTenantFunctionImplementation } from "./pluginImplementation"; import { logDebugMessage } from "supertokens-node/lib/build/logger"; import { AssociateAllLoginMethodsOfUserWithTenant, @@ -153,10 +153,13 @@ export const init = createPluginInitFunction< ...originalImplementation, signInUpPOST: async (input) => { const response = await originalImplementation.signInUpPOST!(input); - if (response.status === "SIGN_IN_UP_NOT_ALLOWED" && response.reason.includes("ERR_CODE_020")) { + // If the status is `SIGN_IN_UP_NOT_ALLOWED`, we will have to pick that + // up and return a GENERAL_ERROR instead to make the error passed along to + // the FE + if (response.status === "SIGN_IN_UP_NOT_ALLOWED") { return { - ...response, - reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", + status: "GENERAL_ERROR", + message: (response as any).reason, }; } @@ -189,9 +192,9 @@ export const init = createPluginInitFunction< logDebugMessage("Reason: " + reason); if (!canJoin) { return { - status: "LINKING_TO_SESSION_USER_FAILED", - reason: "EMAIL_VERIFICATION_REQUIRED", - }; + status: "SIGN_IN_UP_NOT_ALLOWED", + reason, + } as any; } const response = await originalImplementation.signInUp(input); @@ -222,23 +225,50 @@ export const init = createPluginInitFunction< return { ...originalImplementation, createCodePOST: async (input) => { - const response = await originalImplementation.createCodePOST!(input); - if (response.status === "SIGN_IN_UP_NOT_ALLOWED" && response.reason.includes("ERR_CODE_002")) { + // If this is a signup, we need to check if the user + // can signup to the tenant. + + // If this is a signup but its through phone number, we cannot + // restrict it so we will let it go through. + if ("phoneNumber" in input) { + return originalImplementation.createCodePOST!(input); + } + + const isSignUp = implementation.isEmailOrPhonePresentInTenant(input.tenantId, input); + + if (!isSignUp) { + return originalImplementation.createCodePOST!(input); + } + + const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { + type: "email", + email: input.email, + }); + logDebugMessage("Reason: " + reason); + + if (!canJoin) { return { - ...response, - reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", + status: "GENERAL_ERROR", + message: reason, } as any; } - return response; + return await originalImplementation.createCodePOST!(input); }, consumeCodePOST: async (input) => { const response = await originalImplementation.consumeCodePOST!(input); - if (response.status === "SIGN_IN_UP_NOT_ALLOWED" && response.reason.includes("ERR_CODE_002")) { - return { - ...response, - reason: "Cannot sign in / sign up due to security reasons or tenant doesn't allow signup", - } as any; + if (response.status === "RESTART_FLOW_ERROR") { + // If reason is defined in response, return as GENERAL_ERROR + // instead with the error. + const reason = (response as any).reason; + if (reason === undefined) { + return response; + } else { + return { + status: "GENERAL_ERROR", + message: reason, + }; + } } return response; @@ -249,22 +279,29 @@ export const init = createPluginInitFunction< return { ...originalImplementation, createCode: async (input) => { + // NOTE: We are duplicating the code from createCodePOST + // here because we want to ensure that the same checks + // are applied to the API as well as the function. + // + // Ideally, we should check here and return a message to + // createCodePOST but that is not possible since `createCodePOST` + // modifies the response and adds a custom reason if it's a + // non OK status so we won't be able to pass the actual reason + // back to the FE. + // If this is a signup, we need to check if the user // can signup to the tenant. - const accountInfoResponse = await listUsersByAccountInfo(input.tenantId, { - email: "email" in input ? input.email : undefined, - phoneNumber: "phoneNumber" in input ? input.phoneNumber : undefined, - }); - const isSignUp = accountInfoResponse.length === 0; - - if (!isSignUp) { - return originalImplementation.createCode(input); - } // If this is a signup but its through phone number, we cannot // restrict it so we will let it go through. if ("phoneNumber" in input) { - return originalImplementation.createCode(input); + return originalImplementation.createCode!(input); + } + + const isSignUp = implementation.isEmailOrPhonePresentInTenant(input.tenantId, input); + + if (!isSignUp) { + return originalImplementation.createCode!(input); } const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { @@ -275,11 +312,12 @@ export const init = createPluginInitFunction< if (!canJoin) { return { - status: "SIGN_IN_UP_NOT_ALLOWED", + status: "GENERAL_ERROR", + message: reason, } as any; } - return originalImplementation.createCode(input); + return await originalImplementation.createCode!(input); }, consumeCode: async (input) => { // If this is a signup, we need to check if the user @@ -304,11 +342,11 @@ export const init = createPluginInitFunction< input.tenantId, deviceInfo.phoneNumber !== undefined ? { - phoneNumber: deviceInfo.phoneNumber!, - } + phoneNumber: deviceInfo.phoneNumber!, + } : { - email: deviceInfo.email!, - }, + email: deviceInfo.email!, + }, ); const isSignUp = accountInfoResponse.length === 0; @@ -328,7 +366,8 @@ export const init = createPluginInitFunction< if (!canJoin) { return { - status: "SIGN_IN_UP_NOT_ALLOWED", + status: "RESTART_FLOW_ERROR", + reason, } as any; } diff --git a/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts similarity index 94% rename from packages/tenant-enrollment-nodejs/src/recipeImplementation.ts rename to packages/tenant-enrollment-nodejs/src/pluginImplementation.ts index 8177471..5167857 100644 --- a/packages/tenant-enrollment-nodejs/src/recipeImplementation.ts +++ b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts @@ -1,4 +1,4 @@ -import { User } from "supertokens-node"; +import { User, listUsersByAccountInfo } from "supertokens-node"; import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from "./types"; import { assignRoleToUserInTenant, @@ -164,6 +164,12 @@ export const getOverrideableTenantFunctionImplementation = ( getUserIdsInTenantWithRole: async (tenantId, role) => { throw new Error("Not implemented"); }, + isEmailOrPhonePresentInTenant: async (tenantId, details) => { + const accountInfoResponse = await listUsersByAccountInfo(tenantId, { + email: "email" in details ? details.email : undefined, + }); + return accountInfoResponse.length === 0; + } }; return implementation; diff --git a/packages/tenant-enrollment-nodejs/src/types.ts b/packages/tenant-enrollment-nodejs/src/types.ts index 0c7fd73..4edef7c 100644 --- a/packages/tenant-enrollment-nodejs/src/types.ts +++ b/packages/tenant-enrollment-nodejs/src/types.ts @@ -1,10 +1,10 @@ -import { User } from 'supertokens-node'; +import { User } from "supertokens-node"; import { AssociateAllLoginMethodsOfUserWithTenant, GetUserIdsInTenantWithRole, SendPluginEmail, -} from '@supertokens-plugins/tenants-nodejs'; -import { UserContext } from 'supertokens-node/lib/build/types'; +} from "@supertokens-plugins/tenants-nodejs"; +import { UserContext } from "supertokens-node/lib/build/types"; export type SuperTokensPluginTenantEnrollmentPluginConfig = { emailDomainToTenantIdMap: Record; @@ -20,11 +20,11 @@ export type SuperTokensPluginTenantEnrollmentPluginNormalisedConfig = { export type EmailOrThirdPartyId = | { - type: 'email'; + type: "email"; email: string; } | { - type: 'thirdParty'; + type: "thirdParty"; thirdPartyId: string; }; @@ -59,4 +59,5 @@ export type OverrideableTenantFunctionImplementation = { userContext: UserContext, ) => Promise; getUserIdsInTenantWithRole: GetUserIdsInTenantWithRole; -}; + isEmailOrPhonePresentInTenant: (tenantId: string, details: {email?: string, phoneNumber?: string}) => Promise; +} From 63348e4d3d01ab2c81ad969664629686f1acb404 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Wed, 15 Oct 2025 16:05:25 +0530 Subject: [PATCH 25/36] feat: add webauthn overrides for blocking signup/registration --- .../tenant-enrollment-nodejs/src/plugin.ts | 177 ++++++++++++++---- .../src/pluginImplementation.ts | 37 ++-- .../tenant-enrollment-nodejs/src/types.ts | 14 +- .../awaiting-approval/awaiting-approval.tsx | 19 -- .../src/pages/awaiting-approval/index.ts | 1 - .../tenant-enrollment-react/src/plugin.tsx | 92 +++------ .../src/pluginImplementation.ts | 37 ++++ packages/tenant-enrollment-react/src/types.ts | 6 +- 8 files changed, 236 insertions(+), 147 deletions(-) delete mode 100644 packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx delete mode 100644 packages/tenant-enrollment-react/src/pages/awaiting-approval/index.ts create mode 100644 packages/tenant-enrollment-react/src/pluginImplementation.ts diff --git a/packages/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts index f6a4d4d..73a3363 100644 --- a/packages/tenant-enrollment-nodejs/src/plugin.ts +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -227,23 +227,24 @@ export const init = createPluginInitFunction< createCodePOST: async (input) => { // If this is a signup, we need to check if the user // can signup to the tenant. - - // If this is a signup but its through phone number, we cannot - // restrict it so we will let it go through. - if ("phoneNumber" in input) { - return originalImplementation.createCodePOST!(input); - } - - const isSignUp = implementation.isEmailOrPhonePresentInTenant(input.tenantId, input); + const isSignUp = implementation.isUserSigningUpToTenant(input.tenantId, { + email: "email" in input ? input.email : undefined, + phoneNumber: "phoneNumber" in input ? input.phoneNumber : undefined, + }); if (!isSignUp) { return originalImplementation.createCodePOST!(input); } - const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { - type: "email", - email: input.email, - }); + const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, ( + "email" in input ? { + type: "email", + email: input.email, + } : { + type: "phoneNumber", + phoneNumber: input.phoneNumber, + } + )); logDebugMessage("Reason: " + reason); if (!canJoin) { @@ -289,25 +290,24 @@ export const init = createPluginInitFunction< // non OK status so we won't be able to pass the actual reason // back to the FE. - // If this is a signup, we need to check if the user - // can signup to the tenant. - - // If this is a signup but its through phone number, we cannot - // restrict it so we will let it go through. - if ("phoneNumber" in input) { - return originalImplementation.createCode!(input); - } - - const isSignUp = implementation.isEmailOrPhonePresentInTenant(input.tenantId, input); + const isSignUp = implementation.isUserSigningUpToTenant(input.tenantId, { + email: "email" in input ? input.email : undefined, + phoneNumber: "phoneNumber" in input ? input.phoneNumber : undefined, + }); if (!isSignUp) { return originalImplementation.createCode!(input); } - const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { - type: "email", - email: input.email, - }); + const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, ( + "email" in input ? { + type: "email", + email: input.email, + } : { + type: "phoneNumber", + phoneNumber: input.phoneNumber, + } + )); logDebugMessage("Reason: " + reason); if (!canJoin) { @@ -338,17 +338,13 @@ export const init = createPluginInitFunction< }; } - const accountInfoResponse = await listUsersByAccountInfo( - input.tenantId, - deviceInfo.phoneNumber !== undefined - ? { - phoneNumber: deviceInfo.phoneNumber!, - } - : { - email: deviceInfo.email!, - }, - ); - const isSignUp = accountInfoResponse.length === 0; + const isSignUp = await implementation.isUserSigningUpToTenant(input.tenantId, deviceInfo.phoneNumber !== undefined + ? { + phoneNumber: deviceInfo.phoneNumber!, + } + : { + email: deviceInfo.email!, + },); // If this is a signup or its through phone number, we cannot // restrict it so we will let it go through. @@ -358,10 +354,15 @@ export const init = createPluginInitFunction< // Since this is a signup, we need to check if the user // can signup to the tenant. - const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { - type: "email", - email: deviceInfo.email!, - }); + const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, ( + "email" in deviceInfo ? { + type: "email", + email: deviceInfo.email!, + } : { + type: "phoneNumber", + phoneNumber: deviceInfo.phoneNumber!, + } + )); logDebugMessage("Reason: " + reason); if (!canJoin) { @@ -376,6 +377,100 @@ export const init = createPluginInitFunction< }; }, }, + webauthn: { + functions: (originalImplementation) => ({ + ...originalImplementation, + registerOptions: async (input) => { + let userEmail: string | undefined; + if ("email" in input) { + // User's email is provided so we can check + // if they are trying to signup in which case + // we will block this accordingly. + const isSignUp = await implementation.isUserSigningUpToTenant(input.tenantId, input.email); + if (!isSignUp) { + // If the user is not signing up, we can continue the original + // implementation + return originalImplementation.registerOptions(input); + } + + userEmail = input.email; + } else if ("recoverAccountToken" in input) { + // User is trying to register credential through recoverAccountToken + // where there is a possibility that the user doesn't exist. + const result = await originalImplementation.getUserFromRecoverAccountToken({ + token: input.recoverAccountToken, + tenantId: input.tenantId, + userContext: input.userContext, + }); + + if (result.status !== "OK") { + return result; + } + + // If the recipeId is undefined, that means the user is signing up. + if (result.recipeUserId !== undefined) { + // Since the user is not signing up, we will continue with the original + // flow here. + return originalImplementation.registerOptions(input); + } + + // userEmail = result.user. + // TODO: Change after confirmation from Victor/Mihaly + } + + if (userEmail === undefined) { + // Since the email is undefined, we cannot do anything, return + // original implementation. + return originalImplementation.registerOptions(input); + } + + // If execution reaches this point, it means the user is + // signing up so we will need to check if they are allowed to + // do that. + const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, { + type: "email", + email: userEmail, + }); + logDebugMessage("Reason: " + reason); + if (!canJoin) { + return { + status: "GENERAL_ERROR", + message: reason, + } as any; + } + + return originalImplementation.registerOptions(input); + }, + }), + apis: (originalImplementation) => ({ + ...originalImplementation, + signUpPOST: async (input) => { + const response = await originalImplementation.signUpPOST!(input); + + if (response.status !== "OK") { + return response; + } + + logDebugMessage("Going ahead with checking tenant joining approval"); + const { wasAddedToTenant, reason: tenantJoiningReason } = + await implementation.handleTenantJoiningApproval( + response.user, + input.tenantId, + associateLoginMethodDef, + sendEmail, + getAppUrlDef(appInfo, undefined, input.userContext), + input.userContext, + ); + logDebugMessage(`wasAdded: ${wasAddedToTenant}`); + logDebugMessage(`reason: ${tenantJoiningReason}`); + return { + status: "PENDING_APPROVAL" as any, + wasAddedToTenant, + reason: tenantJoiningReason, + }; + }, + }), + }, }, }; }, diff --git a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts index 5167857..7350055 100644 --- a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts +++ b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { User, listUsersByAccountInfo } from "supertokens-node"; import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from "./types"; import { @@ -13,7 +14,7 @@ export const getOverrideableTenantFunctionImplementation = ( config: SuperTokensPluginTenantEnrollmentPluginConfig, ): OverrideableTenantFunctionImplementation => { const implementation: OverrideableTenantFunctionImplementation = { - canUserJoinTenant: async (tenantId, emailOrThirdPartyId) => { + canUserJoinTenant: async (tenantId, userIdentificationDetail) => { /** * Check if the user can join the tenant based on the email domain * @@ -41,16 +42,22 @@ export const getOverrideableTenantFunctionImplementation = ( let canJoin = false; let reason = undefined; - if (emailOrThirdPartyId.type === "email") { - canJoin = implementation.isMatchingEmailDomain(tenantId, emailOrThirdPartyId.email); + if (userIdentificationDetail.type === "email") { + canJoin = implementation.isMatchingEmailDomain(tenantId, userIdentificationDetail.email); if (!canJoin) { reason = NOT_ALLOWED_TO_SIGNUP_REASONS.EMAIL_DOMAIN_NOT_ALLOWED; } - } else if (emailOrThirdPartyId.type === "thirdParty") { - canJoin = implementation.isApprovedIdPProvider(tenantId, emailOrThirdPartyId.thirdPartyId); + } else if (userIdentificationDetail.type === "thirdParty") { + canJoin = implementation.isApprovedIdPProvider(tenantId, userIdentificationDetail.thirdPartyId); if (!canJoin) { reason = NOT_ALLOWED_TO_SIGNUP_REASONS.IDP_NOT_ALLOWED; } + } else if (userIdentificationDetail.type === "phoneNumber") { + // We don't really have a way to check anything for phones so we can + // allow signup. + return { + canJoin: true + }; } return { @@ -89,16 +96,15 @@ export const getOverrideableTenantFunctionImplementation = ( // If the tenant doesn't require approval, add the user as a member // and return. if (!implementation.doesTenantRequireApproval(tenantId)) { + // TODO: Use fn from implementation of base-tenants await assignRoleToUserInTenant(tenantId, user.id, ROLES.MEMBER); return { wasAddedToTenant: true, }; } - // If the tenant requires approval, add a request for the user - // and return. - await associateLoginMethodDef(tenantId, user.id); - + // We don't need to do anything in particular except notifying + // the tenant admins about the new user request being added. // await implementation.sendTenantJoiningRequestEmail(tenantId, user, appUrl, sendEmail, userContext); return { @@ -132,6 +138,7 @@ export const getOverrideableTenantFunctionImplementation = ( * @param user - The user who is requesting to join the tenant * @param sendEmail - The function to send the email */ + // TODO: Use fn from implementation from base tenants const adminUsers = await implementation.getUserIdsInTenantWithRole(tenantId, ROLES.ADMIN); // For each of the users, we will need to find their email address. @@ -164,12 +171,14 @@ export const getOverrideableTenantFunctionImplementation = ( getUserIdsInTenantWithRole: async (tenantId, role) => { throw new Error("Not implemented"); }, - isEmailOrPhonePresentInTenant: async (tenantId, details) => { - const accountInfoResponse = await listUsersByAccountInfo(tenantId, { - email: "email" in details ? details.email : undefined, - }); + isUserSigningUpToTenant: async (tenantId, details) => { + /** + * List the users by account info and filter using the passed + * tenantId and email. + */ + const accountInfoResponse = await listUsersByAccountInfo(tenantId, details); return accountInfoResponse.length === 0; - } + }, }; return implementation; diff --git a/packages/tenant-enrollment-nodejs/src/types.ts b/packages/tenant-enrollment-nodejs/src/types.ts index 4edef7c..e398579 100644 --- a/packages/tenant-enrollment-nodejs/src/types.ts +++ b/packages/tenant-enrollment-nodejs/src/types.ts @@ -18,7 +18,7 @@ export type SuperTokensPluginTenantEnrollmentPluginNormalisedConfig = { requiresApprovalTenants: string[]; }; -export type EmailOrThirdPartyId = +export type UserIdentificationDetail = | { type: "email"; email: string; @@ -26,12 +26,16 @@ export type EmailOrThirdPartyId = | { type: "thirdParty"; thirdPartyId: string; - }; + } + | { + type: "phoneNumber"; + phoneNumber: string; + }; export type OverrideableTenantFunctionImplementation = { canUserJoinTenant: ( tenantId: string, - emailOrThirdPartyId: EmailOrThirdPartyId, + emailOrThirdPartyId: UserIdentificationDetail, ) => Promise<{ canJoin: boolean; reason?: string; @@ -59,5 +63,5 @@ export type OverrideableTenantFunctionImplementation = { userContext: UserContext, ) => Promise; getUserIdsInTenantWithRole: GetUserIdsInTenantWithRole; - isEmailOrPhonePresentInTenant: (tenantId: string, details: {email?: string, phoneNumber?: string}) => Promise; -} + isUserSigningUpToTenant: (tenantId: string, details: { email?: string; phoneNumber?: string }) => Promise; +}; diff --git a/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx b/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx deleted file mode 100644 index bc9d55d..0000000 --- a/packages/tenant-enrollment-react/src/pages/awaiting-approval/awaiting-approval.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { AwaitingApprovalMessage } from "@supertokens-plugins/tenants-react"; - -import { usePluginContext } from "../../plugin"; - -export const AwaitingAdminApproval = () => { - const { t } = usePluginContext(); - - return ( - - {t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE")}{" "} - {t("PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE_HIGHLIGHT")} -
- } - /> - ); -}; diff --git a/packages/tenant-enrollment-react/src/pages/awaiting-approval/index.ts b/packages/tenant-enrollment-react/src/pages/awaiting-approval/index.ts deleted file mode 100644 index ef943b8..0000000 --- a/packages/tenant-enrollment-react/src/pages/awaiting-approval/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AwaitingApproval } from "./awaiting-approval"; diff --git a/packages/tenant-enrollment-react/src/plugin.tsx b/packages/tenant-enrollment-react/src/plugin.tsx index c786b7a..333c1e8 100644 --- a/packages/tenant-enrollment-react/src/plugin.tsx +++ b/packages/tenant-enrollment-react/src/plugin.tsx @@ -1,6 +1,5 @@ import { createPluginInitFunction } from "@shared/js"; import { buildContext, getQuerier } from "@shared/react"; -import { useState } from "react"; import { SuperTokensPlugin, SuperTokensPublicConfig, @@ -8,15 +7,17 @@ import { getTranslationFunction, } from "supertokens-auth-react"; -import { NOT_ALLOWED_TO_SIGNUP_REASONS } from "../../../shared/tenants/src"; - import { getApi } from "./api"; import { PLUGIN_ID, API_PATH } from "./constants"; -import { enableDebugLogs, logDebugMessage } from "./logger"; -import { AwaitingApproval } from "./pages/awaiting-approval"; +import { enableDebugLogs } from "./logger"; import { SignUpBlocked } from "./pages/blocked"; +import { getOverrideableTenantFunctionImplementation } from "./pluginImplementation"; import { defaultTranslationsTenantEnrollment } from "./translations"; -import { SuperTokensPluginTenantEnrollmentPluginConfig, TranslationKeys } from "./types"; +import { + OverrideableTenantFunctionImplementation, + SuperTokensPluginTenantEnrollmentPluginConfig, + TranslationKeys, +} from "./types"; const { usePluginContext, setContext } = buildContext<{ plugins: SuperTokensPublicPlugin[]; @@ -26,18 +27,17 @@ const { usePluginContext, setContext } = buildContext<{ querier: ReturnType; api: ReturnType; t: (key: TranslationKeys) => string; - functions: null; + functions: OverrideableTenantFunctionImplementation; }>(); export { usePluginContext }; export const init = createPluginInitFunction< SuperTokensPlugin, SuperTokensPluginTenantEnrollmentPluginConfig, - {}, - // NOTE: Update the following type if we update the type to accept any values + OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig >( - (pluginConfig) => { + (pluginConfig, implementation) => { return { id: PLUGIN_ID, init: (config, plugins, sdkVersion) => { @@ -60,17 +60,13 @@ export const init = createPluginInitFunction< querier, api, t: translations, - functions: null, + functions: implementation, }); }, routeHandlers: (appConfig: any, plugins: any, sdkVersion: any) => { return { status: "OK", routeHandlers: [ - { - path: "/awaiting-approval", - handler: () => AwaitingApproval.call(null), - }, { path: "/signup-blocked", handler: () => SignUpBlocked.call(null), @@ -84,65 +80,29 @@ export const init = createPluginInitFunction< ...originalImplementation, signUp: async (input) => { let signUpResponse; - - try { + implementation.withSignUpBlockedRedirect(async () => { signUpResponse = await originalImplementation.signUp(input); - } catch (error: any) { - // Check if the error is a STGeneralError - logDebugMessage(`Caught error: ${error}`); - if (error.isSuperTokensGeneralError === true) { - logDebugMessage(`Got general error with reason: ${error.message}`); - - // Check if the message is one of the not allowed defined errors. - if (Object.values(NOT_ALLOWED_TO_SIGNUP_REASONS).includes(error.message)) { - logDebugMessage("Found not-allowed to signup flow, redirecting"); - - // Update the message before re-throwing the error - error.message = "Not allowed to signup to tenant"; - - // Redirect the user to not allowed to signup view - window.location.assign("/signup-blocked"); - } - } - - throw error; - } + }); - logDebugMessage(`response: ${signUpResponse}`); - - if ((signUpResponse.status as any) !== "PENDING_APPROVAL") { - return signUpResponse; - } - - // If it was okay, check if they were added to tenant or not. - const { wasAddedToTenant, reason } = signUpResponse as any; - if (wasAddedToTenant === true) { - // We don't have to do anything - return signUpResponse; - } - - // Since the tenant was not added, if we got a reason, we will have - // to parse it. - if (reason === undefined) { - return signUpResponse; - } - - // Since reason is defined, parse it and handle accordingly. - if (reason === "REQUIRES_APPROVAL") { - if (typeof window !== "undefined") { - window.location.assign("/awaiting-approval"); - } - } - - // NOTE: Currently we don't have any possibility of reason being any other - // value. If that changes, we can update in the future. return signUpResponse; }, }), }, + webauthn: { + functions: (originalImplementation) => ({ + ...originalImplementation, + getRegisterOptions: async (input) => { + let response; + implementation.withSignUpBlockedRedirect(async () => { + response = await originalImplementation.getRegisterOptions(input); + }); + return response; + }, + }), + }, }, }; }, - {}, + getOverrideableTenantFunctionImplementation, (pluginConfig) => pluginConfig, ); diff --git a/packages/tenant-enrollment-react/src/pluginImplementation.ts b/packages/tenant-enrollment-react/src/pluginImplementation.ts new file mode 100644 index 0000000..d19a3f6 --- /dev/null +++ b/packages/tenant-enrollment-react/src/pluginImplementation.ts @@ -0,0 +1,37 @@ +import { NOT_ALLOWED_TO_SIGNUP_REASONS } from "../../../shared/tenants/src/errors"; + +import { logDebugMessage } from "./logger"; +import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from "./types"; + +export const getOverrideableTenantFunctionImplementation = ( + config: SuperTokensPluginTenantEnrollmentPluginConfig, +): OverrideableTenantFunctionImplementation => { + const implementation: OverrideableTenantFunctionImplementation = { + withSignUpBlockedRedirect: async (callback) => { + try { + return await callback(); + } catch (error: any) { + // Check if the error is a STGeneralError + logDebugMessage(`Caught error: ${error}`); + if (error.isSuperTokensGeneralError === true) { + logDebugMessage(`Got general error with reason: ${error.message}`); + + // Check if the message is one of the not allowed defined errors. + if (Object.values(NOT_ALLOWED_TO_SIGNUP_REASONS).includes(error.message)) { + logDebugMessage("Found not-allowed to signup flow, redirecting"); + + // Update the message before re-throwing the error + error.message = "Not allowed to signup to tenant"; + + // Redirect the user to not allowed to signup view + window.location.assign("/signup-blocked"); + } + } + + throw error; + } + }, + }; + + return implementation; +}; diff --git a/packages/tenant-enrollment-react/src/types.ts b/packages/tenant-enrollment-react/src/types.ts index c976b18..172fa51 100644 --- a/packages/tenant-enrollment-react/src/types.ts +++ b/packages/tenant-enrollment-react/src/types.ts @@ -2,4 +2,8 @@ import { defaultTranslationsTenantEnrollment } from "./translations"; export type SuperTokensPluginTenantEnrollmentPluginConfig = {}; -export type TranslationKeys = keyof (typeof defaultTranslationsTenantEnrollment)["en"]; \ No newline at end of file +export type OverrideableTenantFunctionImplementation = { + withSignUpBlockedRedirect: (callback: () => Promise) => Promise; +}; + +export type TranslationKeys = keyof (typeof defaultTranslationsTenantEnrollment)["en"]; From 76cd9e4c2345cf00cafe91a287f9398ae51e8968 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Thu, 16 Oct 2025 14:52:29 +0530 Subject: [PATCH 26/36] fix: add fixes and support for redirecting for passwordless --- .../tenant-enrollment-nodejs/src/plugin.ts | 145 +++++++++++------- .../src/pluginImplementation.ts | 11 +- .../tenant-enrollment-nodejs/src/types.ts | 8 +- .../src/pages/blocked/signup-blocked.tsx | 6 +- .../tenant-enrollment-react/src/plugin.tsx | 21 +++ .../src/translations.ts | 4 - packages/tenants-nodejs/src/index.ts | 1 + packages/tenants-nodejs/src/plugin.ts | 17 +- packages/tenants-nodejs/src/types.ts | 4 +- 9 files changed, 134 insertions(+), 83 deletions(-) diff --git a/packages/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts index 73a3363..be1f390 100644 --- a/packages/tenant-enrollment-nodejs/src/plugin.ts +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -10,6 +10,7 @@ import { getOverrideableTenantFunctionImplementation } from "./pluginImplementat import { logDebugMessage } from "supertokens-node/lib/build/logger"; import { AssociateAllLoginMethodsOfUserWithTenant, + AssignRoleToUserInTenant, PLUGIN_ID as TENANTS_PLUGIN_ID, SendPluginEmail, GetAppUrl, @@ -26,6 +27,7 @@ export const init = createPluginInitFunction< >( (pluginConfig, implementation) => { let associateLoginMethodDef: AssociateAllLoginMethodsOfUserWithTenant; + let assignRoleToUserInTenantDef: AssignRoleToUserInTenant; let sendEmail: SendPluginEmail; let appInfo: NormalisedAppinfo; let getAppUrlDef: GetAppUrl; @@ -52,6 +54,11 @@ export const init = createPluginInitFunction< throw new Error("Tenants plugin does not export associateAllLoginMethodsOfUserWithTenant, cannot continue."); } + const assignRoleToUserInTenant = tenantsPlugin.exports?.assignRoleToUserInTenant; + if (!assignRoleToUserInTenant) { + throw new Error("Tenants plugin does not export assignRoleToUserInTenant, cannot continue."); + } + const sendPluginEmail = tenantsPlugin.exports?.sendEmail; if (!sendPluginEmail) { throw new Error("Tenants plugin does not export sendEmail, cannot continue."); @@ -63,6 +70,7 @@ export const init = createPluginInitFunction< } associateLoginMethodDef = associateAllLoginMethodsOfUserWithTenant; + assignRoleToUserInTenantDef = assignRoleToUserInTenant; sendEmail = sendPluginEmail; implementation.getUserIdsInTenantWithRole = getUserIdsInTenantWithRole; @@ -135,6 +143,7 @@ export const init = createPluginInitFunction< sendEmail, getAppUrlDef(appInfo, undefined, input.userContext), input.userContext, + assignRoleToUserInTenantDef, ); logDebugMessage(`wasAdded: ${wasAddedToTenant}`); logDebugMessage(`reason: ${tenantJoiningReason}`); @@ -210,6 +219,7 @@ export const init = createPluginInitFunction< sendEmail, getAppUrlDef(appInfo, undefined, input.userContext), input.userContext, + assignRoleToUserInTenantDef, ); return { ...response, @@ -236,15 +246,18 @@ export const init = createPluginInitFunction< return originalImplementation.createCodePOST!(input); } - const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, ( - "email" in input ? { - type: "email", - email: input.email, - } : { - type: "phoneNumber", - phoneNumber: input.phoneNumber, - } - )); + const { canJoin, reason } = await implementation.canUserJoinTenant( + input.tenantId, + "email" in input + ? { + type: "email", + email: input.email, + } + : { + type: "phoneNumber", + phoneNumber: input.phoneNumber, + }, + ); logDebugMessage("Reason: " + reason); if (!canJoin) { @@ -299,15 +312,18 @@ export const init = createPluginInitFunction< return originalImplementation.createCode!(input); } - const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, ( - "email" in input ? { - type: "email", - email: input.email, - } : { - type: "phoneNumber", - phoneNumber: input.phoneNumber, - } - )); + const { canJoin, reason } = await implementation.canUserJoinTenant( + input.tenantId, + "email" in input + ? { + type: "email", + email: input.email, + } + : { + type: "phoneNumber", + phoneNumber: input.phoneNumber, + }, + ); logDebugMessage("Reason: " + reason); if (!canJoin) { @@ -338,13 +354,16 @@ export const init = createPluginInitFunction< }; } - const isSignUp = await implementation.isUserSigningUpToTenant(input.tenantId, deviceInfo.phoneNumber !== undefined - ? { - phoneNumber: deviceInfo.phoneNumber!, - } - : { - email: deviceInfo.email!, - },); + const isSignUp = await implementation.isUserSigningUpToTenant( + input.tenantId, + deviceInfo.phoneNumber !== undefined + ? { + phoneNumber: deviceInfo.phoneNumber!, + } + : { + email: deviceInfo.email!, + }, + ); // If this is a signup or its through phone number, we cannot // restrict it so we will let it go through. @@ -354,15 +373,18 @@ export const init = createPluginInitFunction< // Since this is a signup, we need to check if the user // can signup to the tenant. - const { canJoin, reason } = await implementation.canUserJoinTenant(input.tenantId, ( - "email" in deviceInfo ? { - type: "email", - email: deviceInfo.email!, - } : { - type: "phoneNumber", - phoneNumber: deviceInfo.phoneNumber!, - } - )); + const { canJoin, reason } = await implementation.canUserJoinTenant( + input.tenantId, + "email" in deviceInfo + ? { + type: "email", + email: deviceInfo.email!, + } + : { + type: "phoneNumber", + phoneNumber: deviceInfo.phoneNumber!, + }, + ); logDebugMessage("Reason: " + reason); if (!canJoin) { @@ -372,7 +394,30 @@ export const init = createPluginInitFunction< } as any; } - return originalImplementation.consumeCode(input); + const response = await originalImplementation.consumeCode(input); + + if (response.status !== "OK") { + return response; + } + + logDebugMessage("Going ahead with checking tenant joining approval"); + const { wasAddedToTenant, reason: tenantJoiningReason } = + await implementation.handleTenantJoiningApproval( + response.user, + input.tenantId, + associateLoginMethodDef, + sendEmail, + getAppUrlDef(appInfo, undefined, input.userContext), + input.userContext, + assignRoleToUserInTenantDef, + ); + logDebugMessage(`wasAdded: ${wasAddedToTenant}`); + logDebugMessage(`reason: ${tenantJoiningReason}`); + return { + status: "PENDING_APPROVAL" as any, + wasAddedToTenant, + reason: tenantJoiningReason, + }; }, }; }, @@ -386,7 +431,9 @@ export const init = createPluginInitFunction< // User's email is provided so we can check // if they are trying to signup in which case // we will block this accordingly. - const isSignUp = await implementation.isUserSigningUpToTenant(input.tenantId, input.email); + const isSignUp = await implementation.isUserSigningUpToTenant(input.tenantId, { + email: input.email, + }); if (!isSignUp) { // If the user is not signing up, we can continue the original // implementation @@ -394,28 +441,9 @@ export const init = createPluginInitFunction< } userEmail = input.email; - } else if ("recoverAccountToken" in input) { - // User is trying to register credential through recoverAccountToken - // where there is a possibility that the user doesn't exist. - const result = await originalImplementation.getUserFromRecoverAccountToken({ - token: input.recoverAccountToken, - tenantId: input.tenantId, - userContext: input.userContext, - }); - - if (result.status !== "OK") { - return result; - } - - // If the recipeId is undefined, that means the user is signing up. - if (result.recipeUserId !== undefined) { - // Since the user is not signing up, we will continue with the original - // flow here. - return originalImplementation.registerOptions(input); - } - - // userEmail = result.user. - // TODO: Change after confirmation from Victor/Mihaly + } else { + // For the recovery case, continue with normal flow + return originalImplementation.registerOptions(input); } if (userEmail === undefined) { @@ -460,6 +488,7 @@ export const init = createPluginInitFunction< sendEmail, getAppUrlDef(appInfo, undefined, input.userContext), input.userContext, + assignRoleToUserInTenantDef, ); logDebugMessage(`wasAdded: ${wasAddedToTenant}`); logDebugMessage(`reason: ${tenantJoiningReason}`); diff --git a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts index 7350055..ec63a65 100644 --- a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts +++ b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts @@ -2,8 +2,8 @@ import { User, listUsersByAccountInfo } from "supertokens-node"; import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from "./types"; import { - assignRoleToUserInTenant, AssociateAllLoginMethodsOfUserWithTenant, + AssignRoleToUserInTenant, SendPluginEmail, } from "@supertokens-plugins/tenants-nodejs"; import { NOT_ALLOWED_TO_SIGNUP_REASONS, ROLES } from "@shared/tenants"; @@ -56,7 +56,7 @@ export const getOverrideableTenantFunctionImplementation = ( // We don't really have a way to check anything for phones so we can // allow signup. return { - canJoin: true + canJoin: true, }; } @@ -72,6 +72,7 @@ export const getOverrideableTenantFunctionImplementation = ( sendEmail: SendPluginEmail, appUrl: string, userContext: UserContext, + assignRoleToUserInTenant: AssignRoleToUserInTenant, ) => { /** * Handle the tenant joining functionality for the user. @@ -96,8 +97,7 @@ export const getOverrideableTenantFunctionImplementation = ( // If the tenant doesn't require approval, add the user as a member // and return. if (!implementation.doesTenantRequireApproval(tenantId)) { - // TODO: Use fn from implementation of base-tenants - await assignRoleToUserInTenant(tenantId, user.id, ROLES.MEMBER); + await assignRoleToUserInTenant(tenantId, user.id, ROLES.TENANT_MEMBER); return { wasAddedToTenant: true, }; @@ -138,8 +138,7 @@ export const getOverrideableTenantFunctionImplementation = ( * @param user - The user who is requesting to join the tenant * @param sendEmail - The function to send the email */ - // TODO: Use fn from implementation from base tenants - const adminUsers = await implementation.getUserIdsInTenantWithRole(tenantId, ROLES.ADMIN); + const adminUsers = await implementation.getUserIdsInTenantWithRole(tenantId, ROLES.TENANT_ADMIN); // For each of the users, we will need to find their email address. const adminEmails = await Promise.all( diff --git a/packages/tenant-enrollment-nodejs/src/types.ts b/packages/tenant-enrollment-nodejs/src/types.ts index e398579..ae81117 100644 --- a/packages/tenant-enrollment-nodejs/src/types.ts +++ b/packages/tenant-enrollment-nodejs/src/types.ts @@ -3,6 +3,7 @@ import { AssociateAllLoginMethodsOfUserWithTenant, GetUserIdsInTenantWithRole, SendPluginEmail, + AssignRoleToUserInTenant, } from "@supertokens-plugins/tenants-nodejs"; import { UserContext } from "supertokens-node/lib/build/types"; @@ -28,9 +29,9 @@ export type UserIdentificationDetail = thirdPartyId: string; } | { - type: "phoneNumber"; - phoneNumber: string; - }; + type: "phoneNumber"; + phoneNumber: string; + }; export type OverrideableTenantFunctionImplementation = { canUserJoinTenant: ( @@ -47,6 +48,7 @@ export type OverrideableTenantFunctionImplementation = { sendEmail: SendPluginEmail, appUrl: string, userContext: UserContext, + assignRoleToUserInTenant: AssignRoleToUserInTenant, ) => Promise<{ wasAddedToTenant: boolean; reason?: string; diff --git a/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx b/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx index 9576191..ebd1a57 100644 --- a/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx +++ b/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx @@ -7,10 +7,12 @@ export const SignUpBlocked = () => { return ( - {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT")} {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_SUFFIX")} + {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT")} + {" "} + {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_SUFFIX")}
} useDangerAccent diff --git a/packages/tenant-enrollment-react/src/plugin.tsx b/packages/tenant-enrollment-react/src/plugin.tsx index 333c1e8..c39c618 100644 --- a/packages/tenant-enrollment-react/src/plugin.tsx +++ b/packages/tenant-enrollment-react/src/plugin.tsx @@ -100,6 +100,27 @@ export const init = createPluginInitFunction< }, }), }, + passwordless: { + functions: (originalImplementation) => ({ + ...originalImplementation, + createCode: async (input) => { + let createCodeResponse; + implementation.withSignUpBlockedRedirect(async () => { + createCodeResponse = await originalImplementation.createCode(input); + }); + + return createCodeResponse; + }, + consumeCode: async (input) => { + let consumeCodeResponse; + implementation.withSignUpBlockedRedirect(async () => { + consumeCodeResponse = await originalImplementation.consumeCode(input); + }); + + return consumeCodeResponse; + }, + }), + }, }, }; }, diff --git a/packages/tenant-enrollment-react/src/translations.ts b/packages/tenant-enrollment-react/src/translations.ts index dd32bd1..cbb5950 100644 --- a/packages/tenant-enrollment-react/src/translations.ts +++ b/packages/tenant-enrollment-react/src/translations.ts @@ -1,9 +1,5 @@ export const defaultTranslationsTenantEnrollment = { en: { - PL_TE_JOIN_TENANT_AWAITING_APPROVAL_HEADER: "Awaiting tenant admin approval", - PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE: - "It is essential to obtain the tenant administrator's approval before proceeding with the", - PL_TE_JOIN_TENANT_AWAITING_APPROVAL_MESSAGE_HIGHLIGHT: "tenant joining process", PL_TE_SIGN_UP_BLOCKED_HEADER: "Signing up to the tenant is disabled", PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT: "Signing up to this tenant is currently blocked.", PL_TE_SIGN_UP_BLOCKED_MESSAGE_SUFFIX: diff --git a/packages/tenants-nodejs/src/index.ts b/packages/tenants-nodejs/src/index.ts index 7b2dcf1..c7e9e56 100644 --- a/packages/tenants-nodejs/src/index.ts +++ b/packages/tenants-nodejs/src/index.ts @@ -11,6 +11,7 @@ export type { SendPluginEmail, GetUserIdsInTenantWithRole, GetAppUrl, + AssignRoleToUserInTenant, } from "./types"; // Export email services for user configuration diff --git a/packages/tenants-nodejs/src/plugin.ts b/packages/tenants-nodejs/src/plugin.ts index e8ae81c..8022b09 100644 --- a/packages/tenants-nodejs/src/plugin.ts +++ b/packages/tenants-nodejs/src/plugin.ts @@ -940,9 +940,7 @@ export const init = createPluginInitFunction< // We will add a new validator to check that the user // can access the tenant. - const additionalValidators = [ - PermissionClaim.validators.includes(PERMISSIONS.TENANT_ACCESS), - ]; + const additionalValidators = [PermissionClaim.validators.includes(PERMISSIONS.TENANT_ACCESS)]; logDebugMessage("Adding tenant-access permission claim"); if (pluginConfig.requireNonPublicTenantAssociation) { @@ -996,12 +994,12 @@ export const init = createPluginInitFunction< ...input.accessTokenPayload, ...(pluginConfig.requireNonPublicTenantAssociation ? await MultipleTenantsPresentClaim.build( - input.userId, - input.recipeUserId, - tenantId, - input.accessTokenPayload, - input.userContext, - ) + input.userId, + input.recipeUserId, + tenantId, + input.accessTokenPayload, + input.userContext, + ) : {}), ...(await TenantAccessPresentClaim.build( input.userId, @@ -1127,6 +1125,7 @@ export const init = createPluginInitFunction< sendEmail: sendPluginEmail, getUserIdsInTenantWithRole, getAppUrl: implementation.getAppUrl, + assignRoleToUserInTenant: implementation.assignRoleToUserInTenant, }, }; }, diff --git a/packages/tenants-nodejs/src/types.ts b/packages/tenants-nodejs/src/types.ts index 2a74851..2cf1b25 100644 --- a/packages/tenants-nodejs/src/types.ts +++ b/packages/tenants-nodejs/src/types.ts @@ -80,6 +80,8 @@ export type AssociateAllLoginMethodsOfUserWithTenant = ( loginMethodFilter?: (loginMethod: LoginMethod) => boolean, ) => Promise; +export type AssignRoleToUserInTenant = (tenantId: string, userId: string, role: string) => Promise; + export type GetUserIdsInTenantWithRole = (tenantId: string, role: string) => Promise; export type GetAppUrl = ( @@ -178,7 +180,7 @@ export type OverrideableTenantFunctionImplementation = { rejectRequestToJoinTenant: (tenantId: string, userId: string) => Promise<{ status: "OK" } | ErrorResponse>; doesUserHaveTenantCreationRequest: (userId: string, metadata: TenantCreationRequestMetadataType) => Promise; getPreferredTenantId: (tenantIds: string[], inputTenantId: string) => string | undefined; - assignRoleToUserInTenant: (tenantId: string, userId: string, role: string) => Promise; + assignRoleToUserInTenant: AssignRoleToUserInTenant; shouldHaveTenantAccess: ( userId: string, tenantId: string, From c7fa87b58c1c7d39ea00820863869d1b4de1dedd Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 17 Oct 2025 10:15:41 +0530 Subject: [PATCH 27/36] feat: add support for hiding signinup switcher if tenant is invite only --- .../tenant-enrollment-nodejs/src/plugin.ts | 20 +++++++++ .../tenant-enrollment-react/src/plugin.tsx | 41 ++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts index be1f390..6e9c41d 100644 --- a/packages/tenant-enrollment-nodejs/src/plugin.ts +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -500,6 +500,26 @@ export const init = createPluginInitFunction< }, }), }, + multitenancy: { + apis: (originalImplementation) => ({ + ...originalImplementation, + loginMethodsGET: async (input) => { + const response = await originalImplementation.loginMethodsGET(input); + + if (response.status !== "OK") { + return response; + } + + const isTenantInviteOnly = implementation.isTenantInviteOnly(input.tenantId); + + // Inject the key into the response. + return { + ...response, + isTenantInviteOnly, + } as any; + }, + }), + }, }, }; }, diff --git a/packages/tenant-enrollment-react/src/plugin.tsx b/packages/tenant-enrollment-react/src/plugin.tsx index c39c618..7b35019 100644 --- a/packages/tenant-enrollment-react/src/plugin.tsx +++ b/packages/tenant-enrollment-react/src/plugin.tsx @@ -9,7 +9,7 @@ import { import { getApi } from "./api"; import { PLUGIN_ID, API_PATH } from "./constants"; -import { enableDebugLogs } from "./logger"; +import { enableDebugLogs, logDebugMessage } from "./logger"; import { SignUpBlocked } from "./pages/blocked"; import { getOverrideableTenantFunctionImplementation } from "./pluginImplementation"; import { defaultTranslationsTenantEnrollment } from "./translations"; @@ -38,6 +38,7 @@ export const init = createPluginInitFunction< SuperTokensPluginTenantEnrollmentPluginConfig >( (pluginConfig, implementation) => { + let isInviteOnly = false; return { id: PLUGIN_ID, init: (config, plugins, sdkVersion) => { @@ -121,6 +122,44 @@ export const init = createPluginInitFunction< }, }), }, + thirdparty: { + functions: (originalImplementation) => ({ + ...originalImplementation, + signInAndUp: async (input) => { + let signInAndUpResponse; + implementation.withSignUpBlockedRedirect(async () => { + signInAndUpResponse = await originalImplementation.signInAndUp(input); + }); + return signInAndUpResponse; + }, + }), + }, + multitenancy: { + functions: (originalImplementation) => ({ + ...originalImplementation, + getLoginMethods: async (input) => { + const response = await originalImplementation.getLoginMethods(input); + + const isTenantInviteOnly = await response.fetchResponse.json().then((data) => data.isTenantInviteOnly); + logDebugMessage(`Parsed isTenantInviteOnly to be ${isTenantInviteOnly} from response body`); + + if (isTenantInviteOnly !== undefined && typeof isTenantInviteOnly === "boolean") { + isInviteOnly = isTenantInviteOnly; + logDebugMessage("Update isInviteOnly value!"); + } + + return response; + }, + }), + }, + }, + generalAuthRecipeComponentOverrides: { + AuthPageHeader_Override: ({ DefaultComponent, ...props }) => { + logDebugMessage(`Got isInviteOnly value as ${isInviteOnly}`); + // If the tenant is invite only, disable the sign in switcher + // @ts-ignore + return ; + }, }, }; }, From c305746873675357eaac02e09c5892f74429babb Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 17 Oct 2025 11:47:38 +0530 Subject: [PATCH 28/36] feat: hide the signup blocked screen and show errors directly --- .../tenant-enrollment-nodejs/src/plugin.ts | 44 +++---- .../src/pluginImplementation.ts | 77 ++++++----- .../tenant-enrollment-nodejs/src/types.ts | 2 + .../src/pages/blocked/index.ts | 1 - .../src/pages/blocked/signup-blocked.tsx | 22 ---- .../tenant-enrollment-react/src/plugin.tsx | 122 ++++++++---------- .../src/pluginImplementation.ts | 30 +---- packages/tenant-enrollment-react/src/types.ts | 4 +- shared/tenants/src/errors.ts | 10 +- shared/tenants/src/types.ts | 6 + 10 files changed, 141 insertions(+), 177 deletions(-) delete mode 100644 packages/tenant-enrollment-react/src/pages/blocked/index.ts delete mode 100644 packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx diff --git a/packages/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts index 6e9c41d..6224615 100644 --- a/packages/tenant-enrollment-nodejs/src/plugin.ts +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -250,13 +250,13 @@ export const init = createPluginInitFunction< input.tenantId, "email" in input ? { - type: "email", - email: input.email, - } + type: "email", + email: input.email, + } : { - type: "phoneNumber", - phoneNumber: input.phoneNumber, - }, + type: "phoneNumber", + phoneNumber: input.phoneNumber, + }, ); logDebugMessage("Reason: " + reason); @@ -316,13 +316,13 @@ export const init = createPluginInitFunction< input.tenantId, "email" in input ? { - type: "email", - email: input.email, - } + type: "email", + email: input.email, + } : { - type: "phoneNumber", - phoneNumber: input.phoneNumber, - }, + type: "phoneNumber", + phoneNumber: input.phoneNumber, + }, ); logDebugMessage("Reason: " + reason); @@ -358,11 +358,11 @@ export const init = createPluginInitFunction< input.tenantId, deviceInfo.phoneNumber !== undefined ? { - phoneNumber: deviceInfo.phoneNumber!, - } + phoneNumber: deviceInfo.phoneNumber!, + } : { - email: deviceInfo.email!, - }, + email: deviceInfo.email!, + }, ); // If this is a signup or its through phone number, we cannot @@ -377,13 +377,13 @@ export const init = createPluginInitFunction< input.tenantId, "email" in deviceInfo ? { - type: "email", - email: deviceInfo.email!, - } + type: "email", + email: deviceInfo.email!, + } : { - type: "phoneNumber", - phoneNumber: deviceInfo.phoneNumber!, - }, + type: "phoneNumber", + phoneNumber: deviceInfo.phoneNumber!, + }, ); logDebugMessage("Reason: " + reason); diff --git a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts index ec63a65..75987dc 100644 --- a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts +++ b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts @@ -1,20 +1,24 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { User, listUsersByAccountInfo } from "supertokens-node"; -import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from "./types"; +import { User, listUsersByAccountInfo } from 'supertokens-node'; +import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from './types'; import { AssociateAllLoginMethodsOfUserWithTenant, AssignRoleToUserInTenant, SendPluginEmail, -} from "@supertokens-plugins/tenants-nodejs"; -import { NOT_ALLOWED_TO_SIGNUP_REASONS, ROLES } from "@shared/tenants"; -import SuperTokens from "supertokens-node"; -import { UserContext } from "supertokens-node/lib/build/types"; +} from '@supertokens-plugins/tenants-nodejs'; +import { NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE, NotAllowedToSignUpReason, ROLES } from '@shared/tenants'; +import SuperTokens from 'supertokens-node'; +import { UserContext } from 'supertokens-node/lib/build/types'; export const getOverrideableTenantFunctionImplementation = ( config: SuperTokensPluginTenantEnrollmentPluginConfig, ): OverrideableTenantFunctionImplementation => { const implementation: OverrideableTenantFunctionImplementation = { - canUserJoinTenant: async (tenantId, userIdentificationDetail) => { + canUserJoinTenant: async function ( + this: OverrideableTenantFunctionImplementation, + tenantId, + userIdentificationDetail, + ) { /** * Check if the user can join the tenant based on the email domain * @@ -24,7 +28,7 @@ export const getOverrideableTenantFunctionImplementation = ( */ // Skip this for the public tenant - if (tenantId === "public") { + if (tenantId === 'public') { return { canJoin: true, reason: undefined, @@ -33,26 +37,26 @@ export const getOverrideableTenantFunctionImplementation = ( // Check if the tenant is invite only in which case we // can't allow the user to join - if (implementation.isTenantInviteOnly(tenantId)) { + if (this.isTenantInviteOnly(tenantId)) { return { canJoin: false, - reason: NOT_ALLOWED_TO_SIGNUP_REASONS.INVITE_ONLY, + reason: this.getMessageForNoSignUpReason(NotAllowedToSignUpReason.INVITE_ONLY), }; } let canJoin = false; let reason = undefined; - if (userIdentificationDetail.type === "email") { - canJoin = implementation.isMatchingEmailDomain(tenantId, userIdentificationDetail.email); + if (userIdentificationDetail.type === 'email') { + canJoin = this.isMatchingEmailDomain(tenantId, userIdentificationDetail.email); if (!canJoin) { - reason = NOT_ALLOWED_TO_SIGNUP_REASONS.EMAIL_DOMAIN_NOT_ALLOWED; + reason = this.getMessageForNoSignUpReason(NotAllowedToSignUpReason.EMAIL_DOMAIN_NOT_ALLOWED); } - } else if (userIdentificationDetail.type === "thirdParty") { - canJoin = implementation.isApprovedIdPProvider(tenantId, userIdentificationDetail.thirdPartyId); + } else if (userIdentificationDetail.type === 'thirdParty') { + canJoin = this.isApprovedIdPProvider(tenantId, userIdentificationDetail.thirdPartyId); if (!canJoin) { - reason = NOT_ALLOWED_TO_SIGNUP_REASONS.IDP_NOT_ALLOWED; + reason = this.getMessageForNoSignUpReason(NotAllowedToSignUpReason.IDP_NOT_ALLOWED); } - } else if (userIdentificationDetail.type === "phoneNumber") { + } else if (userIdentificationDetail.type === 'phoneNumber') { // We don't really have a way to check anything for phones so we can // allow signup. return { @@ -65,7 +69,8 @@ export const getOverrideableTenantFunctionImplementation = ( reason, }; }, - handleTenantJoiningApproval: async ( + handleTenantJoiningApproval: async function ( + this: OverrideableTenantFunctionImplementation, user: User, tenantId: string, associateLoginMethodDef: AssociateAllLoginMethodsOfUserWithTenant, @@ -73,7 +78,7 @@ export const getOverrideableTenantFunctionImplementation = ( appUrl: string, userContext: UserContext, assignRoleToUserInTenant: AssignRoleToUserInTenant, - ) => { + ) { /** * Handle the tenant joining functionality for the user. * @@ -87,7 +92,7 @@ export const getOverrideableTenantFunctionImplementation = ( * @param associateLoginMethodDef - The function to associate the login methods of the user with the tenant */ // Skip this for the public tenant - if (tenantId === "public") { + if (tenantId === 'public') { return { wasAddedToTenant: true, reason: undefined, @@ -96,7 +101,7 @@ export const getOverrideableTenantFunctionImplementation = ( // If the tenant doesn't require approval, add the user as a member // and return. - if (!implementation.doesTenantRequireApproval(tenantId)) { + if (!this.doesTenantRequireApproval(tenantId)) { await assignRoleToUserInTenant(tenantId, user.id, ROLES.TENANT_MEMBER); return { wasAddedToTenant: true, @@ -109,7 +114,7 @@ export const getOverrideableTenantFunctionImplementation = ( return { wasAddedToTenant: false, - reason: "REQUIRES_APPROVAL", + reason: 'REQUIRES_APPROVAL', }; }, isTenantInviteOnly: (tenantId) => { @@ -118,11 +123,11 @@ export const getOverrideableTenantFunctionImplementation = ( doesTenantRequireApproval: (tenantId) => { return config.requiresApprovalTenants?.includes(tenantId) ?? false; }, - isApprovedIdPProvider: (thirdPartyId) => { - return thirdPartyId.startsWith("boxy-saml"); + isApprovedIdPProvider: (tenantId, thirdPartyId) => { + return thirdPartyId.startsWith('boxy-saml'); }, isMatchingEmailDomain: (tenantId, email) => { - const emailDomain = email.split("@"); + const emailDomain = email.split('@'); if (emailDomain.length !== 2) { return false; } @@ -130,7 +135,14 @@ export const getOverrideableTenantFunctionImplementation = ( const parsedTenantId = config.emailDomainToTenantIdMap[emailDomain[1]!.toLowerCase()]; return parsedTenantId === tenantId; }, - sendTenantJoiningRequestEmail: async (tenantId, user, appUrl, sendEmail, userContext) => { + sendTenantJoiningRequestEmail: async function ( + this: OverrideableTenantFunctionImplementation, + tenantId, + user, + appUrl, + sendEmail, + userContext, + ) { /** * Send an email to all the admins of the tenant * @@ -138,7 +150,7 @@ export const getOverrideableTenantFunctionImplementation = ( * @param user - The user who is requesting to join the tenant * @param sendEmail - The function to send the email */ - const adminUsers = await implementation.getUserIdsInTenantWithRole(tenantId, ROLES.TENANT_ADMIN); + const adminUsers = await this.getUserIdsInTenantWithRole(tenantId, ROLES.TENANT_ADMIN); // For each of the users, we will need to find their email address. const adminEmails = await Promise.all( @@ -156,7 +168,7 @@ export const getOverrideableTenantFunctionImplementation = ( .map(async (email) => { await sendEmail( { - type: "TENANT_REQUEST_APPROVAL", + type: 'TENANT_REQUEST_APPROVAL', email, tenantId, senderEmail: user.emails[0]!, @@ -168,7 +180,7 @@ export const getOverrideableTenantFunctionImplementation = ( ); }, getUserIdsInTenantWithRole: async (tenantId, role) => { - throw new Error("Not implemented"); + throw new Error('Not implemented'); }, isUserSigningUpToTenant: async (tenantId, details) => { /** @@ -178,6 +190,13 @@ export const getOverrideableTenantFunctionImplementation = ( const accountInfoResponse = await listUsersByAccountInfo(tenantId, details); return accountInfoResponse.length === 0; }, + getMessageForNoSignUpReason: (reason) => { + /** + * Return a proper message for the passed reason for not + * allowing signup. + */ + return NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE[reason]; + }, }; return implementation; diff --git a/packages/tenant-enrollment-nodejs/src/types.ts b/packages/tenant-enrollment-nodejs/src/types.ts index ae81117..3df962b 100644 --- a/packages/tenant-enrollment-nodejs/src/types.ts +++ b/packages/tenant-enrollment-nodejs/src/types.ts @@ -6,6 +6,7 @@ import { AssignRoleToUserInTenant, } from "@supertokens-plugins/tenants-nodejs"; import { UserContext } from "supertokens-node/lib/build/types"; +import { NotAllowedToSignUpReason } from "../../../shared/tenants/src/types"; export type SuperTokensPluginTenantEnrollmentPluginConfig = { emailDomainToTenantIdMap: Record; @@ -66,4 +67,5 @@ export type OverrideableTenantFunctionImplementation = { ) => Promise; getUserIdsInTenantWithRole: GetUserIdsInTenantWithRole; isUserSigningUpToTenant: (tenantId: string, details: { email?: string; phoneNumber?: string }) => Promise; + getMessageForNoSignUpReason: (reason: NotAllowedToSignUpReason) => string; }; diff --git a/packages/tenant-enrollment-react/src/pages/blocked/index.ts b/packages/tenant-enrollment-react/src/pages/blocked/index.ts deleted file mode 100644 index 086f330..0000000 --- a/packages/tenant-enrollment-react/src/pages/blocked/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SignUpBlocked } from "./signup-blocked"; diff --git a/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx b/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx deleted file mode 100644 index ebd1a57..0000000 --- a/packages/tenant-enrollment-react/src/pages/blocked/signup-blocked.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { AwaitingApprovalMessage } from "@supertokens-plugins/tenants-react"; - -import { usePluginContext } from "../../plugin"; - -export const SignUpBlocked = () => { - const { t } = usePluginContext(); - - return ( - - {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT")} - {" "} - {t("PL_TE_SIGN_UP_BLOCKED_MESSAGE_SUFFIX")} -
- } - useDangerAccent - hideLogoutButton - /> - ); -}; diff --git a/packages/tenant-enrollment-react/src/plugin.tsx b/packages/tenant-enrollment-react/src/plugin.tsx index 7b35019..ef89515 100644 --- a/packages/tenant-enrollment-react/src/plugin.tsx +++ b/packages/tenant-enrollment-react/src/plugin.tsx @@ -10,7 +10,6 @@ import { import { getApi } from "./api"; import { PLUGIN_ID, API_PATH } from "./constants"; import { enableDebugLogs, logDebugMessage } from "./logger"; -import { SignUpBlocked } from "./pages/blocked"; import { getOverrideableTenantFunctionImplementation } from "./pluginImplementation"; import { defaultTranslationsTenantEnrollment } from "./translations"; import { @@ -64,76 +63,65 @@ export const init = createPluginInitFunction< functions: implementation, }); }, - routeHandlers: (appConfig: any, plugins: any, sdkVersion: any) => { - return { - status: "OK", - routeHandlers: [ - { - path: "/signup-blocked", - handler: () => SignUpBlocked.call(null), - }, - ], - }; - }, overrideMap: { - emailpassword: { - functions: (originalImplementation) => ({ - ...originalImplementation, - signUp: async (input) => { - let signUpResponse; - implementation.withSignUpBlockedRedirect(async () => { - signUpResponse = await originalImplementation.signUp(input); - }); + // emailpassword: { + // functions: (originalImplementation) => ({ + // ...originalImplementation, + // signUp: async (input) => { + // let signUpResponse; + // implementation.withSignUpBlockedRedirect(async () => { + // signUpResponse = await originalImplementation.signUp(input); + // }); - return signUpResponse; - }, - }), - }, - webauthn: { - functions: (originalImplementation) => ({ - ...originalImplementation, - getRegisterOptions: async (input) => { - let response; - implementation.withSignUpBlockedRedirect(async () => { - response = await originalImplementation.getRegisterOptions(input); - }); - return response; - }, - }), - }, - passwordless: { - functions: (originalImplementation) => ({ - ...originalImplementation, - createCode: async (input) => { - let createCodeResponse; - implementation.withSignUpBlockedRedirect(async () => { - createCodeResponse = await originalImplementation.createCode(input); - }); + // return signUpResponse; + // }, + // }), + // }, + // webauthn: { + // functions: (originalImplementation) => ({ + // ...originalImplementation, + // getRegisterOptions: async (input) => { + // let response; + // implementation.withSignUpBlockedRedirect(async () => { + // response = await originalImplementation.getRegisterOptions(input); + // }); + // return response; + // }, + // }), + // }, + // passwordless: { + // functions: (originalImplementation) => ({ + // ...originalImplementation, + // createCode: async (input) => { + // let createCodeResponse; + // implementation.withSignUpBlockedRedirect(async () => { + // createCodeResponse = await originalImplementation.createCode(input); + // }); - return createCodeResponse; - }, - consumeCode: async (input) => { - let consumeCodeResponse; - implementation.withSignUpBlockedRedirect(async () => { - consumeCodeResponse = await originalImplementation.consumeCode(input); - }); + // return createCodeResponse; + // }, + // consumeCode: async (input) => { + // let consumeCodeResponse; + // implementation.withSignUpBlockedRedirect(async () => { + // consumeCodeResponse = await originalImplementation.consumeCode(input); + // }); - return consumeCodeResponse; - }, - }), - }, - thirdparty: { - functions: (originalImplementation) => ({ - ...originalImplementation, - signInAndUp: async (input) => { - let signInAndUpResponse; - implementation.withSignUpBlockedRedirect(async () => { - signInAndUpResponse = await originalImplementation.signInAndUp(input); - }); - return signInAndUpResponse; - }, - }), - }, + // return consumeCodeResponse; + // }, + // }), + // }, + // thirdparty: { + // functions: (originalImplementation) => ({ + // ...originalImplementation, + // signInAndUp: async (input) => { + // let signInAndUpResponse; + // implementation.withSignUpBlockedRedirect(async () => { + // signInAndUpResponse = await originalImplementation.signInAndUp(input); + // }); + // return signInAndUpResponse; + // }, + // }), + // }, multitenancy: { functions: (originalImplementation) => ({ ...originalImplementation, diff --git a/packages/tenant-enrollment-react/src/pluginImplementation.ts b/packages/tenant-enrollment-react/src/pluginImplementation.ts index d19a3f6..14b108d 100644 --- a/packages/tenant-enrollment-react/src/pluginImplementation.ts +++ b/packages/tenant-enrollment-react/src/pluginImplementation.ts @@ -1,37 +1,9 @@ -import { NOT_ALLOWED_TO_SIGNUP_REASONS } from "../../../shared/tenants/src/errors"; - -import { logDebugMessage } from "./logger"; import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from "./types"; export const getOverrideableTenantFunctionImplementation = ( config: SuperTokensPluginTenantEnrollmentPluginConfig, ): OverrideableTenantFunctionImplementation => { - const implementation: OverrideableTenantFunctionImplementation = { - withSignUpBlockedRedirect: async (callback) => { - try { - return await callback(); - } catch (error: any) { - // Check if the error is a STGeneralError - logDebugMessage(`Caught error: ${error}`); - if (error.isSuperTokensGeneralError === true) { - logDebugMessage(`Got general error with reason: ${error.message}`); - - // Check if the message is one of the not allowed defined errors. - if (Object.values(NOT_ALLOWED_TO_SIGNUP_REASONS).includes(error.message)) { - logDebugMessage("Found not-allowed to signup flow, redirecting"); - - // Update the message before re-throwing the error - error.message = "Not allowed to signup to tenant"; - - // Redirect the user to not allowed to signup view - window.location.assign("/signup-blocked"); - } - } - - throw error; - } - }, - }; + const implementation: OverrideableTenantFunctionImplementation = {}; return implementation; }; diff --git a/packages/tenant-enrollment-react/src/types.ts b/packages/tenant-enrollment-react/src/types.ts index 172fa51..f3c4afa 100644 --- a/packages/tenant-enrollment-react/src/types.ts +++ b/packages/tenant-enrollment-react/src/types.ts @@ -2,8 +2,6 @@ import { defaultTranslationsTenantEnrollment } from "./translations"; export type SuperTokensPluginTenantEnrollmentPluginConfig = {}; -export type OverrideableTenantFunctionImplementation = { - withSignUpBlockedRedirect: (callback: () => Promise) => Promise; -}; +export type OverrideableTenantFunctionImplementation = {}; export type TranslationKeys = keyof (typeof defaultTranslationsTenantEnrollment)["en"]; diff --git a/shared/tenants/src/errors.ts b/shared/tenants/src/errors.ts index 695dac3..3cf5f58 100644 --- a/shared/tenants/src/errors.ts +++ b/shared/tenants/src/errors.ts @@ -1,5 +1,7 @@ -export const NOT_ALLOWED_TO_SIGNUP_REASONS = { - INVITE_ONLY: "INVITE_ONLY", - EMAIL_DOMAIN_NOT_ALLOWED: "EMAIL_DOMAIN_NOT_ALLOWED", - IDP_NOT_ALLOWED: "IDP_NOT_ALLOWED", +import { NotAllowedToSignUpReason } from './types'; + +export const NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE: Record = { + [NotAllowedToSignUpReason.INVITE_ONLY]: 'Tenant is invite only, cannot signup', + [NotAllowedToSignUpReason.EMAIL_DOMAIN_NOT_ALLOWED]: 'Email domain not allowed to signup to tenant', + [NotAllowedToSignUpReason.IDP_NOT_ALLOWED]: 'Identity Provider not allowed to signup to tenant', }; diff --git a/shared/tenants/src/types.ts b/shared/tenants/src/types.ts index 2720152..13517b8 100644 --- a/shared/tenants/src/types.ts +++ b/shared/tenants/src/types.ts @@ -46,3 +46,9 @@ export type TenantCreationRequestMetadata = { export type FilterGlobalClaimValidators = ( globalValidators: SessionClaimValidator[], ) => SessionClaimValidator[] | Promise; + +export enum NotAllowedToSignUpReason { + INVITE_ONLY, + EMAIL_DOMAIN_NOT_ALLOWED, + IDP_NOT_ALLOWED, +} From 389e1e550efd878c05f06e7995fd6d050add779f Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 17 Oct 2025 13:25:18 +0530 Subject: [PATCH 29/36] feat: add support for showing proper error message in webauthn --- .../src/pluginImplementation.ts | 64 +++++-------- packages/tenant-enrollment-react/package.json | 12 ++- .../src/components/error/error-message.tsx | 15 +++ .../src/components/error/error.module.scss | 5 + .../src/components/index.ts | 1 + .../tenant-enrollment-react/src/plugin.tsx | 96 ++++++++----------- .../tenant-enrollment-react/vite.config.ts | 24 ++--- 7 files changed, 106 insertions(+), 111 deletions(-) create mode 100644 packages/tenant-enrollment-react/src/components/error/error-message.tsx create mode 100644 packages/tenant-enrollment-react/src/components/error/error.module.scss create mode 100644 packages/tenant-enrollment-react/src/components/index.ts diff --git a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts index 75987dc..6df2706 100644 --- a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts +++ b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts @@ -1,24 +1,20 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { User, listUsersByAccountInfo } from 'supertokens-node'; -import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from './types'; +import { User, listUsersByAccountInfo } from "supertokens-node"; +import { OverrideableTenantFunctionImplementation, SuperTokensPluginTenantEnrollmentPluginConfig } from "./types"; import { AssociateAllLoginMethodsOfUserWithTenant, AssignRoleToUserInTenant, SendPluginEmail, -} from '@supertokens-plugins/tenants-nodejs'; -import { NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE, NotAllowedToSignUpReason, ROLES } from '@shared/tenants'; -import SuperTokens from 'supertokens-node'; -import { UserContext } from 'supertokens-node/lib/build/types'; +} from "@supertokens-plugins/tenants-nodejs"; +import { NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE, NotAllowedToSignUpReason, ROLES } from "@shared/tenants"; +import SuperTokens from "supertokens-node"; +import { UserContext } from "supertokens-node/lib/build/types"; export const getOverrideableTenantFunctionImplementation = ( config: SuperTokensPluginTenantEnrollmentPluginConfig, ): OverrideableTenantFunctionImplementation => { const implementation: OverrideableTenantFunctionImplementation = { - canUserJoinTenant: async function ( - this: OverrideableTenantFunctionImplementation, - tenantId, - userIdentificationDetail, - ) { + canUserJoinTenant: async function (tenantId, userIdentificationDetail) { /** * Check if the user can join the tenant based on the email domain * @@ -28,7 +24,7 @@ export const getOverrideableTenantFunctionImplementation = ( */ // Skip this for the public tenant - if (tenantId === 'public') { + if (tenantId === "public") { return { canJoin: true, reason: undefined, @@ -46,17 +42,17 @@ export const getOverrideableTenantFunctionImplementation = ( let canJoin = false; let reason = undefined; - if (userIdentificationDetail.type === 'email') { + if (userIdentificationDetail.type === "email") { canJoin = this.isMatchingEmailDomain(tenantId, userIdentificationDetail.email); if (!canJoin) { reason = this.getMessageForNoSignUpReason(NotAllowedToSignUpReason.EMAIL_DOMAIN_NOT_ALLOWED); } - } else if (userIdentificationDetail.type === 'thirdParty') { + } else if (userIdentificationDetail.type === "thirdParty") { canJoin = this.isApprovedIdPProvider(tenantId, userIdentificationDetail.thirdPartyId); if (!canJoin) { reason = this.getMessageForNoSignUpReason(NotAllowedToSignUpReason.IDP_NOT_ALLOWED); } - } else if (userIdentificationDetail.type === 'phoneNumber') { + } else if (userIdentificationDetail.type === "phoneNumber") { // We don't really have a way to check anything for phones so we can // allow signup. return { @@ -70,7 +66,6 @@ export const getOverrideableTenantFunctionImplementation = ( }; }, handleTenantJoiningApproval: async function ( - this: OverrideableTenantFunctionImplementation, user: User, tenantId: string, associateLoginMethodDef: AssociateAllLoginMethodsOfUserWithTenant, @@ -92,7 +87,7 @@ export const getOverrideableTenantFunctionImplementation = ( * @param associateLoginMethodDef - The function to associate the login methods of the user with the tenant */ // Skip this for the public tenant - if (tenantId === 'public') { + if (tenantId === "public") { return { wasAddedToTenant: true, reason: undefined, @@ -110,24 +105,24 @@ export const getOverrideableTenantFunctionImplementation = ( // We don't need to do anything in particular except notifying // the tenant admins about the new user request being added. - // await implementation.sendTenantJoiningRequestEmail(tenantId, user, appUrl, sendEmail, userContext); + // await this.sendTenantJoiningRequestEmail(tenantId, user, appUrl, sendEmail, userContext); return { wasAddedToTenant: false, - reason: 'REQUIRES_APPROVAL', + reason: "REQUIRES_APPROVAL", }; }, - isTenantInviteOnly: (tenantId) => { + isTenantInviteOnly: function (tenantId) { return config.inviteOnlyTenants?.includes(tenantId) ?? false; }, - doesTenantRequireApproval: (tenantId) => { + doesTenantRequireApproval: function (tenantId) { return config.requiresApprovalTenants?.includes(tenantId) ?? false; }, - isApprovedIdPProvider: (tenantId, thirdPartyId) => { - return thirdPartyId.startsWith('boxy-saml'); + isApprovedIdPProvider: function (thirdPartyId) { + return thirdPartyId.startsWith("boxy-saml"); }, - isMatchingEmailDomain: (tenantId, email) => { - const emailDomain = email.split('@'); + isMatchingEmailDomain: function (tenantId, email) { + const emailDomain = email.split("@"); if (emailDomain.length !== 2) { return false; } @@ -135,14 +130,7 @@ export const getOverrideableTenantFunctionImplementation = ( const parsedTenantId = config.emailDomainToTenantIdMap[emailDomain[1]!.toLowerCase()]; return parsedTenantId === tenantId; }, - sendTenantJoiningRequestEmail: async function ( - this: OverrideableTenantFunctionImplementation, - tenantId, - user, - appUrl, - sendEmail, - userContext, - ) { + sendTenantJoiningRequestEmail: async function (tenantId, user, appUrl, sendEmail, userContext) { /** * Send an email to all the admins of the tenant * @@ -168,7 +156,7 @@ export const getOverrideableTenantFunctionImplementation = ( .map(async (email) => { await sendEmail( { - type: 'TENANT_REQUEST_APPROVAL', + type: "TENANT_REQUEST_APPROVAL", email, tenantId, senderEmail: user.emails[0]!, @@ -179,10 +167,10 @@ export const getOverrideableTenantFunctionImplementation = ( }), ); }, - getUserIdsInTenantWithRole: async (tenantId, role) => { - throw new Error('Not implemented'); + getUserIdsInTenantWithRole: async function (tenantId, role) { + throw new Error("Not implemented"); }, - isUserSigningUpToTenant: async (tenantId, details) => { + isUserSigningUpToTenant: async function (tenantId, details) { /** * List the users by account info and filter using the passed * tenantId and email. @@ -190,7 +178,7 @@ export const getOverrideableTenantFunctionImplementation = ( const accountInfoResponse = await listUsersByAccountInfo(tenantId, details); return accountInfoResponse.length === 0; }, - getMessageForNoSignUpReason: (reason) => { + getMessageForNoSignUpReason: function (reason) { /** * Return a proper message for the passed reason for not * allowing signup. diff --git a/packages/tenant-enrollment-react/package.json b/packages/tenant-enrollment-react/package.json index 3969037..742604a 100644 --- a/packages/tenant-enrollment-react/package.json +++ b/packages/tenant-enrollment-react/package.json @@ -32,24 +32,26 @@ }, "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", - "vitest": "^1.3.1", - "@shared/js": "*", - "@shared/react": "*", "vite": "^6.3.5", - "@vitejs/plugin-react": "^4.5.2", + "vite-plugin-css-injected-by-js": "^3.5.2", "vite-plugin-dts": "^4.5.4", - "rollup-plugin-peer-deps-external": "^2.2.4" + "vitest": "^1.3.1" }, "browser": { "fs": false }, "main": "./dist/index.js", + "module": "./dist/index.mjs", "types": "./dist/index.d.ts" } diff --git a/packages/tenant-enrollment-react/src/components/error/error-message.tsx b/packages/tenant-enrollment-react/src/components/error/error-message.tsx new file mode 100644 index 0000000..1388233 --- /dev/null +++ b/packages/tenant-enrollment-react/src/components/error/error-message.tsx @@ -0,0 +1,15 @@ +import classNames from "classnames/bind"; +import React from "react"; + +import "./error.module.scss"; +import styles from "./error.module.scss"; + +const cx = classNames.bind(styles); + +type ErrorMessageProps = { + message: string; +}; + +export const ErrorMessage: React.FC = ({ message }) => { + return
{message}
; +}; diff --git a/packages/tenant-enrollment-react/src/components/error/error.module.scss b/packages/tenant-enrollment-react/src/components/error/error.module.scss new file mode 100644 index 0000000..e94854a --- /dev/null +++ b/packages/tenant-enrollment-react/src/components/error/error.module.scss @@ -0,0 +1,5 @@ +.errorMessageWrapper { + background-color: var(--semantic-colors-error-5); + color: var(--semantic-colors-error-9); + padding: 4px; +} diff --git a/packages/tenant-enrollment-react/src/components/index.ts b/packages/tenant-enrollment-react/src/components/index.ts new file mode 100644 index 0000000..531befa --- /dev/null +++ b/packages/tenant-enrollment-react/src/components/index.ts @@ -0,0 +1 @@ +export { ErrorMessage } from "./error/error-message"; diff --git a/packages/tenant-enrollment-react/src/plugin.tsx b/packages/tenant-enrollment-react/src/plugin.tsx index ef89515..63f6da7 100644 --- a/packages/tenant-enrollment-react/src/plugin.tsx +++ b/packages/tenant-enrollment-react/src/plugin.tsx @@ -7,7 +7,10 @@ import { getTranslationFunction, } from "supertokens-auth-react"; +import { NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE } from "../../../shared/tenants/src"; + import { getApi } from "./api"; +import { ErrorMessage } from "./components"; import { PLUGIN_ID, API_PATH } from "./constants"; import { enableDebugLogs, logDebugMessage } from "./logger"; import { getOverrideableTenantFunctionImplementation } from "./pluginImplementation"; @@ -38,6 +41,7 @@ export const init = createPluginInitFunction< >( (pluginConfig, implementation) => { let isInviteOnly = false; + let generalErrorMessage: string | undefined = undefined; return { id: PLUGIN_ID, init: (config, plugins, sdkVersion) => { @@ -64,64 +68,42 @@ export const init = createPluginInitFunction< }); }, overrideMap: { - // emailpassword: { - // functions: (originalImplementation) => ({ - // ...originalImplementation, - // signUp: async (input) => { - // let signUpResponse; - // implementation.withSignUpBlockedRedirect(async () => { - // signUpResponse = await originalImplementation.signUp(input); - // }); - - // return signUpResponse; - // }, - // }), - // }, - // webauthn: { - // functions: (originalImplementation) => ({ - // ...originalImplementation, - // getRegisterOptions: async (input) => { - // let response; - // implementation.withSignUpBlockedRedirect(async () => { - // response = await originalImplementation.getRegisterOptions(input); - // }); - // return response; - // }, - // }), - // }, - // passwordless: { - // functions: (originalImplementation) => ({ - // ...originalImplementation, - // createCode: async (input) => { - // let createCodeResponse; - // implementation.withSignUpBlockedRedirect(async () => { - // createCodeResponse = await originalImplementation.createCode(input); - // }); - - // return createCodeResponse; - // }, - // consumeCode: async (input) => { - // let consumeCodeResponse; - // implementation.withSignUpBlockedRedirect(async () => { - // consumeCodeResponse = await originalImplementation.consumeCode(input); - // }); + webauthn: { + functions: (originalImplementation) => ({ + ...originalImplementation, + getRegisterOptions: async (input) => { + let response; + try { + response = await originalImplementation.getRegisterOptions(input); - // return consumeCodeResponse; - // }, - // }), - // }, - // thirdparty: { - // functions: (originalImplementation) => ({ - // ...originalImplementation, - // signInAndUp: async (input) => { - // let signInAndUpResponse; - // implementation.withSignUpBlockedRedirect(async () => { - // signInAndUpResponse = await originalImplementation.signInAndUp(input); - // }); - // return signInAndUpResponse; - // }, - // }), - // }, + // If the execution completes without getting a general error, clear + // the errorMessage in case it is there from before. + generalErrorMessage = undefined; + } catch (error: any) { + if ( + error.isSuperTokensGeneralError === true && + Object.values(NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE).includes(error.message) + ) { + // This is an error for sign-up being blocked so we will + // capture the message and use it later. + generalErrorMessage = error.message; + } + } + return response; + }, + }), + components: { + WebauthnPasskeySignUpSomethingWentWrong_Override: ({ DefaultComponent, ...props }) => { + return ( +
+ {generalErrorMessage !== undefined && } + {/* @ts-ignore */} + +
+ ); + }, + }, + }, multitenancy: { functions: (originalImplementation) => ({ ...originalImplementation, diff --git a/packages/tenant-enrollment-react/vite.config.ts b/packages/tenant-enrollment-react/vite.config.ts index 7d43469..03cff1c 100644 --- a/packages/tenant-enrollment-react/vite.config.ts +++ b/packages/tenant-enrollment-react/vite.config.ts @@ -1,21 +1,23 @@ -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 { 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') }), + dts({ entryRoot: "src", tsconfigPath: path.join(__dirname, "tsconfig.json") }), peerDepsExternal(), + cssInjectedByJsPlugin(), ], build: { - outDir: 'dist', + outDir: "dist", sourcemap: false, emptyOutDir: true, commonjsOptions: { @@ -23,12 +25,12 @@ export default defineConfig(() => { }, lib: { // Could also be a dictionary or array of multiple entry points. - entry: 'src/index.ts', - fileName: 'index', + 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], + formats: ["es" as const, "cjs" as const], }, rollupOptions: { cache: false, From 4069a15a2de171c2ada9c27ef26c9915f387d2d4 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 17 Oct 2025 13:30:03 +0530 Subject: [PATCH 30/36] feat: uncomment code for sending tenant request notifications --- packages/tenant-enrollment-nodejs/src/pluginImplementation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts index 6df2706..9e32860 100644 --- a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts +++ b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts @@ -105,7 +105,7 @@ export const getOverrideableTenantFunctionImplementation = ( // We don't need to do anything in particular except notifying // the tenant admins about the new user request being added. - // await this.sendTenantJoiningRequestEmail(tenantId, user, appUrl, sendEmail, userContext); + await this.sendTenantJoiningRequestEmail(tenantId, user, appUrl, sendEmail, userContext); return { wasAddedToTenant: false, From fc9160606e1b167440908a959877f39b87544779 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Fri, 17 Oct 2025 19:05:17 +0530 Subject: [PATCH 31/36] op: avoid duplicate import for error module scss --- .../src/components/error/error-message.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tenant-enrollment-react/src/components/error/error-message.tsx b/packages/tenant-enrollment-react/src/components/error/error-message.tsx index 1388233..0042929 100644 --- a/packages/tenant-enrollment-react/src/components/error/error-message.tsx +++ b/packages/tenant-enrollment-react/src/components/error/error-message.tsx @@ -1,7 +1,6 @@ import classNames from "classnames/bind"; import React from "react"; -import "./error.module.scss"; import styles from "./error.module.scss"; const cx = classNames.bind(styles); From 5013e82c4f1a466ded9d0f6f510fc0b99ec600e4 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Wed, 22 Oct 2025 15:07:10 +0530 Subject: [PATCH 32/36] fix: issue of CSS injection for error message wrapper --- .../src/components/error/error-message.tsx | 16 ++++++++++------ .../src/components/error/error.module.scss | 5 ----- 2 files changed, 10 insertions(+), 11 deletions(-) delete mode 100644 packages/tenant-enrollment-react/src/components/error/error.module.scss diff --git a/packages/tenant-enrollment-react/src/components/error/error-message.tsx b/packages/tenant-enrollment-react/src/components/error/error-message.tsx index 0042929..2125782 100644 --- a/packages/tenant-enrollment-react/src/components/error/error-message.tsx +++ b/packages/tenant-enrollment-react/src/components/error/error-message.tsx @@ -1,14 +1,18 @@ -import classNames from "classnames/bind"; import React from "react"; -import styles from "./error.module.scss"; - -const cx = classNames.bind(styles); - type ErrorMessageProps = { message: string; }; export const ErrorMessage: React.FC = ({ message }) => { - return
{message}
; + return ( +
+ {message} +
+ ); }; diff --git a/packages/tenant-enrollment-react/src/components/error/error.module.scss b/packages/tenant-enrollment-react/src/components/error/error.module.scss deleted file mode 100644 index e94854a..0000000 --- a/packages/tenant-enrollment-react/src/components/error/error.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.errorMessageWrapper { - background-color: var(--semantic-colors-error-5); - color: var(--semantic-colors-error-9); - padding: 4px; -} From 096a84439dea3c61ed6bcabd20afcb90d0c3c2f9 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Thu, 23 Oct 2025 11:45:56 +0530 Subject: [PATCH 33/36] test: add tests for all implementation methods for enrollment-nodejs --- .../src/plugin.test.ts | 640 ++++++++++++++++++ .../tenant-enrollment-nodejs/vitest.config.ts | 16 + 2 files changed, 656 insertions(+) create mode 100644 packages/tenant-enrollment-nodejs/src/plugin.test.ts create mode 100644 packages/tenant-enrollment-nodejs/vitest.config.ts diff --git a/packages/tenant-enrollment-nodejs/src/plugin.test.ts b/packages/tenant-enrollment-nodejs/src/plugin.test.ts new file mode 100644 index 0000000..a60e94b --- /dev/null +++ b/packages/tenant-enrollment-nodejs/src/plugin.test.ts @@ -0,0 +1,640 @@ +import express from 'express'; +import crypto from 'node:crypto'; +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; + +// SuperTokens Core +import SuperTokens, { getUser } from 'supertokens-node'; +import Session from 'supertokens-node/recipe/session'; +import EmailPassword from 'supertokens-node/recipe/emailpassword'; +import ThirdParty from 'supertokens-node/recipe/thirdparty'; +import Passwordless from 'supertokens-node/recipe/passwordless'; +import WebAuthn from 'supertokens-node/recipe/webauthn'; +import UserRoles from 'supertokens-node/recipe/userroles'; +import Multitenancy from 'supertokens-node/recipe/multitenancy'; +import AccountLinking from 'supertokens-node/recipe/accountlinking'; +import UserMetadata from 'supertokens-node/recipe/usermetadata'; + +// SuperTokens Framework +import { middleware, errorHandler } from 'supertokens-node/framework/express'; +import { verifySession } from 'supertokens-node/recipe/session/framework/express'; + +// Raw Instances (for reset/cleanup) +import { ProcessState } from 'supertokens-node/lib/build/processState'; +import SuperTokensRaw from 'supertokens-node/lib/build/supertokens'; +import SessionRaw from 'supertokens-node/lib/build/recipe/session/recipe'; +import UserRolesRaw from 'supertokens-node/lib/build/recipe/userroles/recipe'; +import EmailPasswordRaw from 'supertokens-node/lib/build/recipe/emailpassword/recipe'; +import ThirdPartyRaw from 'supertokens-node/lib/build/recipe/thirdparty/recipe'; +import PasswordlessRaw from 'supertokens-node/lib/build/recipe/passwordless/recipe'; +import WebAuthnRaw from 'supertokens-node/lib/build/recipe/webauthn/recipe'; +import AccountLinkingRaw from 'supertokens-node/lib/build/recipe/accountlinking/recipe'; +import MultitenancyRaw from 'supertokens-node/lib/build/recipe/multitenancy/recipe'; +import UserMetadataRaw from 'supertokens-node/lib/build/recipe/usermetadata/recipe'; + +// Plugins +import tenantsPlugin from '@supertokens-plugins/tenants-nodejs'; +import { init } from './plugin'; +import type { SuperTokensPluginTenantEnrollmentPluginConfig } from './types'; + +const testPORT = parseInt(process.env.PORT || '3000'); +const testEmail = 'user@test.com'; +const testPW = 'test1234'; + +function resetST() { + ProcessState.getInstance().reset(); + SuperTokensRaw.reset(); + SessionRaw.reset(); + UserRolesRaw.reset(); + EmailPasswordRaw.reset(); + ThirdPartyRaw.reset(); + PasswordlessRaw.reset(); + WebAuthnRaw.reset(); + AccountLinkingRaw.reset(); + MultitenancyRaw.reset(); + UserMetadataRaw.reset(); +} + +async function setup(pluginConfig: SuperTokensPluginTenantEnrollmentPluginConfig) { + let appId; + let isNewApp = false; + const coreBaseURL = process.env.CORE_BASE_URL || `http://localhost:3567`; + + // Generate unique app ID and create app via Core API + if (appId === undefined) { + isNewApp = true; + appId = crypto.randomUUID(); + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (process.env.CORE_API_KEY) { + headers['api-key'] = process.env.CORE_API_KEY; + } + await fetch(`${coreBaseURL}/recipe/multitenancy/app/v2`, { + method: 'PUT', + headers, + body: JSON.stringify({ + appId: appId, + coreConfig: {}, + }), + }); + } + + // Initialize SuperTokens with plugins + 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({}), + ThirdParty.init({ + signInAndUpFeature: { + providers: [ + { + config: { + thirdPartyId: 'google', + clients: [ + { + clientId: 'test', + clientSecret: 'test', + }, + ], + }, + }, + { + config: { + thirdPartyId: 'boxy-saml-provider1', + clients: [ + { + clientId: 'test', + clientSecret: 'test', + }, + ], + }, + }, + ], + }, + }), + Passwordless.init({ + contactMethod: 'EMAIL', + flowType: 'USER_INPUT_CODE_AND_MAGIC_LINK', + }), + WebAuthn.init({}), + UserRoles.init({}), + AccountLinking.init({ + shouldDoAutomaticAccountLinking: async () => ({ + shouldAutomaticallyLink: false, + shouldRequireVerification: false, + }), + }), + Multitenancy.init({}), + UserMetadata.init({}), + ], + experimental: { + plugins: [tenantsPlugin.init(), init(pluginConfig)], + }, + }); + + // Create Express app with SuperTokens middleware + const app = express(); + app.use(middleware()); + app.get('/check-session', verifySession(), (req, res) => { + res.json({ status: 'OK' }); + }); + app.use(errorHandler()); + + // Start server + await new Promise((resolve) => { + app.listen(testPORT, () => resolve()); + }); + + // Create test user + let user; + let session; + if (isNewApp) { + const signupResponse = await EmailPassword.signUp('public', testEmail, testPW); + if (signupResponse.status !== 'OK') { + throw new Error('Failed to set up test user'); + } + user = signupResponse.user; + session = await Session.createNewSessionWithoutRequestResponse( + 'public', + SuperTokens.convertToRecipeUserId(user.id), + ); + } else { + const userResponse = await SuperTokens.listUsersByAccountInfo('public', { + email: testEmail, + }); + user = userResponse[0]; + session = await Session.createNewSessionWithoutRequestResponse( + 'public', + SuperTokens.convertToRecipeUserId(user.id), + ); + } + + return { user, session, appId }; +} + +describe('tenant-enrollment-nodejs', () => { + beforeEach(() => { + resetST(); + }); + + afterEach(() => { + resetST(); + }); + + describe('Email Domain Validation', () => { + it('should allow signup when email domain matches tenant configuration', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + }); + + // Create tenant + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + // Try to sign up with matching domain + const response = await EmailPassword.signUp('tenant-1', 'user@company.com', testPW); + + expect(response.status).toBe('OK'); + }); + + it('should reject signup when email domain does not match tenant', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await EmailPassword.signUp('tenant-1', 'user@other.com', testPW); + + expect(response.status).toBe('EMAIL_ALREADY_EXISTS_ERROR'); + expect((response as any).reason.toLowerCase()).toContain('email domain'); + }); + + it('should handle case-insensitive domain matching', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await EmailPassword.signUp('tenant-1', 'user@COMPANY.COM', testPW); + + expect(response.status).toBe('OK'); + }); + + it('should handle subdomain matching correctly', async () => { + await setup({ + emailDomainToTenantIdMap: { 'sub.company.com': 'tenant-1' }, + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await EmailPassword.signUp('tenant-1', 'user@sub.company.com', testPW); + + expect(response.status).toBe('OK'); + }); + + it('should allow signup to public tenant without domain check', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + }); + + // Try to sign up to public tenant with any email + const response = await EmailPassword.signUp('public', 'user@anydomain.com', testPW); + + expect(response.status).toBe('OK'); + }); + }); + + describe('Invite-Only Tenant Logic', () => { + it('should block signup for invite-only tenant without approval', async () => { + await setup({ + emailDomainToTenantIdMap: { 'test.com': 'tenant-1' }, + inviteOnlyTenants: ['tenant-1'], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await EmailPassword.signUp('tenant-1', 'user@test.com', testPW); + + expect(response.status).toBe('EMAIL_ALREADY_EXISTS_ERROR'); + expect((response as any).reason.toLowerCase()).toContain('invite only'); + }); + + it('should allow signup for non-invite-only tenant', async () => { + await setup({ + emailDomainToTenantIdMap: { 'test.com': 'tenant-1' }, + inviteOnlyTenants: ['tenant-2'], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await EmailPassword.signUp('tenant-1', 'user@test.com', testPW); + + expect(response.status).toBe('OK'); + }); + + it('should allow signup to public tenant regardless of invite-only config', async () => { + await setup({ + emailDomainToTenantIdMap: {}, + inviteOnlyTenants: ['tenant-1'], + }); + + const response = await EmailPassword.signUp('public', 'user1@test.com', testPW); + + expect(response.status).toBe('OK'); + }); + + it('should allow signup with approved SAML provider in invite-only tenant', async () => { + await setup({ + emailDomainToTenantIdMap: { 'test.com': 'tenant-1' }, + inviteOnlyTenants: ['tenant-1'], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['thirdparty'], + }); + + const response = await ThirdParty.manuallyCreateOrUpdateUser( + 'tenant-1', + 'boxy-saml-provider1', + 'external-user-id', + 'user@test.com', + false, + ); + + expect(response.status).toBe('OK'); + }); + + it('should allow existing user to sign in to invite-only tenant', async () => { + await setup({ + emailDomainToTenantIdMap: { 'test.com': 'tenant-2' }, + inviteOnlyTenants: ['tenant-1'], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + // First create user in public tenant + const signupResponse = await EmailPassword.signUp('public', 'existing@test.com', testPW); + expect(signupResponse.status).toBe('OK'); + + // Manually associate user with tenant-1 + if (signupResponse.status === 'OK') { + await Multitenancy.associateUserToTenant('tenant-1', signupResponse.user.loginMethods[0].recipeUserId); + + // Now try to sign in (should work since user already exists in tenant) + const signinResponse = await EmailPassword.signIn('tenant-1', 'existing@test.com', testPW); + expect(signinResponse.status).toBe('OK'); + } + }); + }); + + describe('Approval Workflow', () => { + it('should automatically add user to tenant when approval not required', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + requiresApprovalTenants: [], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await EmailPassword.signUp('tenant-1', 'user@company.com', testPW); + + expect(response.status).toBe('OK'); + + // Verify user is associated with tenant + if (response.status === 'OK') { + const userDetails = await getUser(response.user.id); + expect(userDetails?.tenantIds).toContain('tenant-1'); + } + }); + + it('should require approval for tenant in requiresApprovalTenants list', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + requiresApprovalTenants: ['tenant-1'], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await EmailPassword.signUp('tenant-1', 'user1ForTenant1@company.com', testPW); + + expect(response.status).toBe('OK'); + + // User should be associated with tenant since requiring approval means + // they aren't given a role but are associated with the tenant. + if (response.status === 'OK') { + const userDetails = await getUser(response.user.id); + // User should be in public tenant and tenant-1 + expect(userDetails?.tenantIds.includes('tenant-1')).toBe(true); + } + }); + + it('should not require approval for public tenant', async () => { + await setup({ + emailDomainToTenantIdMap: { 'test.com': 'tenant-2' }, + requiresApprovalTenants: ['tenant-1'], + }); + + const response = await EmailPassword.signUp('public', 'user2@test.com', testPW); + + expect(response.status).toBe('OK'); + + // User should be in public tenant + if (response.status === 'OK') { + const userDetails = await getUser(response.user.id); + expect(userDetails?.tenantIds).toContain('public'); + } + }); + + it('should allow signup with matching domain but no approval config', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await EmailPassword.signUp('tenant-1', 'user@company.com', testPW); + + expect(response.status).toBe('OK'); + + // User should be automatically added + if (response.status === 'OK') { + const userDetails = await getUser(response.user.id); + expect(userDetails?.tenantIds).toContain('tenant-1'); + } + }); + }); + + describe('Passwordless Recipe Overrides', () => { + it('should validate email domain for passwordless code creation', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['passwordless'], + }); + + // Should fail with non-matching domain + const response = await Passwordless.createCode({ + tenantId: 'tenant-1', + email: 'user@other.com', + }); + + expect(response.status).toBe('GENERAL_ERROR'); + expect((response as any).message.toLowerCase()).toContain('email domain'); + }); + + it('should allow passwordless code creation with matching domain', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['passwordless'], + }); + + const response = await Passwordless.createCode({ + tenantId: 'tenant-1', + email: 'user@company.com', + }); + + expect(response.status).toBe('OK'); + }); + + it('should block passwordless signup in invite-only tenant', async () => { + await setup({ + emailDomainToTenantIdMap: { 'test.com': 'tenant-1' }, + inviteOnlyTenants: ['tenant-1'], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['passwordless'], + }); + + const response = await Passwordless.createCode({ + tenantId: 'tenant-1', + email: 'user@test.com', + }); + + expect(response.status).toBe('GENERAL_ERROR'); + expect((response as any).message).toContain('invite only'); + }); + + it('should not allow passwordless for phone number', async () => { + await setup({ + inviteOnlyTenants: ['tenant-1'], + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['passwordless'], + }); + + // Phone numbers should bypass restrictions + const response = await Passwordless.createCode({ + tenantId: 'tenant-1', + phoneNumber: '+1234567890', + }); + + console.log(response); + + expect(response.status).toBe('GENERAL_ERROR'); + expect((response as any).message).toContain('invite only'); + }); + }); + + describe('Multitenancy Recipe Integration', () => { + it('should inject inviteOnly flag in loginMethods for invite-only tenant', async () => { + await setup({ + emailDomainToTenantIdMap: {}, + inviteOnlyTenants: ['tenant-1'], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await fetch(`http://localhost:${testPORT}/auth/tenant-1/loginmethods`, { + method: 'GET', + }); + + const result = await response.json(); + expect(result.isTenantInviteOnly).toBe(true); + }); + + it('should not set inviteOnly flag for non-invite-only tenant', async () => { + await setup({ + emailDomainToTenantIdMap: {}, + inviteOnlyTenants: ['tenant-2'], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await fetch(`http://localhost:${testPORT}/auth/tenant-1/loginmethods`, { + method: 'GET', + }); + + const result = await response.json(); + expect(result.isTenantInviteOnly).toBe(false); + }); + + it('should not set inviteOnly flag for public tenant', async () => { + await setup({ + emailDomainToTenantIdMap: {}, + inviteOnlyTenants: ['tenant-1'], + }); + + const response = await fetch(`http://localhost:${testPORT}/auth/loginmethods`, { + method: 'GET', + }); + + const result = await response.json(); + expect(result.isTenantInviteOnly).toBe(false); + }); + }); + + describe('Edge Cases and Error Handling', () => { + it('should not allow signup when no configuration is provided', async () => { + await setup({ + emailDomainToTenantIdMap: {}, + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response = await EmailPassword.signUp('tenant-1', 'user@any.com', testPW); + + expect(response.status).toBe('EMAIL_ALREADY_EXISTS_ERROR'); + expect((response as any).reason.toLowerCase()).toContain('email domain not allowed'); + }); + + it('should handle tenant that does not exist by denying signup', async () => { + await setup({ + emailDomainToTenantIdMap: {}, + inviteOnlyTenants: ['tenant-1'], + }); + + // Try to sign up to non-existent tenant + const response = await EmailPassword.signUp('nonexistent', 'user@test.com', testPW); + + expect(response.status).toBe('EMAIL_ALREADY_EXISTS_ERROR'); + expect((response as any).reason.toLowerCase()).toContain('email domain not allowed'); + }); + + it('should handle multiple domain mappings correctly', async () => { + await setup({ + emailDomainToTenantIdMap: { + 'company1.com': 'tenant-1', + 'company2.com': 'tenant-2', + 'company3.com': 'tenant-3', + }, + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + const response1 = await EmailPassword.signUp('tenant-1', 'user@company1.com', testPW); + expect(response1.status).toBe('OK'); + + const response2 = await EmailPassword.signUp('tenant-1', 'user2@company2.com', testPW); + expect(response2.status).toBe('EMAIL_ALREADY_EXISTS_ERROR'); + }); + + it('should handle combined restrictions properly', async () => { + await setup({ + emailDomainToTenantIdMap: { 'company.com': 'tenant-1' }, + inviteOnlyTenants: ['tenant-1'], + requiresApprovalTenants: ['tenant-1'], + }); + + await Multitenancy.createOrUpdateTenant('tenant-1', { + firstFactors: ['emailpassword'], + }); + + // Should fail invite-only check first + const response1 = await EmailPassword.signUp('tenant-1', 'user@company.com', testPW); + expect(response1.status).toBe('EMAIL_ALREADY_EXISTS_ERROR'); + expect((response1 as any).reason.toLowerCase()).toContain('invite only'); + + // Should throw invite only since it doesn't make sense + // to check domain further anyway + const response2 = await EmailPassword.signUp('tenant-1', 'user@other.com', testPW); + expect(response2.status).toBe('EMAIL_ALREADY_EXISTS_ERROR'); + expect((response2 as any).reason.toLowerCase()).toContain('invite only'); + }); + }); +}); diff --git a/packages/tenant-enrollment-nodejs/vitest.config.ts b/packages/tenant-enrollment-nodejs/vitest.config.ts new file mode 100644 index 0000000..ad798df --- /dev/null +++ b/packages/tenant-enrollment-nodejs/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + preserveSymlinks: true, + }, + test: { + globals: true, + environment: "node", + poolOptions: { + forks: { + singleFork: true, + }, + }, + }, +}); From 641b58b46ec962de9bbe9487be3bf0db9e3f64c2 Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Thu, 23 Oct 2025 11:57:40 +0530 Subject: [PATCH 34/36] op: add readme for tenant enrollment in nodejs --- packages/tenant-enrollment-nodejs/README.md | 519 ++++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 packages/tenant-enrollment-nodejs/README.md diff --git a/packages/tenant-enrollment-nodejs/README.md b/packages/tenant-enrollment-nodejs/README.md new file mode 100644 index 0000000..1ffbfb3 --- /dev/null +++ b/packages/tenant-enrollment-nodejs/README.md @@ -0,0 +1,519 @@ +# SuperTokens Plugin Tenant Enrollment + +Control and manage tenant signup enrollment rules for your SuperTokens Node.js backend. +This plugin provides comprehensive enrollment validation based on email domains, invite-only tenants, and approval workflows. It works seamlessly with all SuperTokens authentication recipes including EmailPassword, ThirdParty, Passwordless, and WebAuthn. + +## Installation + +```bash +npm install @supertokens-plugins/tenant-enrollment-nodejs +``` + +## Prerequisites + +This plugin requires the tenants plugin to be installed and initialized: + +```bash +npm install @supertokens-plugins/tenants-nodejs +``` + +> [!IMPORTANT] +> The tenants plugin must be initialized **before** the tenant-enrollment plugin in your SuperTokens configuration. + +## Quick Start + +### Backend Configuration + +Initialize the plugin in your SuperTokens backend configuration: + +```typescript +import SuperTokens from 'supertokens-node'; +import Session from 'supertokens-node/recipe/session'; +import EmailPassword from 'supertokens-node/recipe/emailpassword'; +import UserRoles from 'supertokens-node/recipe/userroles'; +import TenantsPlugin from '@supertokens-plugins/tenants-nodejs'; +import TenantEnrollmentPlugin from '@supertokens-plugins/tenant-enrollment-nodejs'; + +SuperTokens.init({ + appInfo: { + // your app info + }, + recipeList: [ + Session.init({}), + EmailPassword.init({}), + UserRoles.init({}), // Required for role-based access control + // your other recipes + ], + experimental: { + plugins: [ + TenantsPlugin.init({ + // tenants plugin configuration + }), + TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + 'company.com': 'tenant-1', + 'subsidiary.com': 'tenant-2', + }, + inviteOnlyTenants: ['tenant-1'], // Optional + requiresApprovalTenants: ['tenant-2'], // Optional + }), + ], + }, +}); +``` + +> [!IMPORTANT] +> You may also want to install and configure the frontend plugin for the complete tenant enrollment experience with proper error messages and UI flows. + +## Configuration Options + +| Option | Type | Required | Description | +| -------------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------- | +| `emailDomainToTenantIdMap` | `Record` | Yes | Maps email domains to tenant IDs for domain-based enrollment | +| `inviteOnlyTenants` | `string[]` | No | List of tenant IDs that only accept invited users or approved SAML providers | +| `requiresApprovalTenants` | `string[]` | No | List of tenant IDs where new signups require admin approval | + +### Configuration Examples + +#### Email Domain Validation Only + +```typescript +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + 'company.com': 'tenant-company', + 'partner.com': 'tenant-partner', + }, +}); +``` + +Users with `@company.com` email addresses can only sign up to `tenant-company`, and users with `@partner.com` can only sign up to `tenant-partner`. + +#### Invite-Only Tenant + +```typescript +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + 'enterprise.com': 'tenant-enterprise', + }, + inviteOnlyTenants: ['tenant-enterprise'], +}); +``` + +Users cannot sign up to `tenant-enterprise` unless they are invited to the tenant. + +#### Approval-Required Tenant + +```typescript +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + 'startup.com': 'tenant-startup', + }, + requiresApprovalTenants: ['tenant-startup'], +}); +``` + +Users with `@startup.com` email can sign up, but they won't be automatically added to the tenant. Instead, tenant admins will receive an email notification to approve the request. + +#### Combined Configuration + +```typescript +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + 'secure.com': 'tenant-secure', + 'open.com': 'tenant-open', + }, + inviteOnlyTenants: ['tenant-secure'], + requiresApprovalTenants: ['tenant-open'], +}); +``` + +## How It Works + +The tenant enrollment plugin works by intercepting signup attempts across all authentication recipes and applying enrollment rules: + +### 1. Email Domain Validation + +When a user tries to sign up to a tenant, the plugin checks if their email domain is allowed for that tenant: + +```typescript +// User with user@company.com trying to sign up to tenant-1 +emailDomainToTenantIdMap: { + "company.com": "tenant-1", + "other.com": "tenant-2", +} + +// ✅ Allowed: user@company.com → tenant-1 +// ❌ Blocked: user@company.com → tenant-2 +// ❌ Blocked: user@other.com → tenant-1 +``` + +**Features:** + +- Case-insensitive domain matching +- Subdomain support (`user@sub.company.com` matches `company.com` mapping) +- Public tenant always bypasses validation + +### 2. Invite-Only Tenant Logic + +Prevents unauthorized signups to sensitive tenants: + +```typescript +inviteOnlyTenants: ['tenant-1']; +``` + +**Allowed:** + +- Users signing in (already exist in tenant) + +**Blocked:** + +- New sign-ups + +### 3. Approval Workflow + +Requires admin approval before granting tenant access: + +```typescript +requiresApprovalTenants: ['tenant-1']; +``` + +**Workflow:** + +1. User successfully signs up (creates account) +2. User is associated with the tenant but without a role (unable to access anything) +3. Email sent to all tenant admins (users with `tenant-admin` role) +4. Admin manually approves/rejects the request via tenant management UI +5. Upon approval, user is added to tenant with `tenant-member` role + +## Authentication Recipe Support + +The plugin automatically integrates with all SuperTokens authentication recipes: + +### EmailPassword Recipe + +Validates email domain before allowing signup: + +```typescript +// Blocks signup if email domain doesn't match +const response = await EmailPassword.signUp('tenant-1', 'user@wrong.com', 'password'); +// response.status === "GENERAL_ERROR" +// response.message === "Your email domain is not allowed to sign up" +``` + +### ThirdParty Recipe + +Validates IdP provider and email domain: + +```typescript +// OAuth providers (Google, GitHub, etc.) +// - Blocked in invite-only tenants +// - Validates email domain in non-invite-only tenants + +// SAML providers (boxy-saml-*) +// - Allowed in invite-only tenants +// - Validates email domain in non-invite-only tenants +``` + +### Passwordless Recipe + +Validates email domain for email-based authentication: + +```typescript +// Email-based passwordless +// - Validates email domain + +// Phone-based passwordless +// - Always allowed (no domain to validate) +``` + +### WebAuthn Recipe + +Validates email domain before generating registration options: + +```typescript +// Blocks registration if email domain doesn't match +await WebAuthn.generateRegistrationOptions({ + tenantId: 'tenant-1', + email: 'user@wrong.com', +}); +// Throws error: "Your email domain is not allowed to sign up" +``` + +## API Behavior + +The plugin modifies API responses to include enrollment status information: + +### Multitenancy Recipe - Login Methods + +The plugin injects an `inviteOnly` flag into the login methods response: + +**Request:** + +``` +GET /auth/tenant-1/loginmethods +``` + +**Response:** + +```json +{ + "status": "OK", + "firstFactors": ["emailpassword", "thirdparty"], + "isTenantInviteOnly": true +} +``` + +This allows frontend applications to display appropriate messaging to users before they attempt to sign up. + +## Error Messages + +The plugin returns user-friendly error messages for different rejection reasons: + +| Scenario | Error Message | +| ------------------------- | ------------------------------------------------------------------------------ | +| Email domain not allowed | "Your email domain is not allowed to sign up" | +| Invite-only tenant | "This tenant is invite only and you cannot sign up" | +| Non-approved IdP provider | "This tenant is invite only and you need to use an approved identity provider" | + +## Advanced Configuration + +### Custom Implementation Override + +You can override default behaviors by providing custom implementations: + +```typescript +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + 'company.com': 'tenant-1', + }, + override: { + functions: (originalImplementation) => ({ + ...originalImplementation, + + // Custom logic to check if user can join tenant + canUserJoinTenant: async (tenantId, userIdentificationDetail) => { + // Add custom validation logic + if (userIdentificationDetail.type === 'email') { + // Check against custom database or API + const isAllowed = await checkCustomRules(userIdentificationDetail.email); + if (!isAllowed) { + return { + canJoin: false, + reason: 'Custom rejection reason', + }; + } + } + + // Fall back to default implementation + return originalImplementation.canUserJoinTenant(tenantId, userIdentificationDetail); + }, + + // Custom approval workflow logic + handleTenantJoiningApproval: async ( + user, + tenantId, + associateLoginMethodDef, + sendEmail, + appUrl, + userContext, + assignRoleToUserInTenant, + ) => { + // Custom logic before approval + await logTenantJoinAttempt(user, tenantId); + + // Call default implementation + return originalImplementation.handleTenantJoiningApproval( + user, + tenantId, + associateLoginMethodDef, + sendEmail, + appUrl, + userContext, + assignRoleToUserInTenant, + ); + }, + + // Custom email domain matching logic + isMatchingEmailDomain: (tenantId, email) => { + // Add custom domain matching logic (e.g., wildcard support) + return customDomainMatcher(tenantId, email); + }, + }), + }, +}); +``` + +### Available Override Functions + +| Function | Description | Returns | +| ------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- | +| `canUserJoinTenant` | Determine if user can join a tenant based on their identification | `Promise<{ canJoin: boolean, reason?: string }>` | +| `handleTenantJoiningApproval` | Handle post-signup approval workflow | `Promise<{ wasAddedToTenant: boolean, reason?: string }>` | +| `isTenantInviteOnly` | Check if tenant is invite-only | `boolean` | +| `doesTenantRequireApproval` | Check if tenant requires approval | `boolean` | +| `isApprovedIdPProvider` | Validate if IdP provider is approved (SAML) | `boolean` | +| `isMatchingEmailDomain` | Check if email domain matches tenant | `boolean` | +| `sendTenantJoiningRequestEmail` | Send approval request email to admins | `Promise` | +| `isUserSigningUpToTenant` | Determine if user is signing up vs signing in | `Promise` | +| `getMessageForNoSignUpReason` | Get user-friendly error message for rejection | `string` | + +## Email Notifications + +When a user signs up to a tenant that requires approval, the plugin sends an email notification to all tenant admins. + +### Email Type: TENANT_REQUEST_APPROVAL + +**Sent to:** All users with `tenant-admin` role in the target tenant + +**Email Content:** + +- User information (email, name) +- Tenant information +- Link to admin dashboard to approve/reject the request + +**Customizing Email Delivery:** + +Email delivery is handled by the base tenants plugin. Configure it in your `TenantsPlugin.init()`: + +```typescript +import { PluginSMTPService } from '@supertokens-plugins/tenants-nodejs'; + +TenantsPlugin.init({ + emailDelivery: { + service: new PluginSMTPService({ + smtpSettings: { + host: 'smtp.example.com', + port: 587, + from: { + name: 'Your App', + email: 'noreply@example.com', + }, + secure: false, + authUsername: 'username', + password: 'password', + }, + }), + }, +}); +``` + +## Use Cases + +### Enterprise Multi-Tenant SaaS + +```typescript +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + 'acme.com': 'tenant-acme', + 'globex.com': 'tenant-globex', + }, + inviteOnlyTenants: ['tenant-acme'], +}); +``` + +- Acme Corp employees can only use their work email +- Acme is invite-only to prevent unauthorized signups +- Globex employees can sign up freely with company email + +### Managed Service Provider + +```typescript +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + 'client1.com': 'tenant-client1', + 'client2.com': 'tenant-client2', + }, + requiresApprovalTenants: ['tenant-client1', 'tenant-client2'], +}); +``` + +- Each client has their own tenant +- All signups require MSP admin approval +- Domain validation ensures users sign up to correct tenant + +### Freemium to Enterprise + +```typescript +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + 'premium-corp.com': 'tenant-premium', + }, + requiresApprovalTenants: ['tenant-premium'], +}); +``` + +- Free tier: Public tenant (no restrictions) +- Premium tier: Domain-validated with approval workflow +- Ensures paid customers are verified before access + +## Public Tenant Behavior + +The `public` tenant always bypasses all enrollment rules: + +- No email domain validation +- No invite-only restrictions +- No approval workflow + +This ensures users can always sign up to your application's default tenant without restrictions. + +## Testing + +The plugin includes comprehensive test coverage with 43 test cases. Run tests with: + +```bash +npm test +``` + +Tests require a running SuperTokens core instance. Configure using environment variables: + +- `CORE_BASE_URL`: SuperTokens core URL (default: `http://localhost:3567`) +- `CORE_API_KEY`: API key for core authentication (if required) +- `PORT`: Test server port (default: `3000`) + +### Running SuperTokens Core for Testing + +```bash +# Using Docker +docker run -p 3567:3567 -d registry.supertokens.io/supertokens/supertokens-postgresql + +# Then run tests +npm test +``` + +## Troubleshooting + +### Users Can't Sign Up to Tenant + +**Check:** + +1. Email domain is correctly mapped in `emailDomainToTenantIdMap` +2. Tenant is not in `inviteOnlyTenants` (unless using SAML) +3. Tenant exists in SuperTokens Core + +### Approval Emails Not Sending + +**Check:** + +1. Email delivery is configured in `TenantsPlugin.init()` +2. At least one user has `tenant-admin` role in target tenant +3. SMTP settings are correct + +### Third-Party Login Blocked + +**Check:** + +1. If tenant is invite-only, only SAML providers (`boxy-saml-*`) are allowed +2. Email domain from OAuth provider matches `emailDomainToTenantIdMap` + +## Migration Guide + +If you're adding this plugin to an existing application: + +1. **Existing users are not affected** - Enrollment rules only apply to new signups +2. **Plan your domain mapping** - Map all tenant-specific email domains +3. **Test approval workflow** - Ensure admin users exist before enabling `requiresApprovalTenants` +4. **Communicate changes** - Inform users of new signup restrictions + +## License + +See the main repository for license information. From 07d83e126a276b02eebac2cd4f2af9fbb48ad60e Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Thu, 23 Oct 2025 12:28:05 +0530 Subject: [PATCH 35/36] fix: issues with email sending and improve the email's copy --- .../tenant-enrollment-nodejs/src/plugin.ts | 50 +++++++++---------- .../src/pluginImplementation.ts | 3 +- .../tenants-nodejs/src/defaultEmailService.ts | 38 +++++++------- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/packages/tenant-enrollment-nodejs/src/plugin.ts b/packages/tenant-enrollment-nodejs/src/plugin.ts index 6224615..01317d2 100644 --- a/packages/tenant-enrollment-nodejs/src/plugin.ts +++ b/packages/tenant-enrollment-nodejs/src/plugin.ts @@ -148,8 +148,8 @@ export const init = createPluginInitFunction< logDebugMessage(`wasAdded: ${wasAddedToTenant}`); logDebugMessage(`reason: ${tenantJoiningReason}`); return { - status: "PENDING_APPROVAL" as any, wasAddedToTenant, + ...response, reason: tenantJoiningReason, }; }, @@ -250,13 +250,13 @@ export const init = createPluginInitFunction< input.tenantId, "email" in input ? { - type: "email", - email: input.email, - } + type: "email", + email: input.email, + } : { - type: "phoneNumber", - phoneNumber: input.phoneNumber, - }, + type: "phoneNumber", + phoneNumber: input.phoneNumber, + }, ); logDebugMessage("Reason: " + reason); @@ -316,13 +316,13 @@ export const init = createPluginInitFunction< input.tenantId, "email" in input ? { - type: "email", - email: input.email, - } + type: "email", + email: input.email, + } : { - type: "phoneNumber", - phoneNumber: input.phoneNumber, - }, + type: "phoneNumber", + phoneNumber: input.phoneNumber, + }, ); logDebugMessage("Reason: " + reason); @@ -358,11 +358,11 @@ export const init = createPluginInitFunction< input.tenantId, deviceInfo.phoneNumber !== undefined ? { - phoneNumber: deviceInfo.phoneNumber!, - } + phoneNumber: deviceInfo.phoneNumber!, + } : { - email: deviceInfo.email!, - }, + email: deviceInfo.email!, + }, ); // If this is a signup or its through phone number, we cannot @@ -377,13 +377,13 @@ export const init = createPluginInitFunction< input.tenantId, "email" in deviceInfo ? { - type: "email", - email: deviceInfo.email!, - } + type: "email", + email: deviceInfo.email!, + } : { - type: "phoneNumber", - phoneNumber: deviceInfo.phoneNumber!, - }, + type: "phoneNumber", + phoneNumber: deviceInfo.phoneNumber!, + }, ); logDebugMessage("Reason: " + reason); @@ -414,8 +414,8 @@ export const init = createPluginInitFunction< logDebugMessage(`wasAdded: ${wasAddedToTenant}`); logDebugMessage(`reason: ${tenantJoiningReason}`); return { - status: "PENDING_APPROVAL" as any, wasAddedToTenant, + ...response, reason: tenantJoiningReason, }; }, @@ -493,8 +493,8 @@ export const init = createPluginInitFunction< logDebugMessage(`wasAdded: ${wasAddedToTenant}`); logDebugMessage(`reason: ${tenantJoiningReason}`); return { - status: "PENDING_APPROVAL" as any, wasAddedToTenant, + ...response, reason: tenantJoiningReason, }; }, diff --git a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts index 9e32860..e0e7c99 100644 --- a/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts +++ b/packages/tenant-enrollment-nodejs/src/pluginImplementation.ts @@ -150,7 +150,8 @@ export const getOverrideableTenantFunctionImplementation = ( ); // Send emails to all tenant admins using Promise.all - await Promise.all( + // NOTE: No need to await for all the emails to be sent + Promise.all( adminEmails .filter((email) => email !== undefined) .map(async (email) => { diff --git a/packages/tenants-nodejs/src/defaultEmailService.ts b/packages/tenants-nodejs/src/defaultEmailService.ts index b408af3..fcb6d45 100644 --- a/packages/tenants-nodejs/src/defaultEmailService.ts +++ b/packages/tenants-nodejs/src/defaultEmailService.ts @@ -8,13 +8,13 @@ export class DefaultPluginEmailService implements EmailDeliveryInterface
-

You're Invited!

+

Someone wants to join ${input.tenantId}.

${input.senderEmail} has requested to join ${input.tenantId}

@@ -23,30 +23,30 @@ export class DefaultPluginEmailService implements EmailDeliveryInterface ${ - input.customData?.customMessage - ? ` + input.customData?.customMessage + ? `

"${input.customData.customMessage}"

` - : "" -} + : "" + }
`, - text: ` + text: ` ${input.senderEmail} has requested to join ${input.tenantId} Open Requests: ${input.appUrl}/user/tenants ${input.customData?.customMessage ? `Message: "${input.customData.customMessage}"` : ""} `, - }; + }; - case "TENANT_CREATE_APPROVAL": - return { - subject: "New request to create tenant", - html: ` + case "TENANT_CREATE_APPROVAL": + return { + subject: "New request to create tenant", + html: `

New Notification

@@ -60,15 +60,15 @@ export class DefaultPluginEmailService implements EmailDeliveryInterface
`, - text: ` + text: ` ${input.creatorEmail} has requested to create a new tenant ${input.tenantId} Open Requests: ${input.appUrl}/user/tenants `, - }; + }; - default: - throw new Error("Should never come here"); + default: + throw new Error("Should never come here"); } } From 8dba155e4a79a1788c39f2287123494cde49b25f Mon Sep 17 00:00:00 2001 From: Deepjyoti Barman Date: Thu, 23 Oct 2025 14:33:49 +0530 Subject: [PATCH 36/36] op: add readme for tenant enrollment in react --- packages/tenant-enrollment-react/README.md | 550 ++++++++++++++++++ .../tenants-nodejs/src/defaultEmailService.ts | 36 +- 2 files changed, 568 insertions(+), 18 deletions(-) create mode 100644 packages/tenant-enrollment-react/README.md diff --git a/packages/tenant-enrollment-react/README.md b/packages/tenant-enrollment-react/README.md new file mode 100644 index 0000000..2a0156f --- /dev/null +++ b/packages/tenant-enrollment-react/README.md @@ -0,0 +1,550 @@ +# SuperTokens Plugin Tenant Enrollment + +Add tenant enrollment restrictions and validation to your SuperTokens React application. +This plugin provides user-friendly error handling and UI adjustments for tenant signup restrictions, including invite-only tenants, email domain validation, and identity provider restrictions. + +## Installation + +```bash +npm install @supertokens-plugins/tenant-enrollment-react +``` + +## Prerequisites + +This plugin requires the backend tenant-enrollment plugin to be installed and configured: + +```bash +npm install @supertokens-plugins/tenant-enrollment-nodejs +``` + +> [!IMPORTANT] +> The backend plugin `@supertokens-plugins/tenant-enrollment-nodejs` must be initialized in your SuperTokens Node.js backend configuration for this plugin to function properly. + +## Quick Start + +### Frontend Configuration + +Initialize the plugin in your SuperTokens frontend configuration: + +```typescript +import SuperTokens from "supertokens-auth-react"; +import Session from "supertokens-auth-react/recipe/session"; +import WebAuthn from "supertokens-auth-react/recipe/webauthn"; +import Multitenancy from "supertokens-auth-react/recipe/multitenancy"; +import TenantEnrollmentPlugin from "@supertokens-plugins/tenant-enrollment-react"; + +SuperTokens.init({ + appInfo: { + appName: "Your App", + apiDomain: "http://localhost:3001", + websiteDomain: "http://localhost:3000", + apiBasePath: "/auth", + websiteBasePath: "/auth", + }, + recipeList: [ + Session.init(), + WebAuthn.init(), + Multitenancy.init(), + // your other recipes + ], + experimental: { + plugins: [TenantEnrollmentPlugin.init()], + }, +}); +``` + +## How It Works + +The plugin automatically integrates with your SuperTokens authentication flow to: + +1. **Detect tenant enrollment restrictions** from the backend +2. **Display user-friendly error messages** when signup is blocked +3. **Adjust the UI** based on tenant configuration (e.g., hide signup option for invite-only tenants) +4. **Handle authentication errors gracefully** without breaking the user experience + +### Integration Points + +The plugin works seamlessly with: + +- **WebAuthn (Passkey) Authentication** - Catches and displays enrollment errors during passkey registration +- **Multitenancy Recipe** - Detects invite-only tenants and adjusts the UI accordingly +- **All Authentication Methods** - Works with EmailPassword, ThirdParty, Passwordless, and WebAuthn + +## Features + +### 1. Invite-Only Tenant Detection + +The plugin automatically detects when a tenant is invite-only and adjusts the UI: + +- Hides the "Sign Up" option from the authentication page +- Shows only the "Sign In" option +- Displays appropriate error messages if a user attempts to sign up + +**How it works:** + +- When the multitenancy recipe fetches login methods, the plugin checks for the `isTenantInviteOnly` flag +- If the tenant is invite-only, the authentication page header hides the signup switcher +- Users see a clean, simplified sign-in interface + +### 2. Enrollment Error Handling + +When a user attempts to sign up to a tenant they're not allowed to access, the plugin: + +- **Catches the error** from the backend +- **Displays a user-friendly message** explaining why signup was blocked +- **Provides guidance** on how to proceed (e.g., contact tenant administrators) + +**Supported Error Types:** + +- **Invite-Only Tenant** - "This tenant is invite only and you cannot sign up" +- **Email Domain Restriction** - "Your email domain is not allowed to sign up" +- **Identity Provider Restriction** - "This tenant is invite only and you need to use an approved identity provider" + +### 3. WebAuthn Error Display + +For WebAuthn (passkey) authentication, the plugin enhances the error experience: + +- Intercepts WebAuthn registration errors +- Checks if the error is related to tenant enrollment restrictions +- Displays a custom error message component +- Maintains the standard WebAuthn flow for allowed users + +### 4. Seamless UI Integration + +The plugin uses SuperTokens' component override system to: + +- Maintain consistent styling with your authentication UI +- Integrate error messages naturally into the auth flow +- Preserve all standard SuperTokens functionality +- Support custom themes and styles + +## Configuration Options + +Currently, the plugin works with zero configuration. All enrollment rules are defined in the backend plugin. + +```typescript +TenantEnrollmentPlugin.init(); +``` + +### Advanced: Custom Component Overrides + +You can customize the plugin's behavior by overriding components: + +```typescript +TenantEnrollmentPlugin.init({ + override: (originalImplementation) => ({ + ...originalImplementation, + // Add custom overrides here if needed in the future + }), +}); +``` + +## Error Messages + +The plugin displays these error messages based on backend enrollment restrictions: + +| Scenario | Error Message | +| ----------------------------- | ------------------------------------------------------------------------------ | +| Invite-only tenant | "This tenant is invite only and you cannot sign up" | +| Email domain not allowed | "Your email domain is not allowed to sign up" | +| Identity provider not allowed | "This tenant is invite only and you need to use an approved identity provider" | + +### Default Error Display + +When an enrollment error occurs, users see: + +**Header:** "Signing up to the tenant is disabled" + +**Message:** "Signing up to this tenant is currently blocked. If you think this is a mistake, please reach out to tenant administrators or request an invitation to join the tenant." + +### Customizing Error Messages + +Error messages can be customized by providing translation overrides during SuperTokens initialization: + +```typescript +SuperTokens.init({ + languageTranslations: { + translations: { + en: { + PL_TE_SIGN_UP_BLOCKED_HEADER: "Access Restricted", + PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT: "You cannot create an account for this tenant.", + PL_TE_SIGN_UP_BLOCKED_MESSAGE_SUFFIX: "Please contact your administrator or use an approved sign-up method.", + }, + }, + defaultLanguage: "en", + }, + // ... rest of config +}); +``` + +## Hooks and Utilities + +### usePluginContext Hook + +Access plugin context and translation functions: + +```typescript +import { usePluginContext } from '@supertokens-plugins/tenant-enrollment-react'; + +function MyCustomComponent() { + const { t, pluginConfig } = usePluginContext(); + + return ( +
+

{t('PL_TE_SIGN_UP_BLOCKED_HEADER')}

+

{t('PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT')}

+
+ ); +} +``` + +### Available Translation Keys + +| Key | Default Value | +| ----------------------------------------- | --------------------------------------------------------------------------------- | +| `PL_TE_SIGN_UP_BLOCKED_HEADER` | "Signing up to the tenant is disabled" | +| `PL_TE_SIGN_UP_BLOCKED_MESSAGE_HIGHLIGHT` | "Signing up to this tenant is currently blocked." | +| `PL_TE_SIGN_UP_BLOCKED_MESSAGE_SUFFIX` | "If you think this is a mistake, please reach out to tenant administrators or..." | + +## Components + +### ErrorMessage Component + +The plugin exports an `ErrorMessage` component for displaying enrollment-related errors: + +```typescript +import { ErrorMessage } from '@supertokens-plugins/tenant-enrollment-react'; + +function MyAuthPage() { + const [errorMessage, setErrorMessage] = React.useState(''); + + return ( +
+ {errorMessage && } + {/* Your auth UI */} +
+ ); +} +``` + +**Props:** + +- `message: string` - The error message to display + +**Styling:** + +The component uses semantic error colors from the SuperTokens theme and includes a 12px bottom margin for proper spacing. + +## Authentication Flow Examples + +### Example 1: Email Domain Validation + +**Backend Configuration:** + +```typescript +// Node.js backend +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + "company.com": "tenant-acme", + }, +}); +``` + +**User Experience:** + +1. User navigates to `/auth?tenantId=tenant-acme` +2. User attempts to sign up with `user@gmail.com` +3. Plugin catches the error and displays: "Your email domain is not allowed to sign up" +4. User sees guidance to contact administrators or use the correct email domain + +### Example 2: Invite-Only Tenant + +**Backend Configuration:** + +```typescript +// Node.js backend +TenantEnrollmentPlugin.init({ + inviteOnlyTenants: ["tenant-enterprise"], +}); +``` + +**User Experience:** + +1. User navigates to `/auth?tenantId=tenant-enterprise` +2. Plugin detects tenant is invite-only via login methods API +3. "Sign Up" switcher is automatically hidden +4. User only sees "Sign In" option +5. If user attempts signup anyway (e.g., direct API call), they see: "This tenant is invite only and you cannot sign up" + +### Example 3: WebAuthn with Restrictions + +**Backend Configuration:** + +```typescript +// Node.js backend +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + "verified.com": "tenant-verified", + }, + inviteOnlyTenants: ["tenant-verified"], +}); +``` + +**User Experience:** + +1. User navigates to WebAuthn registration page for `tenant-verified` +2. Plugin detects invite-only status and hides signup option +3. If user tries to create a passkey with `user@other.com`, the plugin displays appropriate error +4. Error message is shown in the WebAuthn error component context + +## Integration with Backend Plugin + +This plugin works in tandem with `@supertokens-plugins/tenant-enrollment-nodejs`. Here's how they communicate: + +### Backend Validation + +The backend plugin validates signup attempts and returns specific error codes: + +```typescript +// Backend plugin checks +if (isTenantInviteOnly(tenantId)) { + throw new Error(NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE.INVITE_ONLY); +} + +if (!isMatchingEmailDomain(tenantId, email)) { + throw new Error(NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE.EMAIL_DOMAIN_NOT_ALLOWED); +} +``` + +### Frontend Handling + +The React plugin catches these errors and displays them: + +```typescript +// Frontend plugin intercepts +try { + await WebAuthn.registerOptions(email); +} catch (error) { + if (isSuperTokensGeneralError(error)) { + // Display error message to user + showErrorMessage(error.message); + } +} +``` + +### Login Methods API + +The backend injects tenant metadata into the login methods response: + +```json +{ + "status": "OK", + "firstFactors": ["emailpassword", "webauthn"], + "isTenantInviteOnly": true +} +``` + +The React plugin reads this flag and adjusts the UI accordingly. + +## Debug Logging + +Enable debug logging to troubleshoot enrollment issues: + +```typescript +import { enableDebugLogs } from "@supertokens-plugins/tenant-enrollment-react"; + +// Enable before SuperTokens.init() +enableDebugLogs(); + +SuperTokens.init({ + // ... your config +}); +``` + +Debug logs include: + +- Tenant invite-only status detection +- Error message parsing +- UI state changes +- Component overrides + +## Use Cases + +### Enterprise Multi-Tenant SaaS + +**Scenario:** Only allow employees with company email addresses to sign up + +**Configuration:** + +Backend: + +```typescript +TenantEnrollmentPlugin.init({ + emailDomainToTenantIdMap: { + "acmecorp.com": "tenant-acme", + }, +}); +``` + +Frontend: + +```typescript +TenantEnrollmentPlugin.init(); +``` + +**Result:** Users with `@acmecorp.com` can sign up to `tenant-acme`. Others see: "Your email domain is not allowed to sign up" + +### Invite-Only Private Tenant + +**Scenario:** Restrict tenant access to explicitly invited users + +**Configuration:** + +Backend: + +```typescript +TenantEnrollmentPlugin.init({ + inviteOnlyTenants: ["tenant-private"], +}); +``` + +Frontend: + +```typescript +TenantEnrollmentPlugin.init(); +``` + +**Result:** + +- Signup option is hidden for `tenant-private` +- Unauthorized signup attempts show: "This tenant is invite only and you cannot sign up" +- Only users with valid invitations can proceed + +### SAML-Only Tenant + +**Scenario:** Enterprise tenant that only allows SAML SSO authentication + +**Configuration:** + +Backend: + +```typescript +TenantEnrollmentPlugin.init({ + inviteOnlyTenants: ["tenant-enterprise"], + // Backend automatically allows "boxy-saml-*" providers +}); +``` + +Frontend: + +```typescript +TenantEnrollmentPlugin.init(); +``` + +**Result:** + +- Email/password signup is hidden +- Google/GitHub OAuth is blocked +- Only SAML providers are allowed +- Non-SAML attempts show: "This tenant is invite only and you need to use an approved identity provider" + +## Troubleshooting + +### Error Messages Not Showing + +**Check:** + +1. Backend plugin is initialized in your Node.js app +2. Backend enrollment rules are configured correctly +3. Frontend plugin is initialized before authentication recipes +4. Browser console for any JavaScript errors + +**Debug:** + +```typescript +import { enableDebugLogs } from "@supertokens-plugins/tenant-enrollment-react"; +enableDebugLogs(); +``` + +### Signup Option Still Visible for Invite-Only Tenant + +**Check:** + +1. Backend `inviteOnlyTenants` array includes the tenant ID +2. Multitenancy recipe is initialized in frontend +3. Tenant ID is correctly passed in the URL: `/auth?tenantId=tenant-id` +4. Login methods API response includes `isTenantInviteOnly: true` + +**Debug:** + +Check the network tab for the login methods API call (`/auth/tenant-id/loginmethods`) and verify the response includes the `isTenantInviteOnly` flag. + +### Error Messages in Wrong Language + +**Solution:** + +Override translations in SuperTokens config: + +```typescript +SuperTokens.init({ + languageTranslations: { + translations: { + es: { + PL_TE_SIGN_UP_BLOCKED_HEADER: "Registro deshabilitado", + // ... other translations + }, + }, + defaultLanguage: "es", + }, +}); +``` + +### WebAuthn Errors Not Caught + +**Check:** + +1. WebAuthn recipe is initialized in frontend config +2. Plugin is initialized after authentication recipes +3. Backend returns errors with `isSuperTokensGeneralError: true` +4. Error messages match `NOT_ALLOWED_TO_SIGNUP_REASON_MESSAGE` values + +## Browser Support + +The plugin supports all modern browsers that support: + +- React 18+ +- ES2020+ +- CSS custom properties +- SuperTokens auth-react + +## TypeScript Support + +The plugin is written in TypeScript and includes full type definitions: + +```typescript +import TenantEnrollmentPlugin, { usePluginContext } from "@supertokens-plugins/tenant-enrollment-react"; + +// Fully typed +const { t, pluginConfig } = usePluginContext(); +``` + +## Migration Guide + +If you're adding this plugin to an existing application: + +1. **Install the frontend plugin** - `npm install @supertokens-plugins/tenant-enrollment-react` +2. **Install the backend plugin** - `npm install @supertokens-plugins/tenant-enrollment-nodejs` +3. **Configure backend rules** - Set up `emailDomainToTenantIdMap`, `inviteOnlyTenants`, etc. +4. **Initialize frontend plugin** - Add to `experimental.plugins` array +5. **Test enrollment flows** - Verify error messages and UI changes work correctly +6. **Update user documentation** - Inform users of enrollment restrictions + +**Backward Compatibility:** + +- Existing users are not affected +- Plugin only affects new signup attempts +- No breaking changes to authentication flow + +## License + +See the main repository for license information. diff --git a/packages/tenants-nodejs/src/defaultEmailService.ts b/packages/tenants-nodejs/src/defaultEmailService.ts index fcb6d45..d938b59 100644 --- a/packages/tenants-nodejs/src/defaultEmailService.ts +++ b/packages/tenants-nodejs/src/defaultEmailService.ts @@ -8,10 +8,10 @@ export class DefaultPluginEmailService implements EmailDeliveryInterface

Someone wants to join ${input.tenantId}.

@@ -23,30 +23,30 @@ export class DefaultPluginEmailService implements EmailDeliveryInterface ${ - input.customData?.customMessage - ? ` + input.customData?.customMessage + ? `

"${input.customData.customMessage}"

` - : "" - } + : "" +}
`, - text: ` + text: ` ${input.senderEmail} has requested to join ${input.tenantId} Open Requests: ${input.appUrl}/user/tenants ${input.customData?.customMessage ? `Message: "${input.customData.customMessage}"` : ""} `, - }; + }; - case "TENANT_CREATE_APPROVAL": - return { - subject: "New request to create tenant", - html: ` + case "TENANT_CREATE_APPROVAL": + return { + subject: "New request to create tenant", + html: `

New Notification

@@ -60,15 +60,15 @@ export class DefaultPluginEmailService implements EmailDeliveryInterface
`, - text: ` + text: ` ${input.creatorEmail} has requested to create a new tenant ${input.tenantId} Open Requests: ${input.appUrl}/user/tenants `, - }; + }; - default: - throw new Error("Should never come here"); + default: + throw new Error("Should never come here"); } }