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:
parent
7eadf893df
commit
a93521b87f
|
|
@ -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`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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,33 +255,34 @@ export function SignUp(props: SignUpFormProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cloudBillingSignIn = (
|
||||||
|
<div className="flex flex-row text-[color:var(--ads-v2\-color-fg)] text-[14px] gap-1">
|
||||||
|
<Text kind="body-m">{createMessage(USING_APPSMITH)}</Text>
|
||||||
|
<Link
|
||||||
|
className="t--sign-up t--signup-link"
|
||||||
|
kind="primary"
|
||||||
|
target="_self"
|
||||||
|
to={ORG_LOGIN_PATH}
|
||||||
|
>
|
||||||
|
{createMessage(SIGN_IN_TO_AN_EXISTING_ORGANISATION)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const footerSection = (
|
const footerSection = (
|
||||||
<>
|
<>
|
||||||
{isCloudBillingEnabled && isHostnameEqualtoLogin ? (
|
<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 flex-col items-center justify-center text-center text-[color:var(--ads-v2\-color-fg)] text-[14px]">
|
{createMessage(ALREADY_HAVE_AN_ACCOUNT)}
|
||||||
{createMessage(ALREADY_USING_APPSMITH)}
|
<Link
|
||||||
<Link
|
className="t--sign-up t--signup-link"
|
||||||
className="t--sign-up t--signup-link"
|
kind="primary"
|
||||||
kind="primary"
|
target="_self"
|
||||||
target="_self"
|
to={AUTH_LOGIN_URL}
|
||||||
to={ORG_LOGIN_PATH}
|
>
|
||||||
>
|
{createMessage(SIGNUP_PAGE_LOGIN_LINK_TEXT)}
|
||||||
{createMessage(SIGN_IN_TO_AN_EXISTING_ORGANISATION)}
|
</Link>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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)}
|
|
||||||
<Link
|
|
||||||
className="t--sign-up t--signup-link"
|
|
||||||
kind="primary"
|
|
||||||
target="_self"
|
|
||||||
to={AUTH_LOGIN_URL}
|
|
||||||
>
|
|
||||||
{createMessage(SIGNUP_PAGE_LOGIN_LINK_TEXT)}
|
|
||||||
</Link>
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
app/client/src/utils/multiOrgDomains.ts
Normal file
105
app/client/src/utils/multiOrgDomains.ts
Normal 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 };
|
||||||
Loading…
Reference in New Issue
Block a user