diff --git a/.changeset/gentle-organizations-refresh.md b/.changeset/gentle-organizations-refresh.md new file mode 100644 index 0000000000..5f952fafab --- /dev/null +++ b/.changeset/gentle-organizations-refresh.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Refresh the active organization after sign-in when a session hook selects the initial organization. diff --git a/packages/better-auth/src/client/config.ts b/packages/better-auth/src/client/config.ts index 9be8df7ce0..59cb838efe 100644 --- a/packages/better-auth/src/client/config.ts +++ b/packages/better-auth/src/client/config.ts @@ -39,6 +39,20 @@ const resolvePublicAuthUrl = (basePath?: string) => { return undefined; }; +export const matchesSessionSignal = (path: string) => + path === "/sign-out" || + path === "/update-user" || + path === "/update-session" || + path === "/sign-up/email" || + path === "/sign-in/email" || + path === "/delete-user" || + path === "/verify-email" || + path === "/revoke-sessions" || + path === "/revoke-session" || + path === "/revoke-other-sessions" || + path === "/change-email" || + path === "/change-password"; + export const getClientConfig = ( options?: BetterAuthClientOptions | undefined, loadEnv?: boolean | undefined, @@ -118,23 +132,7 @@ export const getClientConfig = ( const atomListeners: ClientAtomListener[] = [ { signal: "$sessionSignal", - matcher(path) { - const matchesCommonPaths = - path === "/sign-out" || - path === "/update-user" || - path === "/update-session" || - path === "/sign-up/email" || - path === "/sign-in/email" || - path === "/delete-user" || - path === "/verify-email" || - path === "/revoke-sessions" || - path === "/revoke-session" || - path === "/revoke-other-sessions" || - path === "/change-email" || - path === "/change-password"; - - return matchesCommonPaths; - }, + matcher: matchesSessionSignal, callback(path) { if (path === "/sign-out") { broadcastSessionUpdate("signout"); diff --git a/packages/better-auth/src/client/proxy.test.ts b/packages/better-auth/src/client/proxy.test.ts index 64dedd9e9d..5b65da3bd8 100644 --- a/packages/better-auth/src/client/proxy.test.ts +++ b/packages/better-auth/src/client/proxy.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; +import { organizationClient } from "../plugins/organization/client"; import { getClientConfig } from "./config"; import { createDynamicPathProxy } from "./proxy"; @@ -136,4 +137,44 @@ describe("createDynamicPathProxy", () => { vi.useRealTimers(); }); + + /** + * @see https://github.com/better-auth/better-auth/issues/9710 + */ + it("refreshes the organization signal once for auth and organization paths", async () => { + const { $fetch, pluginPathMethods, pluginsAtoms, atomListeners } = + getClientConfig({ + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl: async () => Response.json({}), + }, + plugins: [organizationClient()], + }); + + const activeOrgSignal = pluginsAtoms.$activeOrgSignal; + if (!activeOrgSignal) throw new Error("Active org signal missing"); + const updates = vi.fn(); + onTestFinished(activeOrgSignal.listen(updates)); + + const proxy = createDynamicPathProxy( + {} as { + signIn: { email: () => Promise }; + signUp: { email: () => Promise }; + organization: { setActive: () => Promise }; + }, + $fetch, + pluginPathMethods, + pluginsAtoms, + atomListeners, + ); + + await proxy.signIn.email(); + await vi.waitFor(() => expect(updates).toHaveBeenCalledTimes(1)); + + await proxy.signUp.email(); + await vi.waitFor(() => expect(updates).toHaveBeenCalledTimes(2)); + + await proxy.organization.setActive(); + await vi.waitFor(() => expect(updates).toHaveBeenCalledTimes(3)); + }); }); diff --git a/packages/better-auth/src/plugins/organization/client.test.ts b/packages/better-auth/src/plugins/organization/client.test.ts index 3eceb3da65..cc0dc9b449 100644 --- a/packages/better-auth/src/plugins/organization/client.test.ts +++ b/packages/better-auth/src/plugins/organization/client.test.ts @@ -1,6 +1,7 @@ -import { describe, it } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { betterAuth } from "../../auth/full"; import { createAuthClient } from "../../client"; +import { getTestInstance } from "../../test-utils/test-instance"; import { inferOrgAdditionalFields, organizationClient } from "./client"; import { organization } from "./organization"; @@ -70,3 +71,69 @@ describe("organization", () => { }); }); }); + +/** + * @see https://github.com/better-auth/better-auth/issues/9710 + */ +it("refreshes the active organization after sign-in sets it in a session hook", async () => { + let initialOrganizationId: string | undefined; + const { auth, client, runWithUser, testUser, sessionSetter } = + await getTestInstance( + { + plugins: [organization()], + databaseHooks: { + session: { + create: { + before: async (session) => ({ + data: { + ...session, + activeOrganizationId: initialOrganizationId, + }, + }), + }, + }, + }, + }, + { clientOptions: { plugins: [organizationClient()] } }, + ); + + await runWithUser(testUser.email, testUser.password, async (headers) => { + const organization = await auth.api.createOrganization({ + body: { name: "Test Workspace", slug: "test-workspace" }, + headers, + }); + if (!organization) throw new Error("Organization was not created"); + initialOrganizationId = organization.id; + headers.delete("cookie"); + + const previousWindow = global.window; + global.window = {} as Window & typeof globalThis; + onTestFinished(() => { + global.window = previousWindow; + }); + + const activeOrganization = client.$store.atoms.activeOrganization; + if (!activeOrganization) + throw new Error("Active organization atom missing"); + const unsubscribe = activeOrganization.subscribe(() => {}); + onTestFinished(unsubscribe); + await vi.waitFor(() => { + expect(activeOrganization.get().isPending).toBe(false); + }); + expect(activeOrganization.get().data).toBeNull(); + + const result = await client.signIn.email({ + email: testUser.email, + password: testUser.password, + fetchOptions: { onSuccess: sessionSetter(headers) }, + }); + expect(result.error).toBeNull(); + + const fullOrganization = await auth.api.getFullOrganization({ headers }); + expect(fullOrganization?.id).toBe(initialOrganizationId); + + await vi.waitFor(() => { + expect(activeOrganization.get().data?.id).toBe(initialOrganizationId); + }); + }); +}); diff --git a/packages/better-auth/src/plugins/organization/client.ts b/packages/better-auth/src/plugins/organization/client.ts index 8412cc14fd..4b474447a2 100644 --- a/packages/better-auth/src/plugins/organization/client.ts +++ b/packages/better-auth/src/plugins/organization/client.ts @@ -3,6 +3,7 @@ import type { DBFieldAttribute } from "@better-auth/core/db"; import type { BetterFetch } from "@better-fetch/fetch"; import { atom } from "nanostores"; import { useAuthQuery } from "../../client"; +import { matchesSessionSignal } from "../../client/config"; import type { InferInvitation, InferMember, @@ -249,7 +250,7 @@ export const organizationClient = ( }, { matcher(path) { - return path === "/sign-out" || path.startsWith("/organization"); + return matchesSessionSignal(path) || path.startsWith("/organization"); }, signal: "$activeOrgSignal", },