From a93521b87fd9da4eb27fc7040d22ba5aee3ce13c Mon Sep 17 00:00:00 2001 From: Jacques Ikot Date: Mon, 16 Jun 2025 12:00:42 +0100 Subject: [PATCH] feat: add multi-org domain tracking (#40938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🏢 Multi-Org Domain Tracking System ## 🎯 Problem Statement Users with access to multiple Appsmith organizations often forget the exact domain names of their different workspaces (e.g., `company1.appsmith.com`, `team2.appsmith.com`). This creates friction during login as users need to remember or guess the specific subdomain for each organization they belong to. ## ✨ Solution Overview This PR implements a lightweight domain tracking system that remembers the organization domains users have successfully accessed in the last 30 days. When users visit `login.appsmith.com` or signup pages, they can see and quickly navigate to their recently visited organizations. ## 🏗️ Technical Approach ### Why Cookies Over localStorage? We chose **cookies with `.appsmith.com` domain** over localStorage for the following critical reasons: | Feature | Cookies | localStorage | Winner | |---------|---------|--------------|--------| | Cross-subdomain access | ✅ `.appsmith.com` | ❌ Origin-specific | 🍪 Cookies | | Built-in expiration | ✅ 30-day TTL | ❌ Manual cleanup | 🍪 Cookies | | Storage requirements | ✅ Small domains only | ✅ Large capacity | 🤝 Both adequate | | Browser support | ✅ Universal | ✅ Universal | 🤝 Both good | ### 🛠️ Implementation Details #### Core Utility (`utils/multiOrgDomains.ts`) ```typescript // Key configuration: const COOKIE_NAME = "appsmith_recent_domains"; const EXPIRY_DAYS = 30; const MAX_DOMAINS = 10; interface DomainEntry { domain: string; // e.g., "company.appsmith.com" timestamp: number; // Unix timestamp } ``` #### 📊 Domain Tracking Logic - **⏰ When**: Automatically triggered when users successfully reach `/applications` page - **📝 What**: Current domain (e.g., `company.appsmith.com`) + timestamp - **🔍 Filtering**: Only tracks multi-org domains (excludes `login.appsmith.com`) - **🔄 Deduplication**: Prevents duplicate entries, moves existing domains to top ## Automation /ok-to-test tags="@tag.Authentication, @tag.Sanity" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 935ccef2d320e5c7641b1bd31c39d69068259914 > Cypress dashboard. > Tags: `@tag.Authentication, @tag.Sanity` > Spec: >
Mon, 16 Jun 2025 09:43:36 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - **New Features** - Introduced tracking and display of recently visited organization domains for users, allowing quick access to previously signed-in organizations. - Added a section in the sign-up page to show recent domains with organization names and direct login options. - **Enhancements** - Improved messaging related to user sign-in and account status. - Enabled multi-organization domain tracking on the applications page for better domain management. - **Chores** - Updated internal messaging constants for consistency and future use. --- app/client/src/ce/constants/messages.ts | 2 + .../src/ce/pages/Applications/index.tsx | 4 + app/client/src/pages/UserAuth/SignUp.tsx | 120 +++++++++++++----- app/client/src/utils/multiOrgDomains.ts | 105 +++++++++++++++ 4 files changed, 199 insertions(+), 32 deletions(-) create mode 100644 app/client/src/utils/multiOrgDomains.ts diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index e45425ae9c..1830b12cdd 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -87,6 +87,8 @@ export const ALREADY_HAVE_AN_ACCOUNT = () => `Already have an account?`; export const LOOKING_TO_SELF_HOST = () => "Looking to self-host Appsmith?"; export const VISIT_OUR_DOCS = () => "Visit our docs"; export const ALREADY_USING_APPSMITH = () => `Already using Appsmith?`; +export const USING_APPSMITH = () => `Using Appsmith?`; +export const YOU_VE_ALREADY_SIGNED_INTO = () => `You've already signed into`; export const SIGN_IN_TO_AN_EXISTING_ORGANISATION = () => `Sign in to an existing organisation`; diff --git a/app/client/src/ce/pages/Applications/index.tsx b/app/client/src/ce/pages/Applications/index.tsx index da9669310c..e25b2a0301 100644 --- a/app/client/src/ce/pages/Applications/index.tsx +++ b/app/client/src/ce/pages/Applications/index.tsx @@ -140,6 +140,7 @@ import { GitImportOverrideModal, } from "git"; import OldRepoLimitExceededErrorModal from "pages/Editor/gitSync/RepoLimitExceededErrorModal"; +import { trackCurrentDomain } from "utils/multiOrgDomains"; function GitModals() { const isGitModEnabled = useGitModEnabled(); @@ -1149,6 +1150,9 @@ export class Applications< // Whenever we go back to home page from application page, // we should reset current workspace, as this workspace is not in context anymore this.props.resetCurrentWorkspace(); + + // Track the current domain for multi-org functionality + trackCurrentDomain(); } componentWillUnmount() { diff --git a/app/client/src/pages/UserAuth/SignUp.tsx b/app/client/src/pages/UserAuth/SignUp.tsx index 17cd3f7844..c6ff0937c8 100644 --- a/app/client/src/pages/UserAuth/SignUp.tsx +++ b/app/client/src/pages/UserAuth/SignUp.tsx @@ -26,14 +26,14 @@ import { GOOGLE_RECAPTCHA_KEY_ERROR, LOOKING_TO_SELF_HOST, VISIT_OUR_DOCS, - ALREADY_USING_APPSMITH, SIGN_IN_TO_AN_EXISTING_ORGANISATION, - LOGIN_PAGE_TITLE, + USING_APPSMITH, + YOU_VE_ALREADY_SIGNED_INTO, } from "ee/constants/messages"; import FormTextField from "components/utils/ReduxFormTextField"; import ThirdPartyAuth from "pages/UserAuth/ThirdPartyAuth"; import { FormGroup } from "@appsmith/ads-old"; -import { Button, Link, Callout } from "@appsmith/ads"; +import { Button, Link, Callout, Text } from "@appsmith/ads"; import { isEmail, isStrongPassword, isEmptyString } from "utils/formhelpers"; import type { SignupFormValues } from "pages/UserAuth/helpers"; @@ -66,6 +66,7 @@ import { isLoginHostname } from "utils/cloudBillingUtils"; import { appsmithTelemetry } from "instrumentation"; import { getIsAiAgentInstanceEnabled } from "ee/selectors/aiAgentSelectors"; import { getSafeErrorMessage } from "ee/constants/approvedErrorMessages"; +import { getRecentDomains, isValidAppsmithDomain } from "utils/multiOrgDomains"; declare global { interface Window { @@ -75,6 +76,7 @@ declare global { } } const { cloudHosting, googleRecaptchaSiteKey } = getAppsmithConfigs(); +const recentDomains = getRecentDomains(); const validate = (values: SignupFormValues) => { const errors: SignupFormValues = {}; @@ -94,6 +96,59 @@ const validate = (values: SignupFormValues) => { return errors; }; +const recentDomainsSection = recentDomains.length > 0 && ( +
+
+ {createMessage(YOU_VE_ALREADY_SIGNED_INTO)} +
+ +
+ {recentDomains.map((domain, index) => { + const orgName = domain + .split(".")[0] + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + + const avatarLetter = String.fromCharCode(65 + (index % 26)); + + return ( +
+
+
+ {avatarLetter} +
+
+ + {orgName} + + + {domain} + +
+
+ +
+ ); + })} +
+
+); + type SignUpFormProps = InjectedFormProps< SignupFormValues, { emailValue: string } @@ -200,33 +255,34 @@ export function SignUp(props: SignUpFormProps) { } }; + const cloudBillingSignIn = ( +
+ {createMessage(USING_APPSMITH)} + + {createMessage(SIGN_IN_TO_AN_EXISTING_ORGANISATION)} + +
+ ); + const footerSection = ( <> - {isCloudBillingEnabled && isHostnameEqualtoLogin ? ( -
- {createMessage(ALREADY_USING_APPSMITH)} - - {createMessage(SIGN_IN_TO_AN_EXISTING_ORGANISATION)} - -
- ) : ( -
- {createMessage(ALREADY_HAVE_AN_ACCOUNT)}  - - {createMessage(SIGNUP_PAGE_LOGIN_LINK_TEXT)} - -
- )} +
+ {createMessage(ALREADY_HAVE_AN_ACCOUNT)}  + + {createMessage(SIGNUP_PAGE_LOGIN_LINK_TEXT)} + +
+ {cloudHosting && !isAiAgentInstanceEnabled && ( <> or @@ -249,10 +305,8 @@ export function SignUp(props: SignUpFormProps) { return ( {htmlPageTitle} @@ -313,6 +367,8 @@ export function SignUp(props: SignUpFormProps) { )} + {isCloudBillingEnabled && isHostnameEqualtoLogin && cloudBillingSignIn} + {isCloudBillingEnabled && isHostnameEqualtoLogin && recentDomainsSection} ); } diff --git a/app/client/src/utils/multiOrgDomains.ts b/app/client/src/utils/multiOrgDomains.ts new file mode 100644 index 0000000000..aaf70057c9 --- /dev/null +++ b/app/client/src/utils/multiOrgDomains.ts @@ -0,0 +1,105 @@ +const COOKIE_NAME = "appsmith_recent_domains"; +const EXPIRY_DAYS = 30; +const MAX_DOMAINS = 10; + +interface DomainEntry { + domain: string; + timestamp: number; +} + +function getCurrentDomain(): string { + return window.location.hostname; +} + +function isValidAppsmithDomain(domain: string): boolean { + return ( + domain.endsWith(".appsmith.com") && + !domain.startsWith("login.") && + !domain.startsWith("release.") && + !domain.startsWith("dev.") && + /^[a-z0-9-]+\.appsmith\.com$/i.test(domain) + ); +} + +function isMultiOrgDomain(): boolean { + const hostname = getCurrentDomain(); + + return isValidAppsmithDomain(hostname); +} + +function getStoredDomains(): DomainEntry[] { + const cookieValue = getCookie(COOKIE_NAME); + + if (!cookieValue) return []; + + try { + return JSON.parse(decodeURIComponent(cookieValue)); + } catch (e) { + return []; + } +} + +function storeDomains(domains: DomainEntry[]): void { + const expires = new Date(); + + expires.setDate(expires.getDate() + EXPIRY_DAYS); + + const cookieValue = encodeURIComponent(JSON.stringify(domains)); + + document.cookie = `${COOKIE_NAME}=${cookieValue}; expires=${expires.toUTCString()}; domain=.appsmith.com; path=/; SameSite=Lax`; +} + +function getCookie(name: string): string | null { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + + if (parts.length === 2) { + return parts.pop()?.split(";").shift() || null; + } + + return null; +} + +export function trackCurrentDomain(): void { + if (!isMultiOrgDomain()) { + return; + } + + const currentDomain = getCurrentDomain(); + const currentTime = Date.now(); + + let domains = getStoredDomains(); + + domains = domains.filter((entry) => entry.domain !== currentDomain); + + domains.unshift({ + domain: currentDomain, + timestamp: currentTime, + }); + + domains = domains.slice(0, MAX_DOMAINS); + + const thirtyDaysAgo = currentTime - 30 * 24 * 60 * 60 * 1000; + + domains = domains.filter((entry) => entry.timestamp > thirtyDaysAgo); + + storeDomains(domains); +} + +export function getRecentDomains(): string[] { + const domains = getStoredDomains(); + + const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; + + return domains + .filter((entry) => entry.timestamp > thirtyDaysAgo) + .map((entry) => entry.domain) + .filter((domain) => domain !== getCurrentDomain()) + .filter((domain) => isValidAppsmithDomain(domain)); +} + +export function clearRecentDomains(): void { + document.cookie = `${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; domain=.appsmith.com; path=/;`; +} + +export { isValidAppsmithDomain };