feat: add multi-org domain tracking (#40938)

# 🏢 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"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/15676451597>
> Commit: 935ccef2d320e5c7641b1bd31c39d69068259914
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=15676451597&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Authentication, @tag.Sanity`
> Spec:
> <hr>Mon, 16 Jun 2025 09:43:36 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jacques Ikot 2025-06-16 12:00:42 +01:00 committed by GitHub
parent 7eadf893df
commit a93521b87f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 199 additions and 32 deletions

View File

@ -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 LOOKING_TO_SELF_HOST = () => "Looking to self-host Appsmith?";
export const VISIT_OUR_DOCS = () => "Visit our docs"; export const VISIT_OUR_DOCS = () => "Visit our docs";
export const ALREADY_USING_APPSMITH = () => `Already using Appsmith?`; 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 = () => export const SIGN_IN_TO_AN_EXISTING_ORGANISATION = () =>
`Sign in to an existing organisation`; `Sign in to an existing organisation`;

View File

@ -140,6 +140,7 @@ import {
GitImportOverrideModal, GitImportOverrideModal,
} from "git"; } from "git";
import OldRepoLimitExceededErrorModal from "pages/Editor/gitSync/RepoLimitExceededErrorModal"; import OldRepoLimitExceededErrorModal from "pages/Editor/gitSync/RepoLimitExceededErrorModal";
import { trackCurrentDomain } from "utils/multiOrgDomains";
function GitModals() { function GitModals() {
const isGitModEnabled = useGitModEnabled(); const isGitModEnabled = useGitModEnabled();
@ -1149,6 +1150,9 @@ export class Applications<
// Whenever we go back to home page from application page, // Whenever we go back to home page from application page,
// we should reset current workspace, as this workspace is not in context anymore // we should reset current workspace, as this workspace is not in context anymore
this.props.resetCurrentWorkspace(); this.props.resetCurrentWorkspace();
// Track the current domain for multi-org functionality
trackCurrentDomain();
} }
componentWillUnmount() { componentWillUnmount() {

View File

@ -26,14 +26,14 @@ import {
GOOGLE_RECAPTCHA_KEY_ERROR, GOOGLE_RECAPTCHA_KEY_ERROR,
LOOKING_TO_SELF_HOST, LOOKING_TO_SELF_HOST,
VISIT_OUR_DOCS, VISIT_OUR_DOCS,
ALREADY_USING_APPSMITH,
SIGN_IN_TO_AN_EXISTING_ORGANISATION, SIGN_IN_TO_AN_EXISTING_ORGANISATION,
LOGIN_PAGE_TITLE, USING_APPSMITH,
YOU_VE_ALREADY_SIGNED_INTO,
} from "ee/constants/messages"; } from "ee/constants/messages";
import FormTextField from "components/utils/ReduxFormTextField"; import FormTextField from "components/utils/ReduxFormTextField";
import ThirdPartyAuth from "pages/UserAuth/ThirdPartyAuth"; import ThirdPartyAuth from "pages/UserAuth/ThirdPartyAuth";
import { FormGroup } from "@appsmith/ads-old"; 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 { isEmail, isStrongPassword, isEmptyString } from "utils/formhelpers";
import type { SignupFormValues } from "pages/UserAuth/helpers"; import type { SignupFormValues } from "pages/UserAuth/helpers";
@ -66,6 +66,7 @@ import { isLoginHostname } from "utils/cloudBillingUtils";
import { appsmithTelemetry } from "instrumentation"; import { appsmithTelemetry } from "instrumentation";
import { getIsAiAgentInstanceEnabled } from "ee/selectors/aiAgentSelectors"; import { getIsAiAgentInstanceEnabled } from "ee/selectors/aiAgentSelectors";
import { getSafeErrorMessage } from "ee/constants/approvedErrorMessages"; import { getSafeErrorMessage } from "ee/constants/approvedErrorMessages";
import { getRecentDomains, isValidAppsmithDomain } from "utils/multiOrgDomains";
declare global { declare global {
interface Window { interface Window {
@ -75,6 +76,7 @@ declare global {
} }
} }
const { cloudHosting, googleRecaptchaSiteKey } = getAppsmithConfigs(); const { cloudHosting, googleRecaptchaSiteKey } = getAppsmithConfigs();
const recentDomains = getRecentDomains();
const validate = (values: SignupFormValues) => { const validate = (values: SignupFormValues) => {
const errors: SignupFormValues = {}; const errors: SignupFormValues = {};
@ -94,6 +96,59 @@ const validate = (values: SignupFormValues) => {
return errors; return errors;
}; };
const recentDomainsSection = recentDomains.length > 0 && (
<div className="mt-12">
<div className="mb-2">
<Text kind="body-m">{createMessage(YOU_VE_ALREADY_SIGNED_INTO)}</Text>
</div>
<div className="max-h-48 overflow-y-auto border border-gray-200 rounded-md py-4 px-3">
{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 (
<div
className="flex items-center justify-between p-1 mb-3"
key={domain}
>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-[color:var(--ads-color-background-secondary)] rounded-full flex items-center justify-center text-gray-600 font-light text-sm">
{avatarLetter}
</div>
<div className="flex flex-col">
<span className="text-md font-semibold text-gray-700">
{orgName}
</span>
<span className="text-xs font-light text-gray-500">
{domain}
</span>
</div>
</div>
<Button
className="px-4 py-2 text-sm"
kind="secondary"
onClick={() => {
if (isValidAppsmithDomain(domain)) {
window.location.href = `https://${domain}/user/login`;
}
}}
size="md"
>
Open
</Button>
</div>
);
})}
</div>
</div>
);
type SignUpFormProps = InjectedFormProps< type SignUpFormProps = InjectedFormProps<
SignupFormValues, SignupFormValues,
{ emailValue: string } { emailValue: string }
@ -200,11 +255,9 @@ export function SignUp(props: SignUpFormProps) {
} }
}; };
const footerSection = ( const cloudBillingSignIn = (
<> <div className="flex flex-row text-[color:var(--ads-v2\-color-fg)] text-[14px] gap-1">
{isCloudBillingEnabled && isHostnameEqualtoLogin ? ( <Text kind="body-m">{createMessage(USING_APPSMITH)}</Text>
<div className="px-2 flex flex-col items-center justify-center text-center text-[color:var(--ads-v2\-color-fg)] text-[14px]">
{createMessage(ALREADY_USING_APPSMITH)}
<Link <Link
className="t--sign-up t--signup-link" className="t--sign-up t--signup-link"
kind="primary" kind="primary"
@ -214,7 +267,10 @@ export function SignUp(props: SignUpFormProps) {
{createMessage(SIGN_IN_TO_AN_EXISTING_ORGANISATION)} {createMessage(SIGN_IN_TO_AN_EXISTING_ORGANISATION)}
</Link> </Link>
</div> </div>
) : ( );
const footerSection = (
<>
<div className="px-2 flex align-center justify-center text-center text-[color:var(--ads-v2\-color-fg)] text-[14px]"> <div className="px-2 flex align-center justify-center text-center text-[color:var(--ads-v2\-color-fg)] text-[14px]">
{createMessage(ALREADY_HAVE_AN_ACCOUNT)}&nbsp; {createMessage(ALREADY_HAVE_AN_ACCOUNT)}&nbsp;
<Link <Link
@ -226,7 +282,7 @@ export function SignUp(props: SignUpFormProps) {
{createMessage(SIGNUP_PAGE_LOGIN_LINK_TEXT)} {createMessage(SIGNUP_PAGE_LOGIN_LINK_TEXT)}
</Link> </Link>
</div> </div>
)}
{cloudHosting && !isAiAgentInstanceEnabled && ( {cloudHosting && !isAiAgentInstanceEnabled && (
<> <>
<OrWithLines>or</OrWithLines> <OrWithLines>or</OrWithLines>
@ -249,10 +305,8 @@ export function SignUp(props: SignUpFormProps) {
return ( return (
<Container <Container
footer={footerSection} footer={!isCloudBillingEnabled && footerSection}
title={createMessage( title={createMessage(SIGNUP_PAGE_TITLE)}
isAiAgentInstanceEnabled ? SIGNUP_PAGE_TITLE : LOGIN_PAGE_TITLE,
)}
> >
<Helmet> <Helmet>
<title>{htmlPageTitle}</title> <title>{htmlPageTitle}</title>
@ -313,6 +367,8 @@ export function SignUp(props: SignUpFormProps) {
</FormActions> </FormActions>
</SpacedSubmitForm> </SpacedSubmitForm>
)} )}
{isCloudBillingEnabled && isHostnameEqualtoLogin && cloudBillingSignIn}
{isCloudBillingEnabled && isHostnameEqualtoLogin && recentDomainsSection}
</Container> </Container>
); );
} }

View File

@ -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 };