From 98c80bd66b62cd8bf4adb25f1e136df754580900 Mon Sep 17 00:00:00 2001 From: felixkob Date: Wed, 3 Jun 2026 23:19:57 +0300 Subject: [PATCH 1/7] =?UTF-8?q?feat(accounts):=20multi-tenancy=20support?= =?UTF-8?q?=20=E2=80=94=20accounts=20module=20+=20dynamic=20active-account?= =?UTF-8?q?=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `base44.accounts` module: getActiveAccountId, switchAccount, listMine, create, update, listMembers, invite, acceptInvite, changeMemberRole, removeMember, transferOwnership, and billing.{listPlans, startCheckout} mapping to the backend /api/apps/{appId}/accounts/... routes. - Send X-Active-Account-Id per request, read from the URL path (the canonical account source) so account-scoped reads/writes stay isolated to the current tenant even after a client-side (Link/useNavigate) account switch. No-op for single-tenant apps / Node. - Wire accounts into the client + Base44Client type; re-export account types. - Add node-safe unit tests for the module's HTTP surface. Co-authored-by: Cursor --- src/client.ts | 2 + src/client.types.ts | 3 + src/index.ts | 13 ++++ src/modules/accounts.ts | 111 ++++++++++++++++++++++++++++++ src/modules/accounts.types.ts | 124 ++++++++++++++++++++++++++++++++++ src/utils/axios-client.ts | 11 ++- src/utils/common.ts | 14 ++++ tests/unit/accounts.test.ts | 58 ++++++++++++++++ 8 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 src/modules/accounts.ts create mode 100644 src/modules/accounts.types.ts create mode 100644 tests/unit/accounts.test.ts diff --git a/src/client.ts b/src/client.ts index a028f33..3841573 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,6 +12,7 @@ import { createFunctionsModule } from "./modules/functions.js"; import { createAgentsModule } from "./modules/agents.js"; import { createAppLogsModule } from "./modules/app-logs.js"; import { createUsersModule } from "./modules/users.js"; +import { createAccountsModule } from "./modules/accounts.js"; import { RoomsSocket, RoomsSocketConfig } from "./utils/socket-utils.js"; import type { Base44Client, @@ -192,6 +193,7 @@ export function createClient(config: CreateClientConfig): Base44Client { }), appLogs: createAppLogsModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), + accounts: createAccountsModule(axiosClient, appId), analytics: createAnalyticsModule({ axiosClient, serverUrl, diff --git a/src/client.types.ts b/src/client.types.ts index 6b4c9c5..ad2c471 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -10,6 +10,7 @@ import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; import type { AnalyticsModule } from "./modules/analytics.types.js"; +import type { AccountsModule } from "./modules/accounts.types.js"; /** * Options for creating a Base44 client. @@ -85,6 +86,8 @@ export interface CreateClientConfig { * Provides access to all SDK modules for interacting with the app. */ export interface Base44Client { + /** {@link AccountsModule | Accounts module} for multi-tenancy (accounts, members, billing). */ + accounts: AccountsModule; /** {@link AgentsModule | Agents module} for managing AI agent conversations. */ agents: AgentsModule; /** {@link AnalyticsModule | Analytics module} for tracking custom events in your app. */ diff --git a/src/index.ts b/src/index.ts index bc531d8..ae7fe8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,19 @@ export type { export type { AppLogsModule } from "./modules/app-logs.types.js"; +export type { + AccountsModule, + Account, + AccountMembership, + AccountPlan, + AccountRole, + AssignableAccountRole, + AccountStatus, + AccountMembershipStatus, + MyAccountsResponse, + CheckoutSession, +} from "./modules/accounts.types.js"; + export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js"; export type { diff --git a/src/modules/accounts.ts b/src/modules/accounts.ts new file mode 100644 index 0000000..5a6651a --- /dev/null +++ b/src/modules/accounts.ts @@ -0,0 +1,111 @@ +import { AxiosInstance } from "axios"; + +import { getActiveAccountIdFromPath } from "../utils/common.js"; +import type { + Account, + AccountMembership, + AccountPlan, + AccountsModule, + AssignableAccountRole, + CheckoutSession, + MyAccountsResponse, +} from "./accounts.types.js"; + +/** + * Creates the accounts module (multi-tenancy) for the Base44 SDK. + * + * @param axios - Axios instance (responses are unwrapped to data). + * @param appId - Application ID. + * @returns The accounts module. + * @internal + */ +export function createAccountsModule( + axios: AxiosInstance, + appId: string +): AccountsModule { + const base = `/apps/${appId}/accounts`; + const enc = encodeURIComponent; + + return { + getActiveAccountId(): string | undefined { + return getActiveAccountIdFromPath(); + }, + + switchAccount(accountId: string, subPath = ""): void { + if (typeof window === "undefined") return; + const clean = subPath.replace(/^\/+/, ""); + window.location.assign(`/${accountId}${clean ? `/${clean}` : "/"}`); + }, + + async listMine(): Promise { + return axios.get(`${base}/me`); + }, + + async create(params: { + name: string; + data?: Record; + }): Promise { + return axios.post(base, params); + }, + + async update( + accountId: string, + params: { name?: string; data?: Record } + ): Promise { + return axios.patch(`${base}/${accountId}`, params); + }, + + async listMembers(accountId: string): Promise { + return axios.get(`${base}/${accountId}/members`); + }, + + async invite( + accountId: string, + email: string, + role: AssignableAccountRole = "member" + ): Promise { + return axios.post(`${base}/${accountId}/invites`, { email, role }); + }, + + async acceptInvite(accountId: string): Promise { + return axios.post(`${base}/${accountId}/accept`, {}); + }, + + async changeMemberRole( + accountId: string, + email: string, + role: AssignableAccountRole + ): Promise { + return axios.patch(`${base}/${accountId}/members/${enc(email)}/role`, { + role, + }); + }, + + async removeMember( + accountId: string, + email: string + ): Promise<{ removed: boolean }> { + return axios.delete(`${base}/${accountId}/members/${enc(email)}`); + }, + + async transferOwnership( + accountId: string, + email: string + ): Promise<{ transferred: boolean }> { + return axios.post(`${base}/${accountId}/transfer-ownership`, { email }); + }, + + billing: { + async listPlans(accountId: string): Promise { + return axios.get(`${base}/${accountId}/billing/plans`); + }, + + async startCheckout( + accountId: string, + params: { plan_id: string; success_url: string; cancel_url: string } + ): Promise { + return axios.post(`${base}/${accountId}/billing/checkout`, params); + }, + }, + }; +} diff --git a/src/modules/accounts.types.ts b/src/modules/accounts.types.ts new file mode 100644 index 0000000..5761597 --- /dev/null +++ b/src/modules/accounts.types.ts @@ -0,0 +1,124 @@ +/** + * Types for the {@link AccountsModule | accounts} module (multi-tenancy). + * + * An Account groups the app's end-users into an isolated tenant (a company, + * team, or organization). Users join accounts via membership and act inside one + * active account at a time. Account-scoped entities are transparently isolated + * to the active account (carried by the `X-Active-Account-Id` header, derived + * from the `//...` URL path). + */ + +/** Account-management role. Distinct from the app's business roles. */ +export type AccountRole = "owner" | "admin" | "member"; + +/** Assignable (non-owner) role used for invites/role changes. */ +export type AssignableAccountRole = "admin" | "member"; + +export type AccountStatus = "active" | "suspended"; +export type AccountMembershipStatus = "pending" | "active"; + +/** An account (tenant) within the app. */ +export interface Account { + id: string; + app_id: string; + name: string; + status: AccountStatus; + plan_id?: string | null; + billing_status?: string; + /** The current user's role in this account (present on `listMine()` results). */ + my_role?: AccountRole; + /** Builder-defined custom fields. */ + data?: Record; + created_date?: string; +} + +/** The accounts the current user belongs to, plus the active one. */ +export interface MyAccountsResponse { + accounts: Account[]; + active_account_id: string | null; +} + +/** A user's membership in an account. */ +export interface AccountMembership { + id: string; + account_id: string; + email: string; + role: AccountRole; + status: AccountMembershipStatus; +} + +/** A subscription plan/tier offered to accounts. */ +export interface AccountPlan { + id: string; + name: string; + description?: string | null; + price_amount: number; + currency: string; + interval: "month" | "year"; + is_active: boolean; +} + +/** A provider checkout session. */ +export interface CheckoutSession { + url: string; + session_id: string; +} + +/** + * The accounts module — manage multi-tenancy ("Accounts") from inside the app. + * + * Access via `base44.accounts`. Available when the app has multi-tenancy enabled. + */ +export interface AccountsModule { + /** The active account id, read from the current URL path (or `undefined`). */ + getActiveAccountId(): string | undefined; + /** + * Switch the active account by navigating to its folder (`//...`). + * A full navigation re-roots the app so all data follows the new account. + * @param accountId - The account to switch to. + * @param subPath - Optional in-account route to land on (defaults to the root). + */ + switchAccount(accountId: string, subPath?: string): void; + /** List the accounts the current user belongs to, plus the active one. */ + listMine(): Promise; + /** Create a new account; the current user becomes its owner. */ + create(params: { name: string; data?: Record }): Promise; + /** Rename and/or update an account's custom fields (managers only). */ + update( + accountId: string, + params: { name?: string; data?: Record } + ): Promise; + /** List an account's members (any active member). */ + listMembers(accountId: string): Promise; + /** Invite a user by email to an account (managers only). */ + invite( + accountId: string, + email: string, + role?: AssignableAccountRole + ): Promise; + /** Accept a pending invite to an account for the current user. */ + acceptInvite(accountId: string): Promise; + /** Change a member's role (managers only; not for the owner). */ + changeMemberRole( + accountId: string, + email: string, + role: AssignableAccountRole + ): Promise; + /** Remove a member from an account (managers only; not the owner). */ + removeMember(accountId: string, email: string): Promise<{ removed: boolean }>; + /** Transfer ownership to another active member (owner only). */ + transferOwnership( + accountId: string, + email: string + ): Promise<{ transferred: boolean }>; + /** Per-account billing. */ + billing: { + /** List the active plans available to this account. */ + listPlans(accountId: string): Promise; + /** Start a subscription checkout session for a plan. */ + startCheckout( + accountId: string, + params: { plan_id: string; success_url: string; cancel_url: string } + ): Promise; + }; +} diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index 957835c..cb912c1 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { isInIFrame } from "./common.js"; +import { getActiveAccountIdFromPath, isInIFrame } from "./common.js"; import { v4 as uuidv4 } from "uuid"; import type { Base44ErrorJSON } from "./axios-client.types.js"; @@ -176,6 +176,15 @@ export function createAxiosClient({ client.interceptors.request.use((config) => { if (typeof window !== "undefined") { config.headers.set("X-Origin-URL", window.location.href); + // Multi-tenancy: forward the active account (from the URL path) per request + // so account-scoped reads/writes stay isolated to the current tenant even + // after a client-side account switch. The path is the canonical source, so + // it overrides any stale default header (e.g. one frozen at module load); + // no-op for single-tenant apps (no account segment in the path). + const activeAccountId = getActiveAccountIdFromPath(); + if (activeAccountId) { + config.headers.set("X-Active-Account-Id", activeAccountId); + } } const requestId = uuidv4(); (config as any).requestId = requestId; diff --git a/src/utils/common.ts b/src/utils/common.ts index 61d8d17..c69e885 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,6 +1,20 @@ export const isNode = typeof window === "undefined"; export const isInIFrame = !isNode && window.self !== window.top; +// Multi-tenancy: apps are served under `//` where the first +// path segment is a 24-hex Mongo ObjectId. Read it at request time so the active +// account is always current — even after client-side (Link/useNavigate) account +// switches that don't reload the module. +const ACCOUNT_ID_RE = /^[a-f0-9]{24}$/; + +export function getActiveAccountIdFromPath(): string | undefined { + if (isNode) return undefined; + const firstSegment = window.location.pathname.split("/").filter(Boolean)[0]; + return firstSegment && ACCOUNT_ID_RE.test(firstSegment) + ? firstSegment + : undefined; +} + export const generateUuid = () => { return ( Math.random().toString(36).substring(2, 15) + diff --git a/tests/unit/accounts.test.ts b/tests/unit/accounts.test.ts new file mode 100644 index 0000000..b869a47 --- /dev/null +++ b/tests/unit/accounts.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { createClient } from "../../src/index.ts"; + +describe("Accounts module", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const ACCT = "a".repeat(24); + let base44: ReturnType; + let scope: nock.Scope; + + beforeEach(() => { + base44 = createClient({ serverUrl, appId }); + scope = nock(serverUrl); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test("listMine GETs /accounts/me", async () => { + const payload = { accounts: [{ id: ACCT, name: "Acme", my_role: "owner" }], active_account_id: ACCT }; + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, payload); + const res = await base44.accounts.listMine(); + expect(res).toEqual(payload); + expect(scope.isDone()).toBe(true); + }); + + test("create POSTs the account name", async () => { + scope.post(`/api/apps/${appId}/accounts`, { name: "Acme" }).reply(200, { id: ACCT, name: "Acme" }); + const res = await base44.accounts.create({ name: "Acme" }); + expect(res.id).toBe(ACCT); + expect(scope.isDone()).toBe(true); + }); + + test("invite POSTs email + role and url-encodes member email on role change", async () => { + scope.post(`/api/apps/${appId}/accounts/${ACCT}/invites`, { email: "a@b.com", role: "admin" }).reply(200, {}); + await base44.accounts.invite(ACCT, "a@b.com", "admin"); + scope.patch(`/api/apps/${appId}/accounts/${ACCT}/members/a%2Bx%40b.com/role`, { role: "member" }).reply(200, {}); + await base44.accounts.changeMemberRole(ACCT, "a+x@b.com", "member"); + expect(scope.isDone()).toBe(true); + }); + + test("billing.listPlans GETs the account plans", async () => { + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/plans`).reply(200, []); + const res = await base44.accounts.billing.listPlans(ACCT); + expect(res).toEqual([]); + expect(scope.isDone()).toBe(true); + }); + + // The active-account behavior (getActiveAccountId + the per-request + // X-Active-Account-Id header) is browser-only — `getActiveAccountIdFromPath` + // is gated on a module-load `typeof window` check, so it is exercised in the + // browser/app context, not this node-environment test suite. + test("getActiveAccountId returns undefined outside a browser", () => { + expect(base44.accounts.getActiveAccountId()).toBeUndefined(); + }); +}); From fb1867db99e746ec187f3fe3c581ffa5d5000293 Mon Sep 17 00:00:00 2001 From: felixkob Date: Thu, 4 Jun 2026 10:33:06 +0300 Subject: [PATCH 2/7] fix(accounts): never read the app id as the active account from the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the sandbox/preview the app is served under // (the dev-server base path), so getActiveAccountIdFromPath returned the app id and the request interceptor sent it as X-Active-Account-Id — the server then failed the membership lookup and account-scoped reads got no active account (the app hung on load). Pass the appId through createClient -> createAxiosClient and skip a leading app-id segment, reading the account from the next segment instead. Single-tenant and production multi-tenant (served under //) are unaffected. Co-authored-by: Cursor --- src/client.ts | 4 ++++ src/modules/accounts.ts | 2 +- src/utils/axios-client.ts | 7 +++++-- src/utils/common.ts | 17 ++++++++++++----- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/client.ts b/src/client.ts index 3841573..304f207 100644 --- a/src/client.ts +++ b/src/client.ts @@ -112,6 +112,7 @@ export function createClient(config: CreateClientConfig): Base44Client { baseURL: `${serverUrl}/api`, headers, token, + appId: String(appId), onError: options?.onError, }); @@ -119,6 +120,7 @@ export function createClient(config: CreateClientConfig): Base44Client { baseURL: `${serverUrl}/api`, headers: functionHeaders, token, + appId: String(appId), interceptResponses: false, onError: options?.onError, }); @@ -132,6 +134,7 @@ export function createClient(config: CreateClientConfig): Base44Client { baseURL: `${serverUrl}/api`, headers: serviceRoleHeaders, token: serviceToken, + appId: String(appId), onError: options?.onError, }); @@ -139,6 +142,7 @@ export function createClient(config: CreateClientConfig): Base44Client { baseURL: `${serverUrl}/api`, headers: functionHeaders, token: serviceToken, + appId: String(appId), interceptResponses: false, }); diff --git a/src/modules/accounts.ts b/src/modules/accounts.ts index 5a6651a..04d764e 100644 --- a/src/modules/accounts.ts +++ b/src/modules/accounts.ts @@ -28,7 +28,7 @@ export function createAccountsModule( return { getActiveAccountId(): string | undefined { - return getActiveAccountIdFromPath(); + return getActiveAccountIdFromPath(appId); }, switchAccount(accountId: string, subPath = ""): void { diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index cb912c1..810e447 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -149,12 +149,14 @@ export function createAxiosClient({ baseURL, headers = {}, token, + appId, interceptResponses = true, onError, }: { baseURL: string; headers?: Record; token?: string; + appId?: string; interceptResponses?: boolean; onError?: (error: Error) => void; }) { @@ -180,8 +182,9 @@ export function createAxiosClient({ // so account-scoped reads/writes stay isolated to the current tenant even // after a client-side account switch. The path is the canonical source, so // it overrides any stale default header (e.g. one frozen at module load); - // no-op for single-tenant apps (no account segment in the path). - const activeAccountId = getActiveAccountIdFromPath(); + // no-op for single-tenant apps (no account segment in the path). The app id + // is passed so the sandbox base path (//) is never read as an account. + const activeAccountId = getActiveAccountIdFromPath(appId); if (activeAccountId) { config.headers.set("X-Active-Account-Id", activeAccountId); } diff --git a/src/utils/common.ts b/src/utils/common.ts index c69e885..9a40e46 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -7,12 +7,19 @@ export const isInIFrame = !isNode && window.self !== window.top; // switches that don't reload the module. const ACCOUNT_ID_RE = /^[a-f0-9]{24}$/; -export function getActiveAccountIdFromPath(): string | undefined { +/** + * The active account id from the URL, or undefined. + * + * In the sandbox/preview the app is served under `//` (the dev-server base + * path), so the first segment is the app id — its own base, NOT an account. When + * `appId` is supplied and matches the leading segment, it's skipped so the app id + * is never sent as an account id; the account, if any, is the next segment. + */ +export function getActiveAccountIdFromPath(appId?: string): string | undefined { if (isNode) return undefined; - const firstSegment = window.location.pathname.split("/").filter(Boolean)[0]; - return firstSegment && ACCOUNT_ID_RE.test(firstSegment) - ? firstSegment - : undefined; + const segments = window.location.pathname.split("/").filter(Boolean); + const candidate = appId && segments[0] === appId ? segments[1] : segments[0]; + return candidate && ACCOUNT_ID_RE.test(candidate) ? candidate : undefined; } export const generateUuid = () => { From f30dbed2a39c6f54c7ac54cb5581c20f3892d19b Mon Sep 17 00:00:00 2001 From: felixkob Date: Thu, 4 Jun 2026 11:21:29 +0300 Subject: [PATCH 3/7] fix(accounts): drive active account from stored client state, not the URL path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces getActiveAccountIdFromPath (which read the first URL segment and broke in the sandbox where the app is served under a non-account base path) with localStorage-backed state keyed per app (base44:active_account:). The request interceptor forwards X-Active-Account-Id from storage (no header when unset → server defaults to the user's sole account). accounts.switchAccount(id) persists + reloads; adds clearActiveAccount(); drops the now-unused subPath. Co-authored-by: Cursor --- src/modules/accounts.ts | 17 +++++++---- src/modules/accounts.types.ts | 15 +++++----- src/utils/axios-client.ts | 16 +++++----- src/utils/common.ts | 56 +++++++++++++++++++++++++++-------- tests/unit/accounts.test.ts | 2 +- 5 files changed, 72 insertions(+), 34 deletions(-) diff --git a/src/modules/accounts.ts b/src/modules/accounts.ts index 04d764e..71cf85f 100644 --- a/src/modules/accounts.ts +++ b/src/modules/accounts.ts @@ -1,6 +1,9 @@ import { AxiosInstance } from "axios"; -import { getActiveAccountIdFromPath } from "../utils/common.js"; +import { + getStoredActiveAccountId, + setStoredActiveAccountId, +} from "../utils/common.js"; import type { Account, AccountMembership, @@ -28,13 +31,17 @@ export function createAccountsModule( return { getActiveAccountId(): string | undefined { - return getActiveAccountIdFromPath(appId); + return getStoredActiveAccountId(appId); }, - switchAccount(accountId: string, subPath = ""): void { + switchAccount(accountId: string): void { + setStoredActiveAccountId(appId, accountId); if (typeof window === "undefined") return; - const clean = subPath.replace(/^\/+/, ""); - window.location.assign(`/${accountId}${clean ? `/${clean}` : "/"}`); + window.location.reload(); + }, + + clearActiveAccount(): void { + setStoredActiveAccountId(appId, null); }, async listMine(): Promise { diff --git a/src/modules/accounts.types.ts b/src/modules/accounts.types.ts index 5761597..c0ee772 100644 --- a/src/modules/accounts.types.ts +++ b/src/modules/accounts.types.ts @@ -4,8 +4,8 @@ * An Account groups the app's end-users into an isolated tenant (a company, * team, or organization). Users join accounts via membership and act inside one * active account at a time. Account-scoped entities are transparently isolated - * to the active account (carried by the `X-Active-Account-Id` header, derived - * from the `//...` URL path). + * to the active account (carried by the `X-Active-Account-Id` header, read from + * stored client state in localStorage, keyed per app). */ /** Account-management role. Distinct from the app's business roles. */ @@ -70,15 +70,16 @@ export interface CheckoutSession { * Access via `base44.accounts`. Available when the app has multi-tenancy enabled. */ export interface AccountsModule { - /** The active account id, read from the current URL path (or `undefined`). */ + /** The active account id, read from stored client state (or `undefined`). */ getActiveAccountId(): string | undefined; /** - * Switch the active account by navigating to its folder (`//...`). - * A full navigation re-roots the app so all data follows the new account. + * Switch the active account by persisting it to stored client state and + * reloading the page so all data follows the new account. * @param accountId - The account to switch to. - * @param subPath - Optional in-account route to land on (defaults to the root). */ - switchAccount(accountId: string, subPath?: string): void; + switchAccount(accountId: string): void; + /** Clear the stored active account (the backend falls back to the default). */ + clearActiveAccount(): void; /** List the accounts the current user belongs to, plus the active one. */ listMine(): Promise; /** Create a new account; the current user becomes its owner. */ diff --git a/src/utils/axios-client.ts b/src/utils/axios-client.ts index 810e447..7ba43d4 100644 --- a/src/utils/axios-client.ts +++ b/src/utils/axios-client.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { getActiveAccountIdFromPath, isInIFrame } from "./common.js"; +import { getStoredActiveAccountId, isInIFrame } from "./common.js"; import { v4 as uuidv4 } from "uuid"; import type { Base44ErrorJSON } from "./axios-client.types.js"; @@ -178,13 +178,13 @@ export function createAxiosClient({ client.interceptors.request.use((config) => { if (typeof window !== "undefined") { config.headers.set("X-Origin-URL", window.location.href); - // Multi-tenancy: forward the active account (from the URL path) per request - // so account-scoped reads/writes stay isolated to the current tenant even - // after a client-side account switch. The path is the canonical source, so - // it overrides any stale default header (e.g. one frozen at module load); - // no-op for single-tenant apps (no account segment in the path). The app id - // is passed so the sandbox base path (//) is never read as an account. - const activeAccountId = getActiveAccountIdFromPath(appId); + // Multi-tenancy: forward the active account from stored client state + // (localStorage, keyed per app) on every request so account-scoped + // reads/writes stay isolated to the current tenant. No-op when unset — + // the backend then defaults to the user's sole active account. + const activeAccountId = appId + ? getStoredActiveAccountId(appId) + : undefined; if (activeAccountId) { config.headers.set("X-Active-Account-Id", activeAccountId); } diff --git a/src/utils/common.ts b/src/utils/common.ts index 9a40e46..b7918ce 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,25 +1,55 @@ export const isNode = typeof window === "undefined"; export const isInIFrame = !isNode && window.self !== window.top; -// Multi-tenancy: apps are served under `//` where the first -// path segment is a 24-hex Mongo ObjectId. Read it at request time so the active -// account is always current — even after client-side (Link/useNavigate) account -// switches that don't reload the module. +// Multi-tenancy: the active account is explicit client state, not the URL path. +// It is persisted in localStorage keyed per app (`base44:active_account:`) +// so it survives reloads and works under any base path (e.g. the sandbox/preview +// where the app is served under a non-account base path). When unset, no header +// is sent and the backend defaults to the user's sole active account. const ACCOUNT_ID_RE = /^[a-f0-9]{24}$/; +const activeAccountStorageKey = (appId: string): string => + `base44:active_account:${appId}`; + /** - * The active account id from the URL, or undefined. + * The active account id from stored client state, or undefined. * - * In the sandbox/preview the app is served under `//` (the dev-server base - * path), so the first segment is the app id — its own base, NOT an account. When - * `appId` is supplied and matches the leading segment, it's skipped so the app id - * is never sent as an account id; the account, if any, is the next segment. + * Browser-only: reads `localStorage['base44:active_account:']` and returns + * it when it's a valid 24-hex account id, else undefined. Returns undefined in + * non-browser environments or if storage access throws. */ -export function getActiveAccountIdFromPath(appId?: string): string | undefined { +export function getStoredActiveAccountId(appId: string): string | undefined { if (isNode) return undefined; - const segments = window.location.pathname.split("/").filter(Boolean); - const candidate = appId && segments[0] === appId ? segments[1] : segments[0]; - return candidate && ACCOUNT_ID_RE.test(candidate) ? candidate : undefined; + try { + const stored = window.localStorage.getItem(activeAccountStorageKey(appId)); + return stored && ACCOUNT_ID_RE.test(stored) ? stored : undefined; + } catch { + return undefined; + } +} + +/** + * Persist (or clear) the active account id in stored client state. + * + * Browser-only: writes `localStorage['base44:active_account:']` when + * `accountId` is a valid 24-hex id, and removes the key when it is null or + * invalid. No-op in non-browser environments or if storage access throws. + */ +export function setStoredActiveAccountId( + appId: string, + accountId: string | null +): void { + if (isNode) return; + try { + const key = activeAccountStorageKey(appId); + if (accountId && ACCOUNT_ID_RE.test(accountId)) { + window.localStorage.setItem(key, accountId); + } else { + window.localStorage.removeItem(key); + } + } catch { + /* storage unavailable — ignore */ + } } export const generateUuid = () => { diff --git a/tests/unit/accounts.test.ts b/tests/unit/accounts.test.ts index b869a47..05bbd39 100644 --- a/tests/unit/accounts.test.ts +++ b/tests/unit/accounts.test.ts @@ -49,7 +49,7 @@ describe("Accounts module", () => { }); // The active-account behavior (getActiveAccountId + the per-request - // X-Active-Account-Id header) is browser-only — `getActiveAccountIdFromPath` + // X-Active-Account-Id header) is browser-only — `getStoredActiveAccountId` // is gated on a module-load `typeof window` check, so it is exercised in the // browser/app context, not this node-environment test suite. test("getActiveAccountId returns undefined outside a browser", () => { From feced91e0c60c09ae7af6460573748401351b524 Mon Sep 17 00:00:00 2001 From: felixkob Date: Thu, 4 Jun 2026 11:57:20 +0300 Subject: [PATCH 4/7] feat(accounts/billing): add getSubscription + auto-resolve active account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated multi-tenant apps kept hitting two runtime errors (seen in the builder preview's Issues panel): - "base44.accounts.billing.getSubscription is not a function" — add it. It returns { account_id, plan_id, billing_status, plan } derived from listMine() + listPlans() (no new backend endpoint). - "Account not found" 404 — billing/member reads were called with an account id derived from getActiveAccountId(), which is null for single-account users, so the path became /accounts/undefined/... Make accountId OPTIONAL on the read-style calls (listMembers, billing.listPlans/getSubscription/startCheckout) and resolve it via stored selection → server default (listMine().active_account_id), throwing a clear error only when truly none exists. startCheckout now accepts either (params) or (accountId, params). Co-authored-by: Cursor --- src/modules/accounts.ts | 66 +++++++++++++++++++++++++++++++---- src/modules/accounts.types.ts | 45 ++++++++++++++++++++---- tests/unit/accounts.test.ts | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 13 deletions(-) diff --git a/src/modules/accounts.ts b/src/modules/accounts.ts index 71cf85f..b054a2d 100644 --- a/src/modules/accounts.ts +++ b/src/modules/accounts.ts @@ -9,7 +9,9 @@ import type { AccountMembership, AccountPlan, AccountsModule, + AccountSubscription, AssignableAccountRole, + CheckoutParams, CheckoutSession, MyAccountsResponse, } from "./accounts.types.js"; @@ -29,6 +31,22 @@ export function createAccountsModule( const base = `/apps/${appId}/accounts`; const enc = encodeURIComponent; + // Resolve the account id to operate on: an explicit id wins, then the + // explicitly-stored client selection, then the server-resolved default + // (the sole-account case). Throws a clear error when none can be found so + // callers never silently send `/accounts/undefined/...` (which 404s as + // "Account not found"). + const resolveAccountId = async (provided?: string | null): Promise => { + if (provided) return provided; + const stored = getStoredActiveAccountId(appId); + if (stored) return stored; + const mine: MyAccountsResponse = await axios.get(`${base}/me`); + if (mine.active_account_id) return mine.active_account_id; + throw new Error( + "No active account: pass an accountId, or have the user select or create an account first." + ); + }; + return { getActiveAccountId(): string | undefined { return getStoredActiveAccountId(appId); @@ -62,8 +80,9 @@ export function createAccountsModule( return axios.patch(`${base}/${accountId}`, params); }, - async listMembers(accountId: string): Promise { - return axios.get(`${base}/${accountId}/members`); + async listMembers(accountId?: string): Promise { + const id = await resolveAccountId(accountId); + return axios.get(`${base}/${id}/members`); }, async invite( @@ -103,15 +122,48 @@ export function createAccountsModule( }, billing: { - async listPlans(accountId: string): Promise { - return axios.get(`${base}/${accountId}/billing/plans`); + async listPlans(accountId?: string): Promise { + const id = await resolveAccountId(accountId); + return axios.get(`${base}/${id}/billing/plans`); + }, + + async getSubscription(accountId?: string): Promise { + // /me is needed for the account's plan_id/billing_status, and also + // resolves the active account id — fetch it once and reuse it. + const mine: MyAccountsResponse = await axios.get(`${base}/me`); + const id = + accountId ?? + getStoredActiveAccountId(appId) ?? + mine.active_account_id ?? + undefined; + if (!id) { + throw new Error( + "No active account: pass an accountId, or have the user select or create an account first." + ); + } + const plans: AccountPlan[] = await axios.get(`${base}/${id}/billing/plans`); + const account = mine.accounts.find((a) => a.id === id) ?? null; + const planId = account?.plan_id ?? null; + return { + account_id: id, + plan_id: planId, + billing_status: account?.billing_status ?? "none", + plan: planId ? plans.find((p) => p.id === planId) ?? null : null, + }; }, async startCheckout( - accountId: string, - params: { plan_id: string; success_url: string; cancel_url: string } + accountIdOrParams: string | CheckoutParams, + maybeParams?: CheckoutParams ): Promise { - return axios.post(`${base}/${accountId}/billing/checkout`, params); + const explicitId = + typeof accountIdOrParams === "string" ? accountIdOrParams : undefined; + const params = + typeof accountIdOrParams === "string" + ? maybeParams + : accountIdOrParams; + const id = await resolveAccountId(explicitId); + return axios.post(`${base}/${id}/billing/checkout`, params); }, }, }; diff --git a/src/modules/accounts.types.ts b/src/modules/accounts.types.ts index c0ee772..d4eb214 100644 --- a/src/modules/accounts.types.ts +++ b/src/modules/accounts.types.ts @@ -64,6 +64,24 @@ export interface CheckoutSession { session_id: string; } +/** Parameters for starting a subscription checkout. */ +export interface CheckoutParams { + plan_id: string; + success_url: string; + cancel_url: string; +} + +/** The current subscription state of an account. */ +export interface AccountSubscription { + account_id: string; + /** The active plan id, or `null` when the account has no subscription. */ + plan_id: string | null; + /** Lifecycle status: "none" | "active" | "past_due" | "canceled". */ + billing_status: string; + /** The resolved plan (matched from the account's available plans), or `null`. */ + plan: AccountPlan | null; +} + /** * The accounts module — manage multi-tenancy ("Accounts") from inside the app. * @@ -89,8 +107,11 @@ export interface AccountsModule { accountId: string, params: { name?: string; data?: Record } ): Promise; - /** List an account's members (any active member). */ - listMembers(accountId: string): Promise; + /** + * List an account's members (any active member). + * @param accountId - Defaults to the active account when omitted. + */ + listMembers(accountId?: string): Promise; /** Invite a user by email to an account (managers only). */ invite( accountId: string, @@ -114,12 +135,24 @@ export interface AccountsModule { ): Promise<{ transferred: boolean }>; /** Per-account billing. */ billing: { - /** List the active plans available to this account. */ - listPlans(accountId: string): Promise; - /** Start a subscription checkout session for a plan. */ + /** + * List the active plans available to an account. + * @param accountId - Defaults to the active account when omitted. + */ + listPlans(accountId?: string): Promise; + /** + * Get the current subscription state (plan + status) of an account. + * @param accountId - Defaults to the active account when omitted. + */ + getSubscription(accountId?: string): Promise; + /** + * Start a subscription checkout session for a plan, then redirect the + * browser to the returned `url`. The account defaults to the active one. + */ + startCheckout(params: CheckoutParams): Promise; startCheckout( accountId: string, - params: { plan_id: string; success_url: string; cancel_url: string } + params: CheckoutParams ): Promise; }; } diff --git a/tests/unit/accounts.test.ts b/tests/unit/accounts.test.ts index 05bbd39..3410036 100644 --- a/tests/unit/accounts.test.ts +++ b/tests/unit/accounts.test.ts @@ -48,6 +48,68 @@ describe("Accounts module", () => { expect(scope.isDone()).toBe(true); }); + test("billing.listPlans without an id resolves the active account via /me", async () => { + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: ACCT }); + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/plans`).reply(200, []); + const res = await base44.accounts.billing.listPlans(); + expect(res).toEqual([]); + expect(scope.isDone()).toBe(true); + }); + + test("billing.getSubscription derives plan + status from /me and plans", async () => { + const plan = { id: "p1", name: "Pro", price_amount: 1000, currency: "usd", interval: "month", is_active: true }; + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { + accounts: [{ id: ACCT, name: "Acme", plan_id: "p1", billing_status: "active" }], + active_account_id: ACCT, + }); + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/plans`).reply(200, [plan]); + const sub = await base44.accounts.billing.getSubscription(); + expect(sub).toEqual({ account_id: ACCT, plan_id: "p1", billing_status: "active", plan }); + expect(scope.isDone()).toBe(true); + }); + + test("billing.getSubscription returns null plan/none status when unsubscribed", async () => { + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { + accounts: [{ id: ACCT, name: "Acme", plan_id: null }], + active_account_id: ACCT, + }); + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/plans`).reply(200, []); + const sub = await base44.accounts.billing.getSubscription(ACCT); + expect(sub).toEqual({ account_id: ACCT, plan_id: null, billing_status: "none", plan: null }); + expect(scope.isDone()).toBe(true); + }); + + test("billing.startCheckout(params) resolves the active account", async () => { + const params = { plan_id: "p1", success_url: "https://x/ok", cancel_url: "https://x/no" }; + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: ACCT }); + scope.post(`/api/apps/${appId}/accounts/${ACCT}/billing/checkout`, params).reply(200, { url: "https://pay", session_id: "s1" }); + const res = await base44.accounts.billing.startCheckout(params); + expect(res.url).toBe("https://pay"); + expect(scope.isDone()).toBe(true); + }); + + test("billing.startCheckout(accountId, params) uses the explicit id (no /me)", async () => { + const params = { plan_id: "p1", success_url: "https://x/ok", cancel_url: "https://x/no" }; + scope.post(`/api/apps/${appId}/accounts/${ACCT}/billing/checkout`, params).reply(200, { url: "https://pay", session_id: "s1" }); + const res = await base44.accounts.billing.startCheckout(ACCT, params); + expect(res.url).toBe("https://pay"); + expect(scope.isDone()).toBe(true); + }); + + test("listMembers without an id resolves the active account via /me", async () => { + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: ACCT }); + scope.get(`/api/apps/${appId}/accounts/${ACCT}/members`).reply(200, []); + const res = await base44.accounts.listMembers(); + expect(res).toEqual([]); + expect(scope.isDone()).toBe(true); + }); + + test("resolveAccountId throws a clear error when there is no active account", async () => { + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: null }); + await expect(base44.accounts.billing.listPlans()).rejects.toThrow(/No active account/); + expect(scope.isDone()).toBe(true); + }); + // The active-account behavior (getActiveAccountId + the per-request // X-Active-Account-Id header) is browser-only — `getStoredActiveAccountId` // is gated on a module-load `typeof window` check, so it is exercised in the From b7d230a5881e4e1919e1c93c9b73df7a0beaa0df Mon Sep 17 00:00:00 2001 From: felixkob Date: Thu, 4 Jun 2026 12:07:32 +0300 Subject: [PATCH 5/7] feat(accounts/billing): getSubscription reads a real subscription endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the listMine()-derived shim with a dedicated call to GET /accounts/{id}/billing/subscription, returning richer detail: billing_provider, current_period_end (renewal), cancel_at_period_end, canceled_at, started_at — alongside plan_id/billing_status/plan. The id still auto-resolves to the active account when omitted. Co-authored-by: Cursor --- src/modules/accounts.ts | 24 ++--------------------- src/modules/accounts.types.ts | 12 +++++++++++- tests/unit/accounts.test.ts | 37 +++++++++++++++++++++-------------- 3 files changed, 35 insertions(+), 38 deletions(-) diff --git a/src/modules/accounts.ts b/src/modules/accounts.ts index b054a2d..cdf1e12 100644 --- a/src/modules/accounts.ts +++ b/src/modules/accounts.ts @@ -128,28 +128,8 @@ export function createAccountsModule( }, async getSubscription(accountId?: string): Promise { - // /me is needed for the account's plan_id/billing_status, and also - // resolves the active account id — fetch it once and reuse it. - const mine: MyAccountsResponse = await axios.get(`${base}/me`); - const id = - accountId ?? - getStoredActiveAccountId(appId) ?? - mine.active_account_id ?? - undefined; - if (!id) { - throw new Error( - "No active account: pass an accountId, or have the user select or create an account first." - ); - } - const plans: AccountPlan[] = await axios.get(`${base}/${id}/billing/plans`); - const account = mine.accounts.find((a) => a.id === id) ?? null; - const planId = account?.plan_id ?? null; - return { - account_id: id, - plan_id: planId, - billing_status: account?.billing_status ?? "none", - plan: planId ? plans.find((p) => p.id === planId) ?? null : null, - }; + const id = await resolveAccountId(accountId); + return axios.get(`${base}/${id}/billing/subscription`); }, async startCheckout( diff --git a/src/modules/accounts.types.ts b/src/modules/accounts.types.ts index d4eb214..2f17dc8 100644 --- a/src/modules/accounts.types.ts +++ b/src/modules/accounts.types.ts @@ -78,8 +78,18 @@ export interface AccountSubscription { plan_id: string | null; /** Lifecycle status: "none" | "active" | "past_due" | "canceled". */ billing_status: string; - /** The resolved plan (matched from the account's available plans), or `null`. */ + /** The payment rail backing the subscription, or `null`. */ + billing_provider: string | null; + /** The current plan, or `null` when the account has no subscription. */ plan: AccountPlan | null; + /** When the current paid period ends / renews (ISO 8601), or `null`. */ + current_period_end: string | null; + /** True when the subscription will not renew at period end. */ + cancel_at_period_end: boolean; + /** When the subscription was canceled (ISO 8601), or `null`. */ + canceled_at: string | null; + /** When the subscription started (ISO 8601), or `null`. */ + started_at: string | null; } /** diff --git a/tests/unit/accounts.test.ts b/tests/unit/accounts.test.ts index 3410036..7f0bc38 100644 --- a/tests/unit/accounts.test.ts +++ b/tests/unit/accounts.test.ts @@ -56,26 +56,33 @@ describe("Accounts module", () => { expect(scope.isDone()).toBe(true); }); - test("billing.getSubscription derives plan + status from /me and plans", async () => { - const plan = { id: "p1", name: "Pro", price_amount: 1000, currency: "usd", interval: "month", is_active: true }; - scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { - accounts: [{ id: ACCT, name: "Acme", plan_id: "p1", billing_status: "active" }], - active_account_id: ACCT, - }); - scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/plans`).reply(200, [plan]); + test("billing.getSubscription GETs the subscription endpoint (resolving active account)", async () => { + const payload = { + account_id: ACCT, + plan_id: "p1", + billing_status: "active", + billing_provider: "stripe_connect", + plan: { id: "p1", name: "Pro", price_amount: 1000, currency: "usd", interval: "month", is_active: true }, + current_period_end: "2026-07-01T00:00:00+00:00", + cancel_at_period_end: false, + canceled_at: null, + started_at: "2026-06-01T00:00:00+00:00", + }; + scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { accounts: [], active_account_id: ACCT }); + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/subscription`).reply(200, payload); const sub = await base44.accounts.billing.getSubscription(); - expect(sub).toEqual({ account_id: ACCT, plan_id: "p1", billing_status: "active", plan }); + expect(sub).toEqual(payload); expect(scope.isDone()).toBe(true); }); - test("billing.getSubscription returns null plan/none status when unsubscribed", async () => { - scope.get(`/api/apps/${appId}/accounts/me`).reply(200, { - accounts: [{ id: ACCT, name: "Acme", plan_id: null }], - active_account_id: ACCT, - }); - scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/plans`).reply(200, []); + test("billing.getSubscription(accountId) uses the explicit id (no /me)", async () => { + const payload = { + account_id: ACCT, plan_id: null, billing_status: "none", billing_provider: null, + plan: null, current_period_end: null, cancel_at_period_end: false, canceled_at: null, started_at: null, + }; + scope.get(`/api/apps/${appId}/accounts/${ACCT}/billing/subscription`).reply(200, payload); const sub = await base44.accounts.billing.getSubscription(ACCT); - expect(sub).toEqual({ account_id: ACCT, plan_id: null, billing_status: "none", plan: null }); + expect(sub).toEqual(payload); expect(scope.isDone()).toBe(true); }); From 087fce3b139395d92a3a9a5391684b24e0d036c7 Mon Sep 17 00:00:00 2001 From: felixkob Date: Thu, 4 Jun 2026 12:56:21 +0300 Subject: [PATCH 6/7] feat(accounts): public landing-page support (getPublicAccount, setActiveAccount, joinAccount) - getPublicAccount(slug): unauthenticated GET /accounts/public/by-slug/{slug} for the per-account landing page (returns id/name/slug + public fields). - setActiveAccount(accountId): persist the active account WITHOUT reloading, so the landing can select the account before redirecting to login. - joinAccount(slug): self-join an account from its landing page. - PublicAccount type + unit tests. Co-authored-by: Cursor --- src/modules/accounts.ts | 13 +++++++++++++ src/modules/accounts.types.ts | 32 ++++++++++++++++++++++++++++++++ tests/unit/accounts.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/src/modules/accounts.ts b/src/modules/accounts.ts index cdf1e12..6c74437 100644 --- a/src/modules/accounts.ts +++ b/src/modules/accounts.ts @@ -14,6 +14,7 @@ import type { CheckoutParams, CheckoutSession, MyAccountsResponse, + PublicAccount, } from "./accounts.types.js"; /** @@ -58,6 +59,10 @@ export function createAccountsModule( window.location.reload(); }, + setActiveAccount(accountId: string): void { + setStoredActiveAccountId(appId, accountId); + }, + clearActiveAccount(): void { setStoredActiveAccountId(appId, null); }, @@ -66,6 +71,14 @@ export function createAccountsModule( return axios.get(`${base}/me`); }, + async getPublicAccount(slug: string): Promise { + return axios.get(`${base}/public/by-slug/${enc(slug)}`); + }, + + async joinAccount(slug: string): Promise { + return axios.post(`${base}/by-slug/${enc(slug)}/join`, {}); + }, + async create(params: { name: string; data?: Record; diff --git a/src/modules/accounts.types.ts b/src/modules/accounts.types.ts index 2f17dc8..efcb3bb 100644 --- a/src/modules/accounts.types.ts +++ b/src/modules/accounts.types.ts @@ -38,6 +38,18 @@ export interface MyAccountsResponse { active_account_id: string | null; } +/** + * Public, unauthenticated view of an account (its landing page), resolved by + * slug. Carries identity plus only the builder-designated public custom fields. + */ +export interface PublicAccount { + id: string; + name: string; + slug: string | null; + /** Builder-flagged public companion fields (e.g. logo, tagline). */ + data: Record; +} + /** A user's membership in an account. */ export interface AccountMembership { id: string; @@ -106,10 +118,30 @@ export interface AccountsModule { * @param accountId - The account to switch to. */ switchAccount(accountId: string): void; + /** + * Persist the active account WITHOUT reloading the page. + * + * Use on the public landing page to select the account before redirecting to + * login, so the app resolves that account after the visitor returns. For + * switching accounts inside the running app, use {@link switchAccount} (which + * reloads so all data follows the new account). + */ + setActiveAccount(accountId: string): void; /** Clear the stored active account (the backend falls back to the default). */ clearActiveAccount(): void; /** List the accounts the current user belongs to, plus the active one. */ listMine(): Promise; + /** + * Resolve a public account by its slug (unauthenticated) for its landing page. + * @param slug - The account's URL slug. + */ + getPublicAccount(slug: string): Promise; + /** + * Self-join an account by slug (the current user becomes a member). Requires + * login and that the app enables public joining; otherwise rejects. + * @param slug - The account's URL slug. + */ + joinAccount(slug: string): Promise; /** Create a new account; the current user becomes its owner. */ create(params: { name: string; data?: Record }): Promise; /** Rename and/or update an account's custom fields (managers only). */ diff --git a/tests/unit/accounts.test.ts b/tests/unit/accounts.test.ts index 7f0bc38..06034a2 100644 --- a/tests/unit/accounts.test.ts +++ b/tests/unit/accounts.test.ts @@ -117,6 +117,27 @@ describe("Accounts module", () => { expect(scope.isDone()).toBe(true); }); + test("getPublicAccount GETs the public by-slug endpoint (unauthenticated)", async () => { + const payload = { id: ACCT, name: "Acme", slug: "acme", data: { tagline: "Hi" } }; + scope.get(`/api/apps/${appId}/accounts/public/by-slug/acme`).reply(200, payload); + const res = await base44.accounts.getPublicAccount("acme"); + expect(res).toEqual(payload); + expect(scope.isDone()).toBe(true); + }); + + test("getPublicAccount url-encodes the slug", async () => { + scope.get(`/api/apps/${appId}/accounts/public/by-slug/a%2Fb`).reply(200, { id: ACCT, name: "x", slug: "a-b", data: {} }); + await base44.accounts.getPublicAccount("a/b"); + expect(scope.isDone()).toBe(true); + }); + + test("joinAccount POSTs to the by-slug join endpoint", async () => { + scope.post(`/api/apps/${appId}/accounts/by-slug/acme/join`, {}).reply(200, { id: "m1", account_id: ACCT, email: "a@b.com", role: "member", status: "active" }); + const res = await base44.accounts.joinAccount("acme"); + expect(res.status).toBe("active"); + expect(scope.isDone()).toBe(true); + }); + // The active-account behavior (getActiveAccountId + the per-request // X-Active-Account-Id header) is browser-only — `getStoredActiveAccountId` // is gated on a module-load `typeof window` check, so it is exercised in the @@ -124,4 +145,8 @@ describe("Accounts module", () => { test("getActiveAccountId returns undefined outside a browser", () => { expect(base44.accounts.getActiveAccountId()).toBeUndefined(); }); + + test("setActiveAccount does not throw outside a browser (no reload)", () => { + expect(() => base44.accounts.setActiveAccount(ACCT)).not.toThrow(); + }); }); From 2df4fad3113779ebb4dbf762573b2c036ef581cc Mon Sep 17 00:00:00 2001 From: felixkob Date: Thu, 4 Jun 2026 13:56:56 +0300 Subject: [PATCH 7/7] feat(accounts): accept optional slug on create/update Forward an optional landing-page `slug` on accounts.create and accounts.update so apps can set/change the public landing-page URL (e.g. from a registration form). Backend validates + enforces per-app uniqueness. Co-authored-by: Cursor --- src/modules/accounts.ts | 3 ++- src/modules/accounts.types.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/modules/accounts.ts b/src/modules/accounts.ts index 6c74437..b8095f2 100644 --- a/src/modules/accounts.ts +++ b/src/modules/accounts.ts @@ -82,13 +82,14 @@ export function createAccountsModule( async create(params: { name: string; data?: Record; + slug?: string; }): Promise { return axios.post(base, params); }, async update( accountId: string, - params: { name?: string; data?: Record } + params: { name?: string; data?: Record; slug?: string } ): Promise { return axios.patch(`${base}/${accountId}`, params); }, diff --git a/src/modules/accounts.types.ts b/src/modules/accounts.types.ts index efcb3bb..85b9ebe 100644 --- a/src/modules/accounts.types.ts +++ b/src/modules/accounts.types.ts @@ -142,12 +142,16 @@ export interface AccountsModule { * @param slug - The account's URL slug. */ joinAccount(slug: string): Promise; - /** Create a new account; the current user becomes its owner. */ - create(params: { name: string; data?: Record }): Promise; - /** Rename and/or update an account's custom fields (managers only). */ + /** + * Create a new account; the current user becomes its owner. + * @param params.slug - Optional public landing-page URL segment; auto-derived + * from the name when omitted. + */ + create(params: { name: string; data?: Record; slug?: string }): Promise; + /** Rename, change the landing-page slug, and/or update an account's custom fields (managers only). */ update( accountId: string, - params: { name?: string; data?: Record } + params: { name?: string; data?: Record; slug?: string } ): Promise; /** * List an account's members (any active member).