From 3ffa5b8d2f00597d81e222f90f2d3251f1cf644f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 08:57:34 -0600 Subject: [PATCH 01/12] feat(api): report the session's active organization on /v1/me GET /v1/me now carries active_org_id, the organization the session has switched into, read from the session or, for a JWT, from the token's claims. Clients use it to restore the active organization on reload. --- api/me_active_org_test.go | 47 +++++++++++++++++++++++++++++++++++++++ api/user_handlers.go | 15 +++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 api/me_active_org_test.go diff --git a/api/me_active_org_test.go b/api/me_active_org_test.go new file mode 100644 index 00000000..61480fa2 --- /dev/null +++ b/api/me_active_org_test.go @@ -0,0 +1,47 @@ +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/middleware" +) + +// GET /v1/me reports the session's active organization, so a client that +// reloads knows which organization the session is in without having to +// remember it. +func TestGetMe_ActiveOrgID(t *testing.T) { + a, eng := newTestAPI(t) + handler := withTestKey(a.Handler()) + + _, token, _ := signUp(t, eng, "activeorg@test.com", "SecureP@ss123") + sess, err := eng.ResolveSessionByToken(context.Background(), token) + require.NoError(t, err) + + get := func(ctx context.Context) map[string]any { + t.Helper() + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/v1/me", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var resp map[string]any + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + return resp + } + + base := middleware.WithUserID(context.Background(), sess.UserID) + _, present := get(middleware.WithSession(base, sess))["active_org_id"] + assert.False(t, present, "a session without an organization reports none") + + orgID := id.NewOrgID() + withOrg := *sess + withOrg.OrgID = orgID + assert.Equal(t, orgID.String(), get(middleware.WithSession(base, &withOrg))["active_org_id"]) +} diff --git a/api/user_handlers.go b/api/user_handlers.go index 8e4e8a93..a258be87 100644 --- a/api/user_handlers.go +++ b/api/user_handlers.go @@ -93,6 +93,11 @@ type MeResponse struct { // live session (Engine.restampUserSessions), so the two agree, and the // live list is the one a client can rely on straight after a change. Roles []string `json:"roles"` + + // ActiveOrgID is the organization the session has switched into with + // POST /v1/me/switch-org, empty when it hasn't. Clients read it on reload + // rather than remembering it themselves. + ActiveOrgID string `json:"active_org_id,omitempty"` } func (a *API) handleGetMe(ctx forge.Context, _ *GetMeRequest) (*MeResponse, error) { @@ -108,6 +113,16 @@ func (a *API) handleGetMe(ctx forge.Context, _ *GetMeRequest) (*MeResponse, erro resp := &MeResponse{User: u} sess, hasSess := middleware.SessionFrom(ctx.Context()) + switch { + case hasSess && sess != nil && !sess.OrgID.IsNil(): + resp.ActiveOrgID = sess.OrgID.String() + default: + // A JWT carries the organization in its claims rather than on a + // session row; the auth middleware puts it on the context. + if orgID, ok := middleware.OrgIDFrom(ctx.Context()); ok && !orgID.IsNil() { + resp.ActiveOrgID = orgID.String() + } + } appID := u.AppID if hasSess && sess != nil && !sess.AppID.IsNil() { appID = sess.AppID From 07a15f1e1b62bf923f91ea9df598d02f6a46b833 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 08:58:42 -0600 Subject: [PATCH 02/12] chore(sdk): regenerate clients for active_org_id --- .../packages/authsome_core/lib/src/generated/api_types.dart | 4 ++++ sdk/dart/lib/src/types.dart | 4 ++++ sdk/go/types.go | 1 + sdk/typescript/src/types.ts | 1 + sdkgen/spec.json | 3 +++ ui/packages/core/src/generated/api-types.ts | 1 + 6 files changed, 14 insertions(+) diff --git a/flutter/packages/authsome_core/lib/src/generated/api_types.dart b/flutter/packages/authsome_core/lib/src/generated/api_types.dart index f6fe877b..3778fc90 100644 --- a/flutter/packages/authsome_core/lib/src/generated/api_types.dart +++ b/flutter/packages/authsome_core/lib/src/generated/api_types.dart @@ -5340,6 +5340,7 @@ class MarkInvoicePaidRequest { } class MeResponse { + final String? activeOrgId; final String appId; final String? banExpires; final String? banReason; @@ -5363,6 +5364,7 @@ class MeResponse { final String? username; const MeResponse({ + this.activeOrgId, required this.appId, this.banExpires, this.banReason, @@ -5388,6 +5390,7 @@ class MeResponse { factory MeResponse.fromJson(Map json) { return MeResponse( + activeOrgId: json['active_org_id'] as String?, appId: json['app_id'] as String, banExpires: json['ban_expires'] as String?, banReason: json['ban_reason'] as String?, @@ -5414,6 +5417,7 @@ class MeResponse { Map toJson() { return { + if (activeOrgId != null) 'active_org_id': activeOrgId, 'app_id': appId, if (banExpires != null) 'ban_expires': banExpires, if (banReason != null) 'ban_reason': banReason, diff --git a/sdk/dart/lib/src/types.dart b/sdk/dart/lib/src/types.dart index f6fe877b..3778fc90 100644 --- a/sdk/dart/lib/src/types.dart +++ b/sdk/dart/lib/src/types.dart @@ -5340,6 +5340,7 @@ class MarkInvoicePaidRequest { } class MeResponse { + final String? activeOrgId; final String appId; final String? banExpires; final String? banReason; @@ -5363,6 +5364,7 @@ class MeResponse { final String? username; const MeResponse({ + this.activeOrgId, required this.appId, this.banExpires, this.banReason, @@ -5388,6 +5390,7 @@ class MeResponse { factory MeResponse.fromJson(Map json) { return MeResponse( + activeOrgId: json['active_org_id'] as String?, appId: json['app_id'] as String, banExpires: json['ban_expires'] as String?, banReason: json['ban_reason'] as String?, @@ -5414,6 +5417,7 @@ class MeResponse { Map toJson() { return { + if (activeOrgId != null) 'active_org_id': activeOrgId, 'app_id': appId, if (banExpires != null) 'ban_expires': banExpires, if (banReason != null) 'ban_reason': banReason, diff --git a/sdk/go/types.go b/sdk/go/types.go index 8ffb8443..a4a05ec2 100644 --- a/sdk/go/types.go +++ b/sdk/go/types.go @@ -1229,6 +1229,7 @@ type MarkInvoicePaidRequest struct { // MeResponse represents the MeResponse schema. type MeResponse struct { + ActiveOrgID string `json:"active_org_id,omitempty"` AppID string `json:"app_id"` BanExpires string `json:"ban_expires,omitempty"` BanReason string `json:"ban_reason,omitempty"` diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 4dc90e58..87719a70 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -1080,6 +1080,7 @@ export interface MarkInvoicePaidRequest { } export interface MeResponse { + active_org_id?: string; app_id: string; ban_expires?: string; ban_reason?: string; diff --git a/sdkgen/spec.json b/sdkgen/spec.json index edd2552e..28975021 100644 --- a/sdkgen/spec.json +++ b/sdkgen/spec.json @@ -3931,6 +3931,9 @@ }, "MeResponse": { "properties": { + "active_org_id": { + "type": "string" + }, "app_id": { "type": "string" }, diff --git a/ui/packages/core/src/generated/api-types.ts b/ui/packages/core/src/generated/api-types.ts index 953d1341..1baf682c 100644 --- a/ui/packages/core/src/generated/api-types.ts +++ b/ui/packages/core/src/generated/api-types.ts @@ -1080,6 +1080,7 @@ export interface MarkInvoicePaidRequest { } export interface MeResponse { + active_org_id?: string; app_id: string; ban_expires?: string; ban_reason?: string; From d3728eaed037b47dc6310afda59a889e26a3b6dc Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 09:02:58 -0600 Subject: [PATCH 03/12] feat(ui-core): add a client plugin contract and track the active organization Optional features can now ship as client plugins, the way they ship as server plugins. Pass plugins: [subscription()] to AuthManager and look one up with manager.plugin(subscription). A plugin gets a small context: an authenticated request (token or cookie), the auth state and client config, whether the server has it installed, the active organization, and signedIn, signedOut, sessionRefreshed and orgChanged events. AuthManager also keeps the session's active organization. It's restored from /v1/me, set by switchOrg (which keeps a reissued token), and forgotten on sign-out. --- ui/packages/core/src/auth.ts | 226 +++++++++++++++++++++++++++ ui/packages/core/src/index.ts | 9 ++ ui/packages/core/src/plugins.test.ts | 204 ++++++++++++++++++++++++ ui/packages/core/src/plugins.ts | 110 +++++++++++++ ui/packages/core/src/types.ts | 8 + 5 files changed, 557 insertions(+) create mode 100644 ui/packages/core/src/plugins.test.ts create mode 100644 ui/packages/core/src/plugins.ts diff --git a/ui/packages/core/src/auth.ts b/ui/packages/core/src/auth.ts index 8d34b852..45ac4974 100644 --- a/ui/packages/core/src/auth.ts +++ b/ui/packages/core/src/auth.ts @@ -20,6 +20,22 @@ import type { TokenStorage, User, } from "./types"; +import { + PluginNotRegisteredError, + pluginKeyName, + type AuthEvent, + type AuthPlugin, + type PluginContext, + type PluginKey, +} from "./plugins"; + +/** A registered plugin and, once used, what its init built (or threw). */ +interface PluginSlot { + plugin: AuthPlugin; + built: boolean; + api?: unknown; + error?: unknown; +} /** * Storage key under which the whole Session (tokens + expiry) is persisted. @@ -202,6 +218,12 @@ export class AuthManager { */ private cookieSession: boolean; + private baseURL: string; + private fetchFn: typeof globalThis.fetch; + private plugins = new Map(); + private eventListeners = new Map void>>(); + private activeOrgId: string | null = null; + constructor(config: AuthConfig) { this.cookieSession = config.session === "cookie"; if (this.cookieSession) { @@ -217,6 +239,14 @@ export class AuthManager { this.client = new AuthClient(config); let baseEnd = config.baseURL.length; while (baseEnd > 0 && config.baseURL[baseEnd - 1] === "/") baseEnd--; + this.baseURL = config.baseURL.slice(0, baseEnd); + this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis); + for (const plugin of config.plugins ?? []) { + if (this.plugins.has(plugin.name)) { + throw new Error(`authsome: the "${plugin.name}" plugin is registered twice`); + } + this.plugins.set(plugin.name, { plugin, built: false }); + } this.configCacheKey = `${CONFIG_KEY}:${JSON.stringify([config.baseURL.slice(0, baseEnd), config.publishableKey ?? ""])}`; this.refreshLockKey = `authsome:refresh:${config.baseURL}:${config.publishableKey ?? ""}`; this.storage = config.storage ?? createMemoryStorage(); @@ -772,11 +802,167 @@ export class AuthManager { } /** Tear down: clear timers and listeners. */ + // ── Plugins ─────────────────────────────────────── + + /** + * A registered plugin's API, built on first use. Pass the plugin's + * definition (`manager.plugin(subscription)`) or the instance you + * registered. Throws PluginNotRegisteredError when it isn't in the + * plugins list, and rethrows whatever the plugin's init threw. + */ + plugin(key: PluginKey): Api { + const name = pluginKeyName(key as PluginKey); + const slot = this.plugins.get(name); + if (!slot) throw new PluginNotRegisteredError(name); + if (!slot.built) { + slot.built = true; + try { + slot.api = slot.plugin.init(this.pluginContext(name)); + } catch (err) { + slot.error = err; + } + } + if (slot.error !== undefined) throw slot.error; + return slot.api as Api; + } + + /** Whether a plugin was added to the plugins list. */ + hasPlugin(key: PluginKey): boolean { + return this.plugins.has(pluginKeyName(key)); + } + + /** + * Listen for a lifecycle event: signedIn, signedOut, sessionRefreshed + * (new tokens for the same session) or orgChanged. Returns a function + * that stops listening. + */ + on(event: AuthEvent, listener: () => void): () => void { + let set = this.eventListeners.get(event); + if (!set) { + set = new Set(); + this.eventListeners.set(event, set); + } + set.add(listener); + return () => { + set.delete(listener); + }; + } + + // ── Active organization ─────────────────────────── + + /** + * The organization the session is working in, null when it has none. It + * is restored from /v1/me on load, set by switchOrg, and cleared on sign-out. + */ + getActiveOrgId(): string | null { + return this.activeOrgId; + } + + /** + * Switch the session into another organization. On an app that issues + * JWTs the server hands back a new token carrying the organization; it + * replaces the stored one. Fires orgChanged. + */ + async switchOrg(orgId: string): Promise { + if (this.state.status !== "authenticated") { + throw new Error("authsome: you have to be signed in to switch organization"); + } + const { user, session } = this.state; + const res = await this.client.switchOrg( + { org_id: orgId }, + this.cookieSession ? "" : session.session_token, + ); + + if (res.session_token && !this.cookieSession) { + const next: Session = { + ...session, + session_token: res.session_token, + expires_at: res.expires_at ?? session.expires_at, + }; + await this.persistSession(next); + this.setState({ status: "authenticated", user, session: next }); + this.scheduleRefresh(next); + } + + const changed = this.activeOrgId !== (res.org_id ?? orgId); + this.activeOrgId = res.org_id ?? orgId; + if (changed) this.emit("orgChanged"); + } + destroy(): void { this.active = false; this.clearRefreshTimer(); this.listeners.clear(); this.configListeners.clear(); + this.eventListeners.clear(); + } + + // ── Plugin context ──────────────────────────────── + + private pluginContext(name: string): PluginContext { + return { + request: (method, path, init) => this.pluginRequest(method, path, init), + getState: () => this.state, + getClientConfig: () => this.clientConfig, + isServerEnabled: () => { + const plugins = this.clientConfig?.supported_plugins; + return plugins ? plugins.includes(name) : null; + }, + getActiveOrgId: () => this.activeOrgId, + on: (event, listener) => this.on(event, listener), + }; + } + + private async pluginRequest( + method: string, + path: string, + init?: { + query?: Record; + body?: unknown; + }, + ): Promise { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(init?.query ?? {})) { + if (value !== undefined) params.set(key, String(value)); + } + const qs = params.toString(); + + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + }; + const token = this.cookieSession ? null : this.getSessionToken(); + if (token) headers.Authorization = `Bearer ${token}`; + if (this.publishableKey) headers["X-Publishable-Key"] = this.publishableKey; + + const response = await this.fetchFn(`${this.baseURL}${path}${qs ? `?${qs}` : ""}`, { + method, + headers, + body: init?.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + if (!response.ok) { + const data = (await response.json().catch(() => null)) as + | { error?: string; code?: number; type?: string; [k: string]: unknown } + | null; + throw new AuthClientError( + data?.error ?? `Request failed with status ${response.status}`, + data?.code ?? response.status, + data?.type, + data ?? undefined, + ); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; + } + + private emit(event: AuthEvent): void { + for (const listener of this.eventListeners.get(event) ?? []) { + try { + listener(); + } catch { + // A plugin's listener must not break the state machine. + } + } } // ── Internals ───────────────────────────────────── @@ -911,7 +1097,10 @@ export class AuthManager { } private setState(newState: AuthState): void { + const prev = this.state; this.state = newState; + const events = this.transitionEvents(prev, newState); + for (const listener of this.listeners) { try { listener(newState); @@ -919,6 +1108,43 @@ export class AuthManager { // Listener errors should not break the state machine. } } + for (const event of events) this.emit(event); + } + + /** + * Works out which lifecycle events a state change means, and keeps the + * active organization in step: /v1/me reports it on every load, and + * leaving the authenticated state forgets it. + */ + private transitionEvents(prev: AuthState, next: AuthState): AuthEvent[] { + const wasIn = prev.status === "authenticated"; + const isIn = next.status === "authenticated"; + const events: AuthEvent[] = []; + + if (wasIn && !isIn) { + this.activeOrgId = null; + events.push("signedOut"); + return events; + } + if (!isIn) return events; + + const reported = + (next.user as User & { active_org_id?: string }).active_org_id ?? null; + if (!wasIn) { + this.activeOrgId = reported; + events.push("signedIn"); + return events; + } + // Still signed in. A refresh rotates the refresh token; switching + // organization reissues only the access token, so it doesn't count. + if (prev.session.refresh_token !== next.session.refresh_token) { + events.push("sessionRefreshed"); + } + if (reported !== null && reported !== this.activeOrgId) { + this.activeOrgId = reported; + events.push("orgChanged"); + } + return events; } private handleError(err: unknown): void { diff --git a/ui/packages/core/src/index.ts b/ui/packages/core/src/index.ts index 9f6e696e..a5c73eb6 100644 --- a/ui/packages/core/src/index.ts +++ b/ui/packages/core/src/index.ts @@ -3,6 +3,15 @@ export * from "./types"; export * from "./client"; export { AuthManager, createLocalStorage, SESSION_STORAGE_KEY } from "./auth"; +export { + definePlugin, + PluginNotRegisteredError, + type AuthEvent, + type AuthPlugin, + type PluginContext, + type PluginDefinition, + type PluginKey, +} from "./plugins"; export { isSafeRedirect, safeRedirectTarget, diff --git a/ui/packages/core/src/plugins.test.ts b/ui/packages/core/src/plugins.test.ts new file mode 100644 index 00000000..f9681bc9 --- /dev/null +++ b/ui/packages/core/src/plugins.test.ts @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AuthManager, SESSION_STORAGE_KEY } from "./auth"; +import { definePlugin, PluginNotRegisteredError, type PluginContext } from "./plugins"; +import type { AuthConfig, ClientConfig, Session, User } from "./types"; + +const user = { id: "usr_1", email: "ada@test" } as User; +const managers: AuthManager[] = []; +afterEach(() => managers.splice(0).forEach((m) => m.destroy())); +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +function setup(opts: { authenticated?: boolean; me?: Record; config?: Partial } = {}) { + const session: Session = { + session_token: "token-1", refresh_token: "refresh-1", + expires_at: new Date(Date.now() + 3_600_000).toISOString(), + }; + const store = new Map(); + if (opts.authenticated) store.set(SESSION_STORAGE_KEY, JSON.stringify(session)); + const routes = new Map Response | Promise>([ + ["GET /v1/me", () => json({ ...user, ...opts.me })], + ["POST /v1/signout", () => json({ status: "ok" })], + ]); + const fetch = vi.fn(async (input, init) => { + const url = new URL(String(input)); + const handler = routes.get(`${init?.method ?? "GET"} ${url.pathname}`); + if (!handler) throw new Error(`Unexpected route: ${init?.method} ${url.pathname}`); + return handler(init); + }); + const manager = new AuthManager({ + baseURL: "https://api.test", fetch, + storage: { + getItem: (k) => store.get(k) ?? null, + setItem: (k, v) => { store.set(k, v); }, + removeItem: (k) => { store.delete(k); }, + }, + ...opts.config, + }); + managers.push(manager); + return { manager, routes, fetch, store, session }; +} + +describe("plugins", () => { + const counter = definePlugin("counter", (ctx: PluginContext) => ({ ctx, value: 42 })); + + it("returns what init returned, built once and lazily", async () => { + const init = vi.fn(() => ({ n: 1 })); + const lazy = definePlugin("lazy", init); + const { manager } = setup({ config: { plugins: [lazy()] } }); + expect(init).not.toHaveBeenCalled(); + const first = manager.plugin(lazy); + expect(first).toEqual({ n: 1 }); + expect(manager.plugin(lazy)).toBe(first); + expect(init).toHaveBeenCalledTimes(1); + }); + + it("finds a plugin by its definition or its instance", () => { + const instance = counter(); + const { manager } = setup({ config: { plugins: [instance] } }); + expect(manager.plugin(counter).value).toBe(42); + expect(manager.plugin(instance).value).toBe(42); + expect(manager.hasPlugin(counter)).toBe(true); + }); + + it("throws a named error for a plugin that wasn't registered", () => { + const { manager } = setup(); + expect(manager.hasPlugin(counter)).toBe(false); + expect(() => manager.plugin(counter)).toThrow(PluginNotRegisteredError); + expect(() => manager.plugin(counter)).toThrow(/add counter\(\) to the plugins/i); + }); + + it("refuses the same plugin twice", () => { + expect(() => setup({ config: { plugins: [counter(), counter()] } })).toThrow(/registered twice/); + }); + + it("surfaces an init error on first use, not at construction", () => { + const broken = definePlugin("broken", () => { + throw new Error("bad config"); + }); + const { manager } = setup({ config: { plugins: [broken()] } }); + expect(() => manager.plugin(broken)).toThrow("bad config"); + }); + + it("passes options from the registration to init", () => { + const withOpts = definePlugin("opts", (_ctx, options?: { greeting: string }) => options?.greeting); + const { manager } = setup({ config: { plugins: [withOpts({ greeting: "hi" })] } }); + expect(manager.plugin(withOpts)).toBe("hi"); + }); + + it("reports whether the server has the plugin", () => { + const config = { supported_plugins: ["counter"] } as unknown as ClientConfig; + const unknown = setup({ config: { plugins: [counter()] } }).manager.plugin(counter).ctx; + expect(unknown.isServerEnabled()).toBeNull(); + + const known = setup({ config: { plugins: [counter()], initialClientConfig: config } }).manager.plugin(counter).ctx; + expect(known.isServerEnabled()).toBe(true); + + const other = definePlugin("other", (ctx) => ctx); + const missing = setup({ config: { plugins: [other()], initialClientConfig: config } }).manager.plugin(other); + expect(missing.isServerEnabled()).toBe(false); + }); + + it("sends authenticated requests with the session token and query", async () => { + const { manager, routes, fetch } = setup({ authenticated: true, config: { plugins: [counter()], publishableKey: "pk_test" } }); + routes.set("GET /v1/billing/subscription", () => json({ subscription: null })); + await manager.initialize(); + const ctx = manager.plugin(counter).ctx; + + const body = await ctx.request<{ subscription: null }>("GET", "/v1/billing/subscription", { query: { tenant_id: "org_1", skip: undefined } }); + expect(body).toEqual({ subscription: null }); + const [url, init] = fetch.mock.calls.at(-1)!; + expect(String(url)).toBe("https://api.test/v1/billing/subscription?tenant_id=org_1"); + expect((init?.headers as Record).Authorization).toBe("Bearer token-1"); + expect((init?.headers as Record)["X-Publishable-Key"]).toBe("pk_test"); + }); + + it("sends credentials instead of a token in cookie mode", async () => { + const { manager, routes, fetch } = setup({ config: { plugins: [counter()], session: "cookie" } }); + routes.set("POST /v1/billing/subscription", () => json({ ok: true })); + await manager.plugin(counter).ctx.request("POST", "/v1/billing/subscription", { body: { plan: "pro" } }); + const [, init] = fetch.mock.calls.at(-1)!; + expect(init?.credentials).toBe("include"); + expect((init?.headers as Record).Authorization).toBeUndefined(); + expect(init?.body).toBe(JSON.stringify({ plan: "pro" })); + }); + + it("throws AuthClientError with the server's message", async () => { + const { manager, routes } = setup({ config: { plugins: [counter()] } }); + routes.set("GET /v1/thing", () => json({ error: "nope", type: "forbidden" }, 403)); + await expect(manager.plugin(counter).ctx.request("GET", "/v1/thing")).rejects.toMatchObject({ + message: "nope", code: 403, type: "forbidden", + }); + }); +}); + +describe("lifecycle events", () => { + it("fires signedIn, sessionRefreshed and signedOut once each", async () => { + const { manager, routes } = setup({ authenticated: true }); + const seen: string[] = []; + for (const e of ["signedIn", "signedOut", "sessionRefreshed", "orgChanged"] as const) { + manager.on(e, () => seen.push(e)); + } + await manager.initialize(); + expect(seen).toEqual(["signedIn"]); + + routes.set("POST /v1/refresh", () => json({ session_token: "token-2", refresh_token: "refresh-2", expires_at: new Date(Date.now() + 3_600_000).toISOString() })); + await manager.refreshNow(); + expect(seen).toEqual(["signedIn", "sessionRefreshed"]); + + await manager.signOut(); + expect(seen).toEqual(["signedIn", "sessionRefreshed", "signedOut"]); + }); + + it("stops calling a listener once it unsubscribes", async () => { + const { manager } = setup({ authenticated: true }); + const listener = vi.fn(); + const off = manager.on("signedIn", listener); + off(); + await manager.initialize(); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("active organization", () => { + it("restores the active org from /v1/me", async () => { + const { manager } = setup({ authenticated: true, me: { active_org_id: "org_1" } }); + expect(manager.getActiveOrgId()).toBeNull(); + await manager.initialize(); + expect(manager.getActiveOrgId()).toBe("org_1"); + }); + + it("switches org, keeps a reissued token and announces it", async () => { + const { manager, routes, store } = setup({ authenticated: true }); + await manager.initialize(); + const orgChanged = vi.fn(); + manager.on("orgChanged", orgChanged); + routes.set("POST /v1/me/switch-org", (init) => { + expect(JSON.parse(String(init?.body))).toEqual({ org_id: "org_2" }); + expect((init?.headers as Record).Authorization).toBe("Bearer token-1"); + return json({ session_id: "ses_1", org_id: "org_2", session_token: "token-org2", expires_at: new Date(Date.now() + 3_600_000).toISOString() }); + }); + + await manager.switchOrg("org_2"); + expect(manager.getActiveOrgId()).toBe("org_2"); + expect(orgChanged).toHaveBeenCalledTimes(1); + expect(manager.getSessionToken()).toBe("token-org2"); + expect(JSON.parse(store.get(SESSION_STORAGE_KEY)!).session_token).toBe("token-org2"); + }); + + it("refuses to switch when signed out", async () => { + const { manager } = setup(); + await expect(manager.switchOrg("org_2")).rejects.toThrow(/signed in/); + }); + + it("forgets the org on sign-out and doesn't hand it to the next user", async () => { + const { manager, routes } = setup({ authenticated: true, me: { active_org_id: "org_1" } }); + await manager.initialize(); + expect(manager.getActiveOrgId()).toBe("org_1"); + await manager.signOut(); + expect(manager.getActiveOrgId()).toBeNull(); + + routes.set("POST /v1/signin", () => json({ user: { ...user, id: "usr_2" }, session_token: "t", refresh_token: "r", expires_at: new Date(Date.now() + 3_600_000).toISOString() })); + await manager.signIn({ email: "grace@test", password: "pw" }); + expect(manager.getActiveOrgId()).toBeNull(); + }); +}); diff --git a/ui/packages/core/src/plugins.ts b/ui/packages/core/src/plugins.ts new file mode 100644 index 00000000..2cadee7d --- /dev/null +++ b/ui/packages/core/src/plugins.ts @@ -0,0 +1,110 @@ +/** + * Client plugins: the client side of authsome's server plugins. + * + * A server plugin is something you opt into with `authsome.WithPlugin(...)`. + * Its client half is the same: a package you opt into by adding it to the + * `plugins` list of `AuthManager` (or ``), which then gets an + * authenticated way to call its routes and hears about sign-in, sign-out, + * refreshes and organization switches. Core knows nothing about any + * particular plugin. + * + * ```ts + * import { subscription } from "@authsome/plugin-subscription"; + * + * const auth = new AuthManager({ baseURL, plugins: [subscription()] }); + * const billing = auth.plugin(subscription); + * ``` + */ + +import type { AuthState, ClientConfig } from "./types"; + +/** Moments in the session a plugin can react to. */ +export type AuthEvent = "signedIn" | "signedOut" | "sessionRefreshed" | "orgChanged"; + +/** + * What a plugin gets from the manager. Kept deliberately small: once plugin + * packages version on their own, every member here is public API. + */ +export interface PluginContext { + /** + * Call the API as the signed-in user: base URL, publishable key and the + * session's bearer token (or cookie, in cookie mode) are filled in. Query + * values that are undefined are left out. Rejects with AuthClientError. + */ + request( + method: string, + path: string, + init?: { + query?: Record; + body?: unknown; + }, + ): Promise; + /** The current auth state. */ + getState(): AuthState; + /** The client config, null until it has loaded. */ + getClientConfig(): ClientConfig | null; + /** + * Whether the server has this plugin installed, from the client config's + * supported_plugins. null while that isn't known: no publishable key, or + * the config hasn't loaded yet. + */ + isServerEnabled(): boolean | null; + /** The session's active organization, null when it has none. */ + getActiveOrgId(): string | null; + /** Listen for a lifecycle event. Returns a function that stops listening. */ + on(event: AuthEvent, listener: () => void): () => void; +} + +/** A registered plugin: a name matching the server plugin's, and its init. */ +export interface AuthPlugin { + /** The server plugin's Name(), e.g. "subscription". */ + readonly name: string; + /** Builds the plugin's API. Runs once, the first time the plugin is used. */ + init(ctx: PluginContext): Api; +} + +/** + * What definePlugin returns: call it to register the plugin (with options, + * if it takes any), and pass it to `manager.plugin(...)` to look it up. + */ +export interface PluginDefinition { + (options?: Options): AuthPlugin; + readonly pluginName: string; +} + +/** Either a definition or a registered instance; both identify a plugin. */ +export type PluginKey = + | AuthPlugin + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the lookup only reads pluginName + | PluginDefinition; + +/** + * Defines a client plugin. `name` must be the server plugin's name, so the + * plugin can tell from the client config whether the server side is there. + */ +export function definePlugin( + name: string, + init: (ctx: PluginContext, options: Options | undefined) => Api, +): PluginDefinition { + const define = (options?: Options): AuthPlugin => ({ + name, + init: (ctx) => init(ctx, options), + }); + return Object.assign(define, { pluginName: name }); +} + +/** Thrown when code asks for a plugin that wasn't added to the plugins list. */ +export class PluginNotRegisteredError extends Error { + constructor(readonly pluginName: string) { + super( + `authsome: the "${pluginName}" plugin isn't registered. ` + + `Add ${pluginName}() to the plugins of your AuthManager or .`, + ); + this.name = "PluginNotRegisteredError"; + } +} + +/** The name a key refers to. */ +export function pluginKeyName(key: PluginKey): string { + return "pluginName" in key ? key.pluginName : key.name; +} diff --git a/ui/packages/core/src/types.ts b/ui/packages/core/src/types.ts index 78319e09..1e16bae4 100644 --- a/ui/packages/core/src/types.ts +++ b/ui/packages/core/src/types.ts @@ -1,6 +1,7 @@ /** Core type definitions for AuthSome UI Kit. */ import type { User } from "./generated/api-types"; +import type { AuthPlugin } from "./plugins"; // ── Re-export API types from generated code ────────── // @@ -132,6 +133,13 @@ export interface AuthConfig { */ session?: "token" | "cookie"; + /** + * Client plugins to load, the client half of the server plugins you + * installed, e.g. `[subscription()]`. Look one up with + * `manager.plugin(subscription)`. + */ + plugins?: AuthPlugin[]; + /** Callback invoked when the auth state changes. */ onStateChange?: (state: AuthState) => void; From 4560563d9d84df640d2a02766b22b03d516c3f9a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 09:06:27 -0600 Subject: [PATCH 04/12] feat(ui-react)!: add usePlugin and useActiveOrg; make Protect fail closed usePlugin(subscription) returns a registered client plugin's API, and useActiveOrg() the session's organization with a switchOrg that re-renders on change. AuthProvider takes plugins through its config. BREAKING CHANGE: and used to render their children for everyone. role now checks the roles /v1/me returns. permission can't be checked on the client, so without a condition that checks it Protect shows the fallback and warns once. Pass a condition to keep a permission-gated Protect working. --- ui/packages/react/src/components.tsx | 50 ++++++---- ui/packages/react/src/hooks.ts | 65 ++++++++++++- ui/packages/react/src/index.ts | 6 +- ui/packages/react/src/plugins.test.tsx | 122 +++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 20 deletions(-) create mode 100644 ui/packages/react/src/plugins.test.tsx diff --git a/ui/packages/react/src/components.tsx b/ui/packages/react/src/components.tsx index b56a67dd..f3349c30 100644 --- a/ui/packages/react/src/components.tsx +++ b/ui/packages/react/src/components.tsx @@ -1,6 +1,6 @@ /** Headless UI components for common auth flows. */ -import { useCallback, useState, type FormEvent, type ReactNode } from "react"; +import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from "react"; import { useAuth, type AuthContextValue } from "./context"; // ── Sign In ───────────────────────────────────────── @@ -219,17 +219,35 @@ export interface ProtectProps { children: ReactNode; /** Fallback rendered when the user doesn't meet the requirements. */ fallback?: ReactNode; - /** Require a specific role (e.g. "admin"). */ + /** Require an app role the user holds (e.g. "admin"), from /v1/me's roles. */ role?: string; - /** Require a specific permission (e.g. "packages:write"). */ + /** + * Require a permission (e.g. "packages:write"). The client can't check a + * permission on its own, so this needs a `condition` that does; without + * one, Protect shows the fallback. + */ permission?: string; /** Custom condition receiving the auth context. */ condition?: (auth: AuthContextValue) => boolean; } +let warnedPermission = false; + +/** Warns once per page that a permission-only Protect can't be satisfied. */ +function warnUncheckedPermission(permission: string): void { + if (warnedPermission) return; + warnedPermission = true; + console.warn( + `authsome: needs a condition that checks the permission. ` + + "The client can't check one by itself, so it shows the fallback.", + ); +} + /** * Renders children only when the user is authenticated AND meets - * the specified role, permission, or custom condition. + * the specified role, permission, or custom condition. Anything it can't + * check, it treats as not met: a guard that guesses would show protected + * UI to the wrong people. * * Without role/permission/condition, behaves like ``. * @@ -247,24 +265,22 @@ export function Protect({ condition, }: ProtectProps) { const auth = useAuth(); + const unchecked = Boolean(permission) && !condition; + useEffect(() => { + if (unchecked && permission) warnUncheckedPermission(permission); + }, [unchecked, permission]); if (auth.isLoading) return null; if (!auth.isAuthenticated) return <>{fallback}; - // Custom condition check - if (condition && !condition(auth)) return <>{fallback}; - - // Role/permission checks are left to the consumer's RBAC layer - // since the auth context doesn't carry roles directly. The - // `condition` prop covers this use case generically. - if (role || permission) { - // These are checked via condition or by a higher-level wrapper - // that injects role/permission data. - if (condition === undefined) { - // No condition provided but role/permission requested — pass through - // (the consumer should wrap with their own RBAC provider). - } + if (role) { + const roles = (auth.user as { roles?: string[] } | null)?.roles ?? []; + if (!roles.includes(role)) return <>{fallback}; } + if (unchecked) return <>{fallback}; + + if (condition && !condition(auth)) return <>{fallback}; + return <>{children}; } diff --git a/ui/packages/react/src/hooks.ts b/ui/packages/react/src/hooks.ts index b9e0b82c..b4c4c2e8 100644 --- a/ui/packages/react/src/hooks.ts +++ b/ui/packages/react/src/hooks.ts @@ -1,8 +1,8 @@ /** Convenience hooks built on top of useAuth. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { readOrganizationList } from "@authsome/ui-core"; -import type { ClientConfig, Organization, OrganizationList, User } from "@authsome/ui-core"; +import type { ClientConfig, Organization, OrganizationList, PluginKey, User } from "@authsome/ui-core"; import { useAuth } from "./context"; /** @@ -116,6 +116,67 @@ export function useOrganizations(): { }; } +/** + * usePlugin returns a client plugin's API. The plugin has to be in the + * `plugins` of the surrounding ``; if it isn't, this throws an + * error that says which one to add. + * + * ```tsx + * ... + * const billing = usePlugin(subscription); + * ``` + */ +export function usePlugin(plugin: PluginKey): Api { + const { manager } = useAuth(); + return manager.plugin(plugin); +} + +/** + * useActiveOrg returns the session's active organization and a way to + * switch it. It re-renders when the organization changes, including on + * sign-in (restored from the session) and sign-out (cleared). + * + * ```tsx + * const { orgId, switchOrg } = useActiveOrg(); + * ``` + */ +export function useActiveOrg(): { + orgId: string | null; + switchOrg: (orgId: string) => Promise; + isSwitching: boolean; +} { + const { manager } = useAuth(); + const subscribe = useCallback( + (onChange: () => void) => { + const offs = (["orgChanged", "signedIn", "signedOut"] as const).map((e) => + manager.on(e, onChange), + ); + return () => offs.forEach((off) => off()); + }, + [manager], + ); + const orgId = useSyncExternalStore( + subscribe, + () => manager.getActiveOrgId(), + () => manager.getActiveOrgId(), + ); + + const [isSwitching, setIsSwitching] = useState(false); + const switchOrg = useCallback( + async (next: string) => { + setIsSwitching(true); + try { + await manager.switchOrg(next); + } finally { + setIsSwitching(false); + } + }, + [manager], + ); + + return { orgId, switchOrg, isSwitching }; +} + /** * useSessionToken returns the current session token (or null). * diff --git a/ui/packages/react/src/index.ts b/ui/packages/react/src/index.ts index 4b52d5c5..b6e25c56 100644 --- a/ui/packages/react/src/index.ts +++ b/ui/packages/react/src/index.ts @@ -17,11 +17,15 @@ export type { MFAEnrollment, APIError, TokenStorage, + AuthPlugin, + AuthEvent, + PluginContext, + PluginKey, } from "@authsome/ui-core"; // Context + hooks export { AuthProvider, useAuth, AuthContext, type AuthProviderProps, type AuthContextValue } from "./context"; -export { useUser, useOrganizations, useSessionToken, useClientConfig } from "./hooks"; +export { useUser, useOrganizations, useSessionToken, useClientConfig, usePlugin, useActiveOrg } from "./hooks"; // Headless components export { diff --git a/ui/packages/react/src/plugins.test.tsx b/ui/packages/react/src/plugins.test.tsx new file mode 100644 index 00000000..ec03ca85 --- /dev/null +++ b/ui/packages/react/src/plugins.test.tsx @@ -0,0 +1,122 @@ +import { definePlugin } from "@authsome/ui-core"; +import type { AuthPlugin } from "@authsome/ui-core"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { Protect } from "./components"; +import { AuthProvider } from "./context"; +import { useActiveOrg, usePlugin } from "./hooks"; + +const BASE = "https://api.example.test"; + +function json(body: unknown) { + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +function harness(opts: { me?: Record; plugins?: AuthPlugin[] } = {}) { + const store = new Map([[ + "authsome:session", + JSON.stringify({ session_token: "tok", refresh_token: "refresh", expires_at: new Date(Date.now() + 3_600_000).toISOString() }), + ]]); + const fetchFn = (async (url: string | URL, init?: RequestInit) => { + const path = new URL(String(url)).pathname; + if (path === "/v1/me") return json({ id: "usr_1", email: "a@test", roles: ["member"], ...opts.me }); + if (path === "/v1/me/switch-org") { + const { org_id } = JSON.parse(String(init?.body)); + return json({ session_id: "ses_1", org_id }); + } + return new Response("{}", { status: 404 }); + }) as unknown as typeof fetch; + + const wrap = (children: ReactNode) => ( + store.get(k) ?? null, + setItem: (k, v) => void store.set(k, v), + removeItem: (k) => void store.delete(k), + }}> + {children} + + ); + + return { + async mount(node: ReactNode) { + let out!: ReturnType; + await act(async () => { out = render(wrap(node)); }); + return out; + }, + }; +} + +const greeter = definePlugin("greeter", () => ({ hello: () => "hi" })); + +describe("usePlugin", () => { + it("returns the registered plugin's API", async () => { + let seen: ReturnType string }>> | null = null; + function Probe() { + seen = usePlugin(greeter); + return null; + } + await harness({ plugins: [greeter()] }).mount(); + expect(seen!.hello()).toBe("hi"); + }); + + it("throws a helpful error when the plugin isn't registered", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + function Probe() { + usePlugin(greeter); + return null; + } + await expect(harness().mount()).rejects.toThrow(/greeter\(\)/); + spy.mockRestore(); + }); +}); + +describe("useActiveOrg", () => { + it("reports the restored org and re-renders on a switch", async () => { + let seen = null as unknown as ReturnType; + function Probe() { + seen = useActiveOrg(); + return {seen.orgId ?? "none"}; + } + await harness({ me: { active_org_id: "org_1" } }).mount(); + await waitFor(() => expect(screen.getByTestId("org").textContent).toBe("org_1")); + + await act(async () => { await seen.switchOrg("org_2"); }); + expect(screen.getByTestId("org").textContent).toBe("org_2"); + expect(seen.isSwitching).toBe(false); + }); +}); + +describe("Protect", () => { + it("shows children only to holders of the role", async () => { + const h = harness({ me: { roles: ["admin"] } }); + await h.mount( + <> + no-admin}>admin-panel + no-owner}>owner-panel + , + ); + await waitFor(() => expect(screen.queryByText("admin-panel")).not.toBeNull()); + expect(screen.queryByText("owner-panel")).toBeNull(); + expect(screen.queryByText("no-owner")).not.toBeNull(); + }); + + it("fails closed for a permission it can't check", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await harness({ me: { roles: ["admin"] } }).mount( + hidden}>secret, + ); + await waitFor(() => expect(screen.queryByText("hidden")).not.toBeNull()); + expect(screen.queryByText("secret")).toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/permission/)); + warn.mockRestore(); + }); + + it("lets a condition decide a permission", async () => { + await harness().mount( + true} fallback={hidden}>shown, + ); + await waitFor(() => expect(screen.queryByText("shown")).not.toBeNull()); + }); +}); From 99c9071b0b1c13fca14cea64584add368d5e2f74 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 09:17:32 -0600 Subject: [PATCH 05/12] feat(authsome_core): add a client plugin contract and track the active organization The Dart side of the ui-core change. Pass plugins: [SomePlugin()] in AuthConfig and look one up with manager.plugin(). A plugin gets a PluginContext with an authenticated request, the auth state, the client config, whether the server has it installed, the active organization and an events stream. AuthManager keeps the session's active organization from /v1/me, switchOrg changes it (keeping a reissued token), and sign-out clears it. AuthSomeClient gains send(), the raw request plugins use. --- .../authsome_core/lib/authsome_core.dart | 1 + .../authsome_core/lib/src/auth_manager.dart | 199 +++++++++++++++- .../authsome_core/lib/src/client.dart | 48 ++++ .../authsome_core/lib/src/plugins.dart | 88 +++++++ .../packages/authsome_core/lib/src/types.dart | 8 + .../authsome_core/test/plugins_test.dart | 223 ++++++++++++++++++ 6 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 flutter/packages/authsome_core/lib/src/plugins.dart create mode 100644 flutter/packages/authsome_core/test/plugins_test.dart diff --git a/flutter/packages/authsome_core/lib/authsome_core.dart b/flutter/packages/authsome_core/lib/authsome_core.dart index 23b84c6e..fa97b4b0 100644 --- a/flutter/packages/authsome_core/lib/authsome_core.dart +++ b/flutter/packages/authsome_core/lib/authsome_core.dart @@ -12,6 +12,7 @@ export 'src/types.dart'; export 'src/client.dart'; export 'src/auth_manager.dart'; export 'src/exceptions.dart'; +export 'src/plugins.dart'; export 'src/passkey_authenticator.dart'; export 'src/webauthn.dart' show prepareRequestOptions, prepareCreationOptions, serializeAssertion; export 'src/generated/api_client.dart' diff --git a/flutter/packages/authsome_core/lib/src/auth_manager.dart b/flutter/packages/authsome_core/lib/src/auth_manager.dart index 173c7335..393b7cb7 100644 --- a/flutter/packages/authsome_core/lib/src/auth_manager.dart +++ b/flutter/packages/authsome_core/lib/src/auth_manager.dart @@ -15,6 +15,7 @@ import 'package:meta/meta.dart'; import 'client.dart'; import 'exceptions.dart'; import 'passkey_authenticator.dart'; +import 'plugins.dart'; import 'types.dart'; import 'webauthn.dart'; @@ -52,6 +53,15 @@ class AuthManager { /// the UI without callers having to pass it again. String? _pendingEmail; + final Map _plugins = {}; + final StreamController _events = + StreamController.broadcast(sync: true); + String? _activeOrgId; + + /// The active organization /v1/me reported on the last profile load. The + /// next transition to authenticated consumes it. + String? _reportedOrgId; + AuthManager(AuthConfig config) : _client = AuthSomeClient( AuthClientConfig( @@ -70,6 +80,7 @@ class AuthManager { if (config.onStateChange != null) { _listeners.add(config.onStateChange!); } + _registerPlugins(config.plugins); } /// Test-only constructor that accepts a pre-built [AuthSomeClient]. @@ -83,6 +94,7 @@ class AuthManager { ClientConfig? initialClientConfig, void Function(String, int?)? onError, void Function(AuthState)? onStateChange, + List> plugins = const [], }) : _client = client, _storage = storage ?? MemoryTokenStorage(), _onError = onError, @@ -93,6 +105,102 @@ class AuthManager { if (onStateChange != null) { _listeners.add(onStateChange); } + _registerPlugins(plugins); + } + + void _registerPlugins(List> plugins) { + for (final plugin in plugins) { + if (_plugins.containsKey(plugin.name)) { + throw ArgumentError.value( + plugin.name, 'plugins', 'the "${plugin.name}" plugin is registered twice'); + } + _plugins[plugin.name] = _PluginSlot(plugin); + } + } + + // ── Plugins ─────────────────────────────────────── + + /// The API of the registered plugin that provides [Api], built on first + /// use: `auth.plugin()`. Throws + /// [PluginNotRegisteredError] when no registered plugin provides it, and + /// rethrows whatever the plugin's init threw. + Api plugin() { + final slot = _slotFor(); + if (slot == null) throw PluginNotRegisteredError(Api); + if (!slot.built) { + slot.built = true; + try { + slot.api = slot.plugin.init(_PluginContextImpl(this, slot.plugin.name)); + } catch (error, stack) { + slot.error = error; + slot.stack = stack; + } + } + if (slot.error != null) { + Error.throwWithStackTrace(slot.error!, slot.stack ?? StackTrace.current); + } + return slot.api! as Api; + } + + /// Whether a registered plugin provides [Api]. + bool hasPlugin() => _slotFor() != null; + + _PluginSlot? _slotFor() { + for (final slot in _plugins.values) { + if (slot.plugin is AuthPlugin) return slot; + } + return null; + } + + /// Lifecycle events: [AuthEvent.signedIn], [AuthEvent.signedOut], + /// [AuthEvent.sessionRefreshed] and [AuthEvent.orgChanged]. + Stream get events => _events.stream; + + // ── Active organization ─────────────────────────── + + /// The organization the session is working in, null when it has none. + /// Restored from /v1/me on load, set by [switchOrg], cleared on sign-out. + String? get activeOrgId => _activeOrgId; + + /// Switches the session into another organization. On an app that issues + /// JWTs the server hands back a new token carrying the organization; it + /// replaces the stored one. Fires [AuthEvent.orgChanged]. + Future switchOrg(String orgId) async { + final current = _state; + if (current is! AuthAuthenticated) { + throw StateError('authsome: you have to be signed in to switch organization'); + } + final res = await _client.switchOrg( + body: SwitchOrgRequest(orgId: orgId), + token: current.session.sessionToken, + ); + + final token = res.sessionToken; + if (token != null && token.isNotEmpty) { + final next = Session( + sessionToken: token, + refreshToken: current.session.refreshToken, + expiresAt: res.expiresAt ?? current.session.expiresAt, + ); + await _persistSession(next); + _setState(AuthAuthenticated(user: current.user, session: next)); + _scheduleRefresh(next); + } + + final now = res.orgId ?? orgId; + final changed = _activeOrgId != now; + _activeOrgId = now; + if (changed) _emit(AuthEvent.orgChanged); + } + + Future _loadUser(String token) async { + final me = await _client.getMe(token: token); + _reportedOrgId = me.activeOrgId; + return User.fromJson(me.toJson()); + } + + void _emit(AuthEvent event) { + if (!_events.isClosed) _events.add(event); } // ── Public API ──────────────────────────────────── @@ -155,7 +263,7 @@ class AuthManager { // Token still valid — fetch user profile. _setState(const AuthLoading()); - final user = await _client.getMeWithToken(session.sessionToken); + final user = await _loadUser(session.sessionToken); _setState(AuthAuthenticated(user: user, session: session)); _scheduleRefresh(session); } catch (_) { @@ -538,6 +646,7 @@ class AuthManager { _clearRefreshTimer(); _listeners.clear(); _configListeners.clear(); + _events.close(); } // ── Internals ───────────────────────────────────── @@ -602,7 +711,7 @@ class AuthManager { Future _refreshSession(String refreshToken) async { try { final newSession = await _client.refreshWithToken(refreshToken); - final user = await _client.getMeWithToken(newSession.sessionToken); + final user = await _loadUser(newSession.sessionToken); await _persistSession(newSession); _setState(AuthAuthenticated(user: user, session: newSession)); _scheduleRefresh(newSession); @@ -671,7 +780,9 @@ class AuthManager { } void _setState(AuthState newState) { + final prev = _state; _state = newState; + final events = _transitionEvents(prev, newState); for (final listener in _listeners) { try { listener(newState); @@ -679,6 +790,39 @@ class AuthManager { // Listener errors should not break the state machine. } } + for (final event in events) { + _emit(event); + } + } + + /// Works out which lifecycle events a state change means, and keeps the + /// active organization in step: /v1/me reports it on every load, and + /// leaving the authenticated state forgets it. + List _transitionEvents(AuthState prev, AuthState next) { + final reported = _reportedOrgId; + _reportedOrgId = null; + + if (prev is AuthAuthenticated && next is! AuthAuthenticated) { + _activeOrgId = null; + return const [AuthEvent.signedOut]; + } + if (next is! AuthAuthenticated) return const []; + if (prev is! AuthAuthenticated) { + _activeOrgId = reported; + return const [AuthEvent.signedIn]; + } + + final events = []; + // A refresh rotates the refresh token; switching organization reissues + // only the access token, so it doesn't count. + if (prev.session.refreshToken != next.session.refreshToken) { + events.add(AuthEvent.sessionRefreshed); + } + if (reported != null && reported != _activeOrgId) { + _activeOrgId = reported; + events.add(AuthEvent.orgChanged); + } + return events; } void _handleError(Object err) { @@ -709,3 +853,54 @@ class AuthManager { _onError?.call(message, code); } } + +/// A registered plugin and, once used, what its init built (or threw). +class _PluginSlot { + _PluginSlot(this.plugin); + final AuthPlugin plugin; + bool built = false; + Object? api; + Object? error; + StackTrace? stack; +} + +/// The [PluginContext] one plugin sees. +class _PluginContextImpl implements PluginContext { + _PluginContextImpl(this._manager, this._name); + final AuthManager _manager; + final String _name; + + @override + Future request( + String method, + String path, { + Map? query, + Object? body, + }) { + return _manager.client.send( + method, + path, + query: query, + body: body, + token: _manager.getSessionToken(), + ); + } + + @override + AuthState get state => _manager.state; + + @override + ClientConfig? get clientConfig => _manager.clientConfig; + + @override + bool? get isServerEnabled { + final supported = _manager.clientConfig?.supportedPlugins; + return supported?.contains(_name); + } + + @override + String? get activeOrgId => _manager.activeOrgId; + + @override + Stream get events => _manager.events; +} diff --git a/flutter/packages/authsome_core/lib/src/client.dart b/flutter/packages/authsome_core/lib/src/client.dart index 1192ac13..3c23cd15 100644 --- a/flutter/packages/authsome_core/lib/src/client.dart +++ b/flutter/packages/authsome_core/lib/src/client.dart @@ -358,4 +358,52 @@ class AuthSomeClient extends generated.AuthClient { return jsonDecode(response.body) as Map; } + + /// Sends any request and returns the decoded JSON body, null when the + /// body is empty. Client plugins call their routes through this (via + /// their PluginContext). Throws [AuthClientException] on an error status. + Future send( + String method, + String path, { + Map? query, + Object? body, + String? token, + }) async { + final params = { + for (final entry in (query ?? const {}).entries) + if (entry.value != null) entry.key: entry.value!, + }; + final uri = Uri.parse('$_rawBaseUrl$path') + .replace(queryParameters: params.isEmpty ? null : params); + final request = http.Request(method, uri) + ..headers['Content-Type'] = 'application/json' + ..headers['Accept'] = 'application/json'; + if (token != null && token.isNotEmpty) { + request.headers['Authorization'] = 'Bearer $token'; + } + final pk = publishableKey; + if (pk != null && pk.isNotEmpty) { + request.headers['X-Publishable-Key'] = pk; + } + if (body != null) request.body = jsonEncode(body); + + final response = await http.Response.fromStream(await _http.send(request)); + if (response.statusCode < 200 || response.statusCode >= 300) { + Map? errorBody; + try { + errorBody = jsonDecode(response.body) as Map; + } catch (_) { + // Not JSON; fall back to the status below. + } + throw AuthClientException( + (errorBody?['error'] as String?) ?? + 'Request failed with status ${response.statusCode}', + code: response.statusCode, + type: errorBody?['type'] as String?, + details: errorBody, + ); + } + if (response.body.isEmpty) return null; + return jsonDecode(response.body); + } } diff --git a/flutter/packages/authsome_core/lib/src/plugins.dart b/flutter/packages/authsome_core/lib/src/plugins.dart new file mode 100644 index 00000000..2c0198e7 --- /dev/null +++ b/flutter/packages/authsome_core/lib/src/plugins.dart @@ -0,0 +1,88 @@ +/// Client plugins: the client side of authsome's server plugins. +/// +/// A server plugin is something you opt into with `authsome.WithPlugin`. Its +/// client half is the same: a package you add to [AuthConfig.plugins], which +/// then gets an authenticated way to call its routes and hears about sign-in, +/// sign-out, refreshes and organization switches. Core knows nothing about +/// any particular plugin. +/// +/// ```dart +/// final auth = AuthManager(AuthConfig( +/// baseUrl: 'https://api.example.com', +/// plugins: [SubscriptionPlugin()], +/// )); +/// final billing = auth.plugin(); +/// ``` +library; + +import 'types.dart'; + +/// Moments in the session a plugin can react to. +enum AuthEvent { + /// The session became authenticated: sign-in, or a session restored on load. + signedIn, + + /// The session stopped being authenticated. + signedOut, + + /// The session got new tokens (a refresh). Switching organization doesn't + /// count; it fires [orgChanged]. + sessionRefreshed, + + /// The session's active organization changed. + orgChanged, +} + +/// What a plugin gets from the [AuthManager]. Kept deliberately small: once +/// plugin packages version on their own, every member here is public API. +abstract interface class PluginContext { + /// Calls the API as the signed-in user: base URL, publishable key and the + /// session's bearer token are filled in, and query values that are null + /// are left out. Returns the decoded JSON body (null for an empty one). + /// Throws [AuthClientException] on an error status. + Future request( + String method, + String path, { + Map? query, + Object? body, + }); + + /// The current auth state. + AuthState get state; + + /// The client config, null until it has loaded. + ClientConfig? get clientConfig; + + /// Whether the server has this plugin installed, from the client config's + /// supported plugins. Null while that isn't known. + bool? get isServerEnabled; + + /// The session's active organization, null when it has none. + String? get activeOrgId; + + /// Lifecycle events. + Stream get events; +} + +/// A client plugin. [name] must be the server plugin's name, so the plugin +/// can tell from the client config whether the server side is installed. +/// +/// [init] runs once, the first time the plugin is looked up with +/// [AuthManager.plugin], and returns the plugin's API. +abstract class AuthPlugin { + const AuthPlugin(); + + /// The server plugin's name, e.g. `subscription`. + String get name; + + /// Builds the plugin's API. + Api init(PluginContext ctx); +} + +/// Thrown when code asks for a plugin that wasn't added to +/// [AuthConfig.plugins]. +class PluginNotRegisteredError extends StateError { + PluginNotRegisteredError(Type api) + : super('authsome: no registered plugin provides $api. ' + 'Add it to AuthConfig.plugins.'); +} diff --git a/flutter/packages/authsome_core/lib/src/types.dart b/flutter/packages/authsome_core/lib/src/types.dart index 7629a731..1392072b 100644 --- a/flutter/packages/authsome_core/lib/src/types.dart +++ b/flutter/packages/authsome_core/lib/src/types.dart @@ -5,6 +5,8 @@ library; import 'package:http/http.dart' as http; +import 'plugins.dart' show AuthPlugin; + // Re-export generated API types. export 'generated/api_types.dart'; @@ -597,6 +599,11 @@ class AuthConfig { /// Callback invoked on authentication error. final void Function(String error, int? code)? onError; + /// Client plugins to load, the client half of the server plugins you + /// installed, e.g. `[SubscriptionPlugin()]`. Look one up with + /// `manager.plugin()`. + final List> plugins; + const AuthConfig({ required this.baseUrl, this.publishableKey, @@ -605,6 +612,7 @@ class AuthConfig { this.httpClient, this.onStateChange, this.onError, + this.plugins = const [], }); } diff --git a/flutter/packages/authsome_core/test/plugins_test.dart b/flutter/packages/authsome_core/test/plugins_test.dart new file mode 100644 index 00000000..d4880748 --- /dev/null +++ b/flutter/packages/authsome_core/test/plugins_test.dart @@ -0,0 +1,223 @@ +import 'dart:convert'; + +import 'package:authsome_core/authsome_core.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +/// A plugin whose API hands back its context so tests can poke it. +class _ProbePlugin extends AuthPlugin<_Probe> { + _ProbePlugin({this.pluginName = 'probe', this.fail = false}); + final String pluginName; + final bool fail; + int inits = 0; + + @override + String get name => pluginName; + + @override + _Probe init(PluginContext ctx) { + inits++; + if (fail) throw StateError('bad config'); + return _Probe(ctx); + } +} + +class _Probe { + _Probe(this.ctx); + final PluginContext ctx; +} + +class _OtherPlugin extends AuthPlugin { + @override + String get name => 'other'; + @override + String init(PluginContext ctx) => 'other'; +} + +final _session = { + 'session_token': 'token-1', + 'refresh_token': 'refresh-1', + 'expires_at': DateTime.now().add(const Duration(hours: 1)).toUtc().toIso8601String(), +}; + +final _me = { + 'id': 'usr_1', + 'app_id': 'app_1', + 'env_id': 'env_1', + 'email': 'ada@test', + 'email_verified': true, + 'first_name': 'Ada', + 'last_name': 'Lovelace', + 'banned': false, + 'phone_verified': false, + 'roles': [], + 'created_at': '2026-01-01T00:00:00Z', + 'updated_at': '2026-01-01T00:00:00Z', +}; + +typedef _Routes = Map; + +({AuthManager manager, _Routes routes, List calls, MemoryTokenStorage storage}) _setup({ + bool signedIn = false, + Map me = const {}, + List> plugins = const [], + ClientConfig? clientConfig, + String? publishableKey, +}) { + final routes = { + 'GET /v1/me': (_) => http.Response(jsonEncode({..._me, ...me}), 200), + 'POST /v1/signout': (_) => http.Response('{"status":"ok"}', 200), + }; + final calls = []; + final mock = MockClient((req) async { + calls.add(req); + final handler = routes['${req.method} ${req.url.path}']; + if (handler == null) return http.Response('{"error":"no route"}', 404); + return handler(req); + }); + final storage = MemoryTokenStorage(); + if (signedIn) storage.setItem('authsome:session', jsonEncode(_session)); + final manager = AuthManager(AuthConfig( + baseUrl: 'https://api.test', + httpClient: mock, + storage: storage, + plugins: plugins, + initialClientConfig: clientConfig, + publishableKey: publishableKey, + )); + addTearDown(manager.dispose); + return (manager: manager, routes: routes, calls: calls, storage: storage); +} + +void main() { + group('plugins', () { + test('returns what init built, once and lazily', () { + final probe = _ProbePlugin(); + final s = _setup(plugins: [probe]); + expect(probe.inits, 0); + final first = s.manager.plugin<_Probe>(); + expect(identical(s.manager.plugin<_Probe>(), first), isTrue); + expect(probe.inits, 1); + expect(s.manager.hasPlugin<_Probe>(), isTrue); + expect(s.manager.hasPlugin(), isFalse); + }); + + test('throws a named error for a plugin that was not registered', () { + final s = _setup(); + expect(() => s.manager.plugin<_Probe>(), throwsA(isA())); + }); + + test('refuses the same plugin name twice', () { + expect(() => _setup(plugins: [_ProbePlugin(), _ProbePlugin()]), throwsA(isA())); + }); + + test('surfaces an init error on first use, not at construction', () { + final s = _setup(plugins: [_ProbePlugin(fail: true)]); + expect(() => s.manager.plugin<_Probe>(), throwsA(isA())); + }); + + test('reports whether the server has the plugin', () { + expect(_setup(plugins: [_ProbePlugin()]).manager.plugin<_Probe>().ctx.isServerEnabled, isNull); + const config = ClientConfig(supportedPlugins: ['probe']); + expect(_setup(plugins: [_ProbePlugin()], clientConfig: config).manager.plugin<_Probe>().ctx.isServerEnabled, isTrue); + final other = _setup(plugins: [_ProbePlugin(pluginName: 'elsewhere')], clientConfig: config); + expect(other.manager.plugin<_Probe>().ctx.isServerEnabled, isFalse); + expect(_setup(plugins: [_OtherPlugin()]).manager.plugin(), 'other'); + }); + + test('sends authenticated requests with the session token and query', () async { + final s = _setup(signedIn: true, plugins: [_ProbePlugin()], publishableKey: 'pk_test'); + s.routes['GET /v1/billing/subscription'] = (_) => http.Response('{"subscription":null}', 200); + await s.manager.initialize(); + + final body = await s.manager.plugin<_Probe>().ctx.request( + 'GET', '/v1/billing/subscription', query: {'tenant_id': 'org_1', 'skip': null}); + expect(body, {'subscription': null}); + final req = s.calls.last; + expect(req.url.toString(), 'https://api.test/v1/billing/subscription?tenant_id=org_1'); + expect(req.headers['Authorization'], 'Bearer token-1'); + expect(req.headers['X-Publishable-Key'], 'pk_test'); + }); + + test('throws AuthClientException with the server message', () async { + final s = _setup(plugins: [_ProbePlugin()]); + s.routes['POST /v1/thing'] = (_) => http.Response('{"error":"nope","type":"forbidden"}', 403); + await expectLater( + s.manager.plugin<_Probe>().ctx.request('POST', '/v1/thing', body: {'a': 1}), + throwsA(isA() + .having((e) => e.message, 'message', 'nope') + .having((e) => e.code, 'code', 403) + .having((e) => e.type, 'type', 'forbidden')), + ); + expect(s.calls.last.body, '{"a":1}'); + }); + }); + + group('lifecycle events', () { + test('fires signedIn, sessionRefreshed and signedOut once each', () async { + final s = _setup(signedIn: true); + final seen = []; + s.manager.events.listen(seen.add); + await s.manager.initialize(); + expect(seen, [AuthEvent.signedIn]); + + s.routes['POST /v1/refresh'] = (_) => http.Response(jsonEncode({ + ..._session, + 'session_token': 'token-2', + 'refresh_token': 'refresh-2', + }), 200); + await s.manager.refreshNow(); + expect(seen, [AuthEvent.signedIn, AuthEvent.sessionRefreshed]); + + await s.manager.signOut(); + expect(seen, [AuthEvent.signedIn, AuthEvent.sessionRefreshed, AuthEvent.signedOut]); + }); + }); + + group('active organization', () { + test('restores the active org from /v1/me', () async { + final s = _setup(signedIn: true, me: {'active_org_id': 'org_1'}); + expect(s.manager.activeOrgId, isNull); + await s.manager.initialize(); + expect(s.manager.activeOrgId, 'org_1'); + }); + + test('switches org, keeps a reissued token and announces it', () async { + final s = _setup(signedIn: true); + await s.manager.initialize(); + final seen = []; + s.manager.events.listen(seen.add); + s.routes['POST /v1/me/switch-org'] = (req) { + expect(jsonDecode(req.body), {'org_id': 'org_2'}); + expect(req.headers['Authorization'], 'Bearer token-1'); + return http.Response(jsonEncode({ + 'session_id': 'ses_1', + 'org_id': 'org_2', + 'session_token': 'token-org2', + 'expires_at': _session['expires_at'], + }), 200); + }; + + await s.manager.switchOrg('org_2'); + expect(s.manager.activeOrgId, 'org_2'); + expect(seen, [AuthEvent.orgChanged]); + expect(s.manager.getSessionToken(), 'token-org2'); + final stored = jsonDecode((await s.storage.getItem('authsome:session'))!) as Map; + expect(stored['session_token'], 'token-org2'); + }); + + test('refuses to switch when signed out', () async { + final s = _setup(); + await expectLater(s.manager.switchOrg('org_2'), throwsA(isA())); + }); + + test('forgets the org on sign-out', () async { + final s = _setup(signedIn: true, me: {'active_org_id': 'org_1'}); + await s.manager.initialize(); + expect(s.manager.activeOrgId, 'org_1'); + await s.manager.signOut(); + expect(s.manager.activeOrgId, isNull); + }); + }); +} From 52a1d5937a215d8306cdf58919d392cc8f176de1 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 09:36:16 -0600 Subject: [PATCH 06/12] feat(flutter,ui): expose plugins and the active organization in the bindings AuthNotifier passes the configured plugins through to AuthManager (it used to rebuild the config field by field and would have dropped them), and gains plugin(), activeOrgId and switchOrg, notifying on an organization switch. Both OrgSwitchers now work on their own: without activeOrgId and onOrgChange they show the session's active organization and switch it. Passing the props keeps the old controlled behaviour. The Flutter switcher's create row also stops overflowing when its label is long. --- .../lib/src/auth_provider.dart | 27 ++++++ .../packages/authsome_flutter/pubspec.yaml | 1 + .../authsome_flutter/test/plugins_test.dart | 84 +++++++++++++++++++ .../lib/src/user/org_switcher.dart | 46 ++++++++-- .../test/user/org_switcher_test.dart | 72 ++++++++++++++++ .../src/components/org-switcher.test.tsx | 56 +++++++++++++ .../src/components/org-switcher.tsx | 24 ++++-- 7 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 flutter/packages/authsome_flutter/test/plugins_test.dart create mode 100644 flutter/packages/authsome_flutter_ui/test/user/org_switcher_test.dart create mode 100644 ui/packages/components/src/components/org-switcher.test.tsx diff --git a/flutter/packages/authsome_flutter/lib/src/auth_provider.dart b/flutter/packages/authsome_flutter/lib/src/auth_provider.dart index ca97bc9b..f2f91834 100644 --- a/flutter/packages/authsome_flutter/lib/src/auth_provider.dart +++ b/flutter/packages/authsome_flutter/lib/src/auth_provider.dart @@ -3,6 +3,8 @@ /// Mirrors the React AuthProvider pattern from `ui/packages/react/src/context.tsx`. library; +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:authsome_core/authsome_core.dart'; @@ -21,6 +23,7 @@ class AuthNotifier extends ChangeNotifier { bool _isConfigLoaded = false; void Function()? _unsubscribeState; void Function()? _unsubscribeConfig; + StreamSubscription? _orgEvents; /// Creates an [AuthNotifier] with the given [AuthConfig]. /// @@ -36,6 +39,7 @@ class AuthNotifier extends ChangeNotifier { storage: config.storage ?? SecureTokenStorage(), onStateChange: config.onStateChange, onError: config.onError, + plugins: config.plugins, ), ) { _state = _manager.state; @@ -52,8 +56,30 @@ class AuthNotifier extends ChangeNotifier { _isConfigLoaded = true; notifyListeners(); }); + + // A switch that keeps the same token changes no state, so listen for + // it directly. + _orgEvents = _manager.events.listen((event) { + if (event == AuthEvent.orgChanged) notifyListeners(); + }); } + // ── Plugins and organization ─────────────────────── + + /// The API of the registered client plugin that provides [Api]: + /// `context.auth.plugin()`. Plugin packages usually wrap + /// this in their own `BuildContext` extension. + Api plugin() => _manager.plugin(); + + /// Whether a registered client plugin provides [Api]. + bool hasPlugin() => _manager.hasPlugin(); + + /// The session's active organization, null when it has none. + String? get activeOrgId => _manager.activeOrgId; + + /// Switches the session into another organization and notifies listeners. + Future switchOrg(String orgId) => _manager.switchOrg(orgId); + // ── State Getters ────────────────────────────────── /// Current authentication state. @@ -206,6 +232,7 @@ class AuthNotifier extends ChangeNotifier { void dispose() { _unsubscribeState?.call(); _unsubscribeConfig?.call(); + _orgEvents?.cancel(); _manager.dispose(); super.dispose(); } diff --git a/flutter/packages/authsome_flutter/pubspec.yaml b/flutter/packages/authsome_flutter/pubspec.yaml index 7fee0b0a..4798beb0 100644 --- a/flutter/packages/authsome_flutter/pubspec.yaml +++ b/flutter/packages/authsome_flutter/pubspec.yaml @@ -24,4 +24,5 @@ dev_dependencies: flutter_lints: ^4.0.0 flutter_test: sdk: flutter + http: ^1.2.0 mocktail: ^1.0.4 diff --git a/flutter/packages/authsome_flutter/test/plugins_test.dart b/flutter/packages/authsome_flutter/test/plugins_test.dart new file mode 100644 index 00000000..ba24fa6c --- /dev/null +++ b/flutter/packages/authsome_flutter/test/plugins_test.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; + +import 'package:authsome_flutter/authsome_flutter.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +class _GreeterPlugin extends AuthPlugin<_Greeter> { + @override + String get name => 'greeter'; + @override + _Greeter init(PluginContext ctx) => _Greeter(); +} + +class _Greeter { + String hello() => 'hi'; +} + +AuthNotifier _notifier({Map me = const {}}) { + final storage = MemoryTokenStorage() + ..setItem('authsome:session', jsonEncode({ + 'session_token': 'tok', + 'refresh_token': 'refresh', + 'expires_at': DateTime.now().add(const Duration(hours: 1)).toUtc().toIso8601String(), + })); + final mock = MockClient((req) async { + switch (req.url.path) { + case '/v1/me': + return http.Response(jsonEncode({ + 'id': 'usr_1', 'app_id': 'app_1', 'env_id': 'env_1', 'email': 'a@test', + 'email_verified': true, 'first_name': 'A', 'last_name': 'B', 'banned': false, + 'phone_verified': false, 'roles': [], + 'created_at': '2026-01-01T00:00:00Z', 'updated_at': '2026-01-01T00:00:00Z', + ...me, + }), 200); + case '/v1/me/switch-org': + final orgId = (jsonDecode(req.body) as Map)['org_id']; + return http.Response(jsonEncode({'session_id': 'ses_1', 'org_id': orgId}), 200); + } + return http.Response('{}', 404); + }); + return AuthNotifier(AuthConfig( + baseUrl: 'https://api.test', + httpClient: mock, + storage: storage, + plugins: [_GreeterPlugin()], + )); +} + +void main() { + test('AuthNotifier keeps the configured plugins', () { + final auth = _notifier(); + addTearDown(auth.dispose); + expect(auth.plugin<_Greeter>().hello(), 'hi'); + }); + + test('AuthNotifier reports the active org and notifies on a switch', () async { + final auth = _notifier(me: {'active_org_id': 'org_1'}); + addTearDown(auth.dispose); + await auth.initialize(); + expect(auth.activeOrgId, 'org_1'); + + var notified = 0; + auth.addListener(() => notified++); + await auth.switchOrg('org_2'); + expect(auth.activeOrgId, 'org_2'); + expect(notified, greaterThan(0)); + }); + + testWidgets('context.auth.plugin finds the plugin from the widget tree', (tester) async { + final auth = _notifier(); + addTearDown(auth.dispose); + String? greeting; + await tester.pumpWidget(AuthProvider.test( + notifier: auth, + child: Builder(builder: (context) { + greeting = context.auth.plugin<_Greeter>().hello(); + return const SizedBox.shrink(); + }), + )); + expect(greeting, 'hi'); + }); +} diff --git a/flutter/packages/authsome_flutter_ui/lib/src/user/org_switcher.dart b/flutter/packages/authsome_flutter_ui/lib/src/user/org_switcher.dart index 19e3d7a6..afa0ac73 100644 --- a/flutter/packages/authsome_flutter_ui/lib/src/user/org_switcher.dart +++ b/flutter/packages/authsome_flutter_ui/lib/src/user/org_switcher.dart @@ -5,6 +5,15 @@ /// checkmark. Includes a "Create organization" option that opens a /// dialog for creating a new organization with name and slug. /// +/// On its own it shows and switches the session's active organization: +/// +/// ```dart +/// const OrgSwitcher() +/// ``` +/// +/// Pass [OrgSwitcher.activeOrgId] and [OrgSwitcher.onOrgChange] to manage +/// the active organization yourself: +/// /// ```dart /// OrgSwitcher( /// activeOrgId: currentOrgId, @@ -25,13 +34,15 @@ import 'package:authsome_flutter/authsome_flutter.dart'; /// checkmark. A footer option opens a dialog to create a new /// organization. class OrgSwitcher extends StatefulWidget { - /// Called when the user selects a different organization. + /// Called when the user selects a different organization. Leave it null + /// and the switcher switches the session's active organization itself. final ValueChanged? onOrgChange; /// Called after a new organization is created. final VoidCallback? onCreateOrg; - /// The ID of the currently active organization. + /// The ID of the currently active organization. Leave it null to show the + /// session's active organization. final String? activeOrgId; /// Creates an [OrgSwitcher]. @@ -80,6 +91,24 @@ class _OrgSwitcherState extends State { } } + /// Hands the choice to [OrgSwitcher.onOrgChange] when the app manages the + /// active organization itself, and switches the session's otherwise. + Future _select(String orgId) async { + final onOrgChange = widget.onOrgChange; + if (onOrgChange != null) { + onOrgChange(orgId); + return; + } + try { + await context.auth.switchOrg(orgId); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text("Couldn't switch organization")), + ); + } + } + /// Reads a field from an Organization object. String _orgField(Organization org, String key) { return switch (key) { @@ -224,10 +253,13 @@ class _OrgSwitcherState extends State { ); } + // Without an activeOrgId the switcher shows the session's own. + final activeOrgId = widget.activeOrgId ?? context.auth.activeOrgId; + // Determine the label for the button. String activeLabel = 'Select organization'; for (final org in _orgs) { - if (_orgField(org, 'id') == widget.activeOrgId) { + if (_orgField(org, 'id') == activeOrgId) { activeLabel = _orgField(org, 'name'); break; } @@ -242,7 +274,7 @@ class _OrgSwitcherState extends State { for (final org in _orgs) { final id = _orgField(org, 'id'); final name = _orgField(org, 'name'); - final isActive = id == widget.activeOrgId; + final isActive = id == activeOrgId; items.add( PopupMenuItem( @@ -279,7 +311,9 @@ class _OrgSwitcherState extends State { children: [ Icon(Icons.add, size: 18), SizedBox(width: 8), - Text('Create organization'), + Flexible( + child: Text('Create organization', overflow: TextOverflow.ellipsis), + ), ], ), ), @@ -291,7 +325,7 @@ class _OrgSwitcherState extends State { if (value == '_create') { _showCreateDialog(); } else { - widget.onOrgChange?.call(value); + _select(value); } }, child: Chip( diff --git a/flutter/packages/authsome_flutter_ui/test/user/org_switcher_test.dart b/flutter/packages/authsome_flutter_ui/test/user/org_switcher_test.dart new file mode 100644 index 00000000..03314b43 --- /dev/null +++ b/flutter/packages/authsome_flutter_ui/test/user/org_switcher_test.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:authsome_flutter_ui/authsome_flutter_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../_helpers/mock_auth_notifier.dart'; + +MockAuthNotifier _signedIn({String? activeOrgId}) { + final mock = buildIdleMock( + state: const AuthAuthenticated( + user: null, + session: Session(sessionToken: 'tok', refreshToken: 'r', expiresAt: '2099-01-01T00:00:00Z'), + ), + ); + when(() => mock.session).thenReturn( + const Session(sessionToken: 'tok', refreshToken: 'r', expiresAt: '2099-01-01T00:00:00Z'), + ); + when(() => mock.client).thenReturn(AuthSomeClient(AuthClientConfig( + baseUrl: 'http://test.local', + httpClient: MockClient((req) async => http.Response(jsonEncode({ + 'organizations': [ + {'id': 'org_1', 'name': 'Acme', 'slug': 'acme', 'app_id': 'app_1', 'env_id': 'env_1', 'created_at': '2026-01-01T00:00:00Z', 'updated_at': '2026-01-01T00:00:00Z', 'created_by': 'usr_1'}, + {'id': 'org_2', 'name': 'Globex', 'slug': 'globex', 'app_id': 'app_1', 'env_id': 'env_1', 'created_at': '2026-01-01T00:00:00Z', 'updated_at': '2026-01-01T00:00:00Z', 'created_by': 'usr_1'}, + ], + }), 200)), + ))); + when(() => mock.activeOrgId).thenReturn(activeOrgId); + when(() => mock.switchOrg(any())).thenAnswer((_) async {}); + return mock; +} + +Future _pump(WidgetTester tester, MockAuthNotifier auth, Widget child) async { + await tester.pumpWidget(AuthProvider.test( + notifier: auth, + child: MaterialApp(home: Scaffold(body: Center(child: child))), + )); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('without props it shows and switches the core active org', (tester) async { + final auth = _signedIn(activeOrgId: 'org_1'); + await _pump(tester, auth, const OrgSwitcher()); + + expect(find.text('Acme'), findsOneWidget); + await tester.tap(find.text('Acme')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Globex').last); + await tester.pumpAndSettle(); + + verify(() => auth.switchOrg('org_2')).called(1); + }); + + testWidgets('controlled props still win', (tester) async { + final auth = _signedIn(activeOrgId: 'org_1'); + String? picked; + await _pump(tester, auth, OrgSwitcher(activeOrgId: 'org_2', onOrgChange: (id) => picked = id)); + + expect(find.text('Globex'), findsOneWidget); + await tester.tap(find.text('Globex')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Acme').last); + await tester.pumpAndSettle(); + + expect(picked, 'org_1'); + verifyNever(() => auth.switchOrg(any())); + }); +} diff --git a/ui/packages/components/src/components/org-switcher.test.tsx b/ui/packages/components/src/components/org-switcher.test.tsx new file mode 100644 index 00000000..c93ae6da --- /dev/null +++ b/ui/packages/components/src/components/org-switcher.test.tsx @@ -0,0 +1,56 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { OrgSwitcher } from "./org-switcher"; +import { makeUser, routedFetch, withProvider } from "../test-support"; + +const orgs = [ + { id: "org_1", name: "Acme", slug: "acme" }, + { id: "org_2", name: "Globex", slug: "globex" }, +]; + +function open(name: string) { + const trigger = screen.getByRole("button", { name: new RegExp(name) }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" }); +} + +describe("OrgSwitcher", () => { + it("shows and switches the session's active org when uncontrolled", async () => { + const switched: string[] = []; + const { fetchFn, calls } = routedFetch({ + "GET /v1/me": () => ({ ...makeUser(), active_org_id: "org_1" }), + "GET /v1/orgs": () => ({ organizations: orgs }), + "POST /v1/me/switch-org": () => { + switched.push("org_2"); + return { session_id: "ses_1", org_id: "org_2" }; + }, + }); + + render(withProvider(, { fetch: fetchFn })); + await waitFor(() => expect(screen.getByRole("button", { name: /Acme/ })).toBeTruthy()); + + open("Acme"); + const item = await screen.findByRole("menuitem", { name: /Globex/ }); + await act(async () => { fireEvent.click(item); }); + + await waitFor(() => expect(calls).toContain("POST /v1/me/switch-org")); + await waitFor(() => expect(screen.getByRole("button", { name: /Globex/ })).toBeTruthy()); + }); + + it("leaves switching to the app when it passes onOrgChange", async () => { + const onOrgChange = vi.fn(); + const { fetchFn, calls } = routedFetch({ + "GET /v1/orgs": () => ({ organizations: orgs }), + }); + + render(withProvider(, { fetch: fetchFn })); + await waitFor(() => expect(screen.getByRole("button", { name: /Globex/ })).toBeTruthy()); + + open("Globex"); + const item = await screen.findByRole("menuitem", { name: /Acme/ }); + await act(async () => { fireEvent.click(item); }); + + expect(onOrgChange).toHaveBeenCalledWith("org_1"); + expect(calls).not.toContain("POST /v1/me/switch-org"); + }); +}); diff --git a/ui/packages/components/src/components/org-switcher.tsx b/ui/packages/components/src/components/org-switcher.tsx index f8501e2d..a23a45dc 100644 --- a/ui/packages/components/src/components/org-switcher.tsx +++ b/ui/packages/components/src/components/org-switcher.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { useState } from "react"; -import { useAuth, useOrganizations } from "@authsome/ui-react"; +import { useActiveOrg, useAuth, useOrganizations } from "@authsome/ui-react"; import { ChevronsUpDown, Plus, Check, Building2 } from "lucide-react"; import { cn } from "../lib/utils"; import { @@ -28,11 +28,14 @@ import { Label } from "../primitives/label"; import { LoadingSpinner } from "./loading-spinner"; export interface OrgSwitcherProps { - /** Called when the user selects a different organization. */ + /** + * Called when the user selects a different organization. Leave it out and + * the switcher switches the session's active organization itself. + */ onOrgChange?: (orgId: string) => void; /** Called when the user creates a new organization. If not provided, the built-in create dialog is used. */ onCreateOrg?: () => void; - /** The currently selected organization ID. */ + /** The currently selected organization ID. Leave it out to show the session's. */ activeOrgId?: string; /** Additional CSS class names. */ className?: string; @@ -45,10 +48,21 @@ export interface OrgSwitcherProps { export function OrgSwitcher({ onOrgChange, onCreateOrg, - activeOrgId, + activeOrgId: activeOrgIdProp, className, }: OrgSwitcherProps) { const { client, session } = useAuth(); + const { orgId: sessionOrgId, switchOrg } = useActiveOrg(); + const activeOrgId = activeOrgIdProp ?? sessionOrgId ?? undefined; + const select = (orgId: string) => { + if (onOrgChange) { + onOrgChange(orgId); + return; + } + // A failed switch leaves the session where it was, which is what the + // switcher already shows, so there's nothing to undo. + switchOrg(orgId).catch(() => {}); + }; const { organizations: orgsRaw, isLoading, reload } = useOrganizations(); const organizations = orgsRaw ?? []; const [createOpen, setCreateOpen] = useState(false); @@ -145,7 +159,7 @@ export function OrgSwitcher({ organizations.map((org) => ( onOrgChange?.(String(org.id))} + onClick={() => select(String(org.id))} className="flex items-center gap-2" > From 798bea74cf83a9bd0e6d25b088ccd46df9368917 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 09:37:21 -0600 Subject: [PATCH 07/12] docs(ui): document client plugins and the active organization --- docs/content/docs/ui/flutter.mdx | 17 +++++- docs/content/docs/ui/meta.json | 1 + docs/content/docs/ui/plugins.mdx | 92 ++++++++++++++++++++++++++++++++ docs/content/docs/ui/react.mdx | 31 +++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 docs/content/docs/ui/plugins.mdx diff --git a/docs/content/docs/ui/flutter.mdx b/docs/content/docs/ui/flutter.mdx index 57688965..f67921aa 100644 --- a/docs/content/docs/ui/flutter.mdx +++ b/docs/content/docs/ui/flutter.mdx @@ -239,7 +239,13 @@ UserProfileCard( ### OrgSwitcher -Popup menu for switching between organizations and creating new ones. +Popup menu for switching between organizations and creating new ones. With no arguments it shows the session's active organization and switches it for you: + +```dart +const OrgSwitcher() +``` + +If your app keeps the active organization itself, pass both `activeOrgId` and `onOrgChange`: ```dart OrgSwitcher( @@ -249,6 +255,15 @@ OrgSwitcher( ) ``` +The session's organization is also on `context.auth`. You get it back after a restart because `/v1/me` reports it, and signing out clears it: + +```dart +final orgId = context.auth.activeOrgId; +await context.auth.switchOrg('org_123'); // rebuilds listeners +``` + +Client plugins, the client half of server plugins like `subscription`, are added to `AuthConfig.plugins` and read with `context.auth.plugin()`. See [Client plugins](/docs/ui/plugins). + ## Session and device management ### SessionList diff --git a/docs/content/docs/ui/meta.json b/docs/content/docs/ui/meta.json index 2fbbfd61..c339e299 100644 --- a/docs/content/docs/ui/meta.json +++ b/docs/content/docs/ui/meta.json @@ -7,6 +7,7 @@ "nextjs", "vue", "playground", + "plugins", "flutter" ] } diff --git a/docs/content/docs/ui/plugins.mdx b/docs/content/docs/ui/plugins.mdx new file mode 100644 index 00000000..d092bef7 --- /dev/null +++ b/docs/content/docs/ui/plugins.mdx @@ -0,0 +1,92 @@ +--- +title: Client Plugins +description: Add the client half of a server plugin to your React or Flutter app, or write one of your own. +--- + +A server plugin is something you opt into with `authsome.WithPlugin(...)`. Most of them need nothing on the client. Some, like `subscription`, come with a client half too: a package that knows the plugin's routes, keeps its state, and gives you hooks or widgets. You opt into that half the same way, by adding it to your provider. + +## Using a plugin + +### React + +```tsx +import { AuthProvider, usePlugin } from "@authsome/ui-react"; +import { subscription } from "@authsome/plugin-subscription"; + + + + + +function Somewhere() { + const billing = usePlugin(subscription); +} +``` + +Plugin packages usually ship their own hooks on top of `usePlugin`, so you'll mostly call those. + +### Flutter + +```dart +AuthProvider( + config: AuthConfig( + baseUrl: url, + publishableKey: pk, + plugins: [SubscriptionPlugin()], + ), + child: const MyApp(), +); + +final billing = context.auth.plugin(); +``` + +### Without a framework + +```ts +const auth = new AuthManager({ baseURL: url, plugins: [subscription()] }); +const billing = auth.plugin(subscription); +``` + +Asking for a plugin you didn't add throws an error that names it, so a missing registration shows up the first time you run the page. + +A plugin's setup runs the first time you ask for it, not when the provider mounts. If its setup throws (a bad option, say), you see the error there too. + +## Writing a plugin + +A plugin has a name and a setup function. The name has to be the server plugin's `Name()`, because that's how the plugin can tell from the client config whether the server side is installed. + +```ts +import { definePlugin } from "@authsome/ui-core"; + +export const audit = definePlugin("audit", (ctx, options?: { pageSize?: number }) => ({ + async recent() { + return ctx.request<{ events: AuditEvent[] }>("GET", "/v1/audit/events", { + query: { org_id: ctx.getActiveOrgId() ?? undefined, limit: options?.pageSize ?? 20 }, + }); + }, +})); +``` + +```dart +class AuditPlugin extends AuthPlugin { + @override + String get name => 'audit'; + + @override + AuditApi init(PluginContext ctx) => AuditApi(ctx); +} +``` + +Setup gets a small context and nothing else: + +| Member | What it's for | +|---|---| +| `request` | Calls your routes as the signed-in user. The base URL, publishable key and bearer token (or the session cookie in cookie mode) are filled in, and it throws `AuthClientError` / `AuthClientException` on an error status | +| `getState()` / `state` | The current auth state | +| `getClientConfig()` / `clientConfig` | The client config, null until it loads | +| `isServerEnabled()` / `isServerEnabled` | `true` or `false` from the config's `supported_plugins`, `null` while that isn't known | +| `getActiveOrgId()` / `activeOrgId` | The session's active organization | +| `on(event, fn)` / `events` | `signedIn`, `signedOut`, `sessionRefreshed`, `orgChanged` | + +Listen for `signedOut` to drop anything you cached, and `orgChanged` to refetch anything scoped to an organization. `sessionRefreshed` fires when the tokens rotate; switching organization doesn't fire it, because `orgChanged` already does. + +The context is small on purpose. Plugin packages version separately from the core, so anything added to it is a promise to every plugin, and it only grows when a second plugin needs the same thing. diff --git a/docs/content/docs/ui/react.mdx b/docs/content/docs/ui/react.mdx index 8a510bfb..e0cd2608 100644 --- a/docs/content/docs/ui/react.mdx +++ b/docs/content/docs/ui/react.mdx @@ -157,6 +157,37 @@ function OrgSelector() { } ``` +### useActiveOrg + +The organization the session is working in, and a way to switch it. You get it back after a reload because `/v1/me` reports it, and it's cleared when you sign out. + +```tsx +import { useActiveOrg } from "@authsome/ui-react"; + +function OrgBadge() { + const { orgId, switchOrg, isSwitching } = useActiveOrg(); + return ( + + ); +} +``` + +`` from `@authsome/ui-components` uses this when you don't pass it `activeOrgId` and `onOrgChange`, so dropping it in with no props is enough. Pass both if your app keeps the active organization somewhere else. + +### usePlugin + +Returns a client plugin's API. Client plugins are the client half of server plugins: you add them to `AuthProvider` the way you add the server side with `authsome.WithPlugin`. See [Client plugins](/docs/ui/plugins). + +```tsx + + + + +const billing = usePlugin(subscription); +``` + ### useSessionToken Returns the raw session token string, useful for passing to custom API calls. From 7877e88877b9a22928ef3f560a1fdf5bf4d5742c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Tue, 10 Nov 2026 09:53:36 -0600 Subject: [PATCH 08/12] fix(ui-core,authsome_core): harden org switching and plugin events switchOrg now runs in turn with token refreshes and merges a reissued access token into the latest stored session, so a refresh that rotated the refresh token meanwhile isn't undone. It also refuses to restore a session that signed out, or became someone else's, while it was waiting. Signing in as a different user without signing out first fires signedOut and signedIn and drops the previous user's organization. In TS a second switch no longer replays the org the session was loaded with. destroy() keeps plugin listeners, so plugins keep hearing events after a StrictMode remount. Switching to no organization leaves null, not "". definePlugin types required options as required. Dart's plugin() refuses a call without a type argument and an API two plugins share. OrgSwitcher shows a message (and calls onError) when a switch fails, and is disabled while one is in flight. The React guide documents Protect and what changed about role and permission. --- docs/content/docs/ui/react.mdx | 24 +++++ .../authsome_core/lib/src/auth_manager.dart | 88 ++++++++++++--- .../authsome_core/test/plugins_test.dart | 91 +++++++++++++++- .../src/components/org-switcher.test.tsx | 20 ++++ .../src/components/org-switcher.tsx | 25 ++++- ui/packages/core/src/auth.ts | 60 ++++++++--- ui/packages/core/src/plugins.test.ts | 100 ++++++++++++++++++ ui/packages/core/src/plugins.ts | 13 ++- ui/packages/react/src/plugins.test.tsx | 20 +++- 9 files changed, 404 insertions(+), 37 deletions(-) diff --git a/docs/content/docs/ui/react.mdx b/docs/content/docs/ui/react.mdx index e0cd2608..695644ca 100644 --- a/docs/content/docs/ui/react.mdx +++ b/docs/content/docs/ui/react.mdx @@ -329,6 +329,30 @@ function ProtectedArea() { } ``` +### Protect + +`AuthGuard` with requirements. It shows its children only when you're signed in and meet them, and the fallback otherwise. + +```tsx +import { Protect } from "@authsome/ui-react"; + +Admins only

}> + +
+ + canManageBilling(auth)} fallback={null}> + + +``` + +- `role` checks the app roles `/v1/me` returns for the signed-in user. Organization member roles (owner, admin, member) aren't in that list. +- `permission` can't be checked in the browser, so it needs a `condition` that does the check, usually against something your backend told you. Without one, `Protect` shows the fallback and logs a warning once. +- `condition` alone works too, for anything else. + + +Before this release, `role` and `permission` didn't check anything: children rendered for every signed-in user. If you relied on that, your protected UI is now hidden until you pass a role the user actually holds, or a `condition` for permissions. + + ## Integration with React Router ```tsx diff --git a/flutter/packages/authsome_core/lib/src/auth_manager.dart b/flutter/packages/authsome_core/lib/src/auth_manager.dart index 393b7cb7..83400753 100644 --- a/flutter/packages/authsome_core/lib/src/auth_manager.dart +++ b/flutter/packages/authsome_core/lib/src/auth_manager.dart @@ -146,10 +146,19 @@ class AuthManager { bool hasPlugin() => _slotFor() != null; _PluginSlot? _slotFor() { - for (final slot in _plugins.values) { - if (slot.plugin is AuthPlugin) return slot; + if (Api == Object) { + throw ArgumentError( + 'authsome: plugin() needs the API type, e.g. plugin()'); } - return null; + final matches = [ + for (final slot in _plugins.values) + if (slot.plugin is AuthPlugin) slot, + ]; + if (matches.length > 1) { + throw StateError('authsome: more than one registered plugin provides $Api ' + '(${matches.map((s) => s.plugin.name).join(', ')})'); + } + return matches.isEmpty ? null : matches.single; } /// Lifecycle events: [AuthEvent.signedIn], [AuthEvent.signedOut], @@ -166,33 +175,74 @@ class AuthManager { /// JWTs the server hands back a new token carrying the organization; it /// replaces the stored one. Fires [AuthEvent.orgChanged]. Future switchOrg(String orgId) async { - final current = _state; - if (current is! AuthAuthenticated) { + if (_state is! AuthAuthenticated) { + throw StateError('authsome: you have to be signed in to switch organization'); + } + // Runs in turn with refreshes, so a refresh rotating the refresh token + // meanwhile isn't overwritten with the spent one. + return _exclusive(() => _applyOrgSwitch(orgId)); + } + + Future _applyOrgSwitch(String orgId) async { + final before = _state; + if (before is! AuthAuthenticated) { throw StateError('authsome: you have to be signed in to switch organization'); } final res = await _client.switchOrg( body: SwitchOrgRequest(orgId: orgId), - token: current.session.sessionToken, + token: before.session.sessionToken, ); + // The session may have ended, or become someone else's, while the + // request was out. Don't bring it back. + final now = _state; + if (now is! AuthAuthenticated || _userId(now) != _userId(before)) { + throw StateError('authsome: the session changed while switching organization'); + } + final token = res.sessionToken; if (token != null && token.isNotEmpty) { + final raw = await _storage.getItem(_sessionKey); + final latest = raw == null + ? now.session + : Session.fromJson(jsonDecode(raw) as Map); final next = Session( sessionToken: token, - refreshToken: current.session.refreshToken, - expiresAt: res.expiresAt ?? current.session.expiresAt, + refreshToken: latest.refreshToken, + expiresAt: res.expiresAt ?? latest.expiresAt, ); await _persistSession(next); - _setState(AuthAuthenticated(user: current.user, session: next)); + _setState(AuthAuthenticated(user: now.user, session: next)); _scheduleRefresh(next); } - final now = res.orgId ?? orgId; - final changed = _activeOrgId != now; - _activeOrgId = now; + // An empty org id is the server saying the session has none now. + final reported = res.orgId; + final org = (reported != null && reported.isNotEmpty) ? reported : null; + final changed = _activeOrgId != org; + _activeOrgId = org; if (changed) _emit(AuthEvent.orgChanged); } + /// Runs [op] after every operation queued before it has finished, so a + /// refresh and an organization switch never interleave. + Future _exclusive(Future Function() op) { + final result = _serial.then((_) => op()); + _serial = result.then((_) {}, onError: (Object _) {}); + return result; + } + + Future _serial = Future.value(); + + /// The signed-in user's id, null when unknown (an offline restore). + static String? _userId(AuthState state) { + if (state is! AuthAuthenticated) return null; + final user = state.user; + if (user is User) return user.id; + if (user is Map) return user['id'] as String?; + return null; + } + Future _loadUser(String token) async { final me = await _client.getMe(token: token); _reportedOrgId = me.activeOrgId; @@ -708,7 +758,10 @@ class AuthManager { /// may still be valid and the server could be temporarily unreachable. /// Schedules a 30-second retry instead. Only [signOut] clears storage. /// Mirrors React `auth.ts` refreshSession (lines 476–492). - Future _refreshSession(String refreshToken) async { + Future _refreshSession(String refreshToken) => + _exclusive(() => _doRefreshSession(refreshToken)); + + Future _doRefreshSession(String refreshToken) async { try { final newSession = await _client.refreshWithToken(refreshToken); final user = await _loadUser(newSession.sessionToken); @@ -812,6 +865,15 @@ class AuthManager { return const [AuthEvent.signedIn]; } + // Signing in as someone else without signing out first is a sign-out + // and a sign-in, so nothing of the previous user's carries over. + final prevUser = _userId(prev); + final nextUser = _userId(next); + if (prevUser != null && nextUser != null && prevUser != nextUser) { + _activeOrgId = reported; + return const [AuthEvent.signedOut, AuthEvent.signedIn]; + } + final events = []; // A refresh rotates the refresh token; switching organization reissues // only the access token, so it doesn't count. diff --git a/flutter/packages/authsome_core/test/plugins_test.dart b/flutter/packages/authsome_core/test/plugins_test.dart index d4880748..2ba59687 100644 --- a/flutter/packages/authsome_core/test/plugins_test.dart +++ b/flutter/packages/authsome_core/test/plugins_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:authsome_core/authsome_core.dart'; @@ -56,7 +57,7 @@ final _me = { 'updated_at': '2026-01-01T00:00:00Z', }; -typedef _Routes = Map; +typedef _Routes = Map Function(http.Request)>; ({AuthManager manager, _Routes routes, List calls, MemoryTokenStorage storage}) _setup({ bool signedIn = false, @@ -65,7 +66,7 @@ typedef _Routes = Map; ClientConfig? clientConfig, String? publishableKey, }) { - final routes = { + final routes = Function(http.Request)>{ 'GET /v1/me': (_) => http.Response(jsonEncode({..._me, ...me}), 200), 'POST /v1/signout': (_) => http.Response('{"status":"ok"}', 200), }; @@ -220,4 +221,90 @@ void main() { expect(s.manager.activeOrgId, isNull); }); }); + + group('review fixes', () { + http.Response switched(String org, {String? token}) => http.Response(jsonEncode({ + 'session_id': 'ses_1', + 'org_id': org, + if (token != null) 'session_token': token, + if (token != null) 'expires_at': _session['expires_at'], + }), 200); + + test('keeps a token a refresh rotated while the switch was in flight', () async { + final s = _setup(signedIn: true); + await s.manager.initialize(); + final gate = Completer(); + s.routes['POST /v1/me/switch-org'] = (_) => gate.future; + s.routes['POST /v1/refresh'] = (_) => http.Response(jsonEncode({ + ..._session, + 'session_token': 'token-2', + 'refresh_token': 'refresh-2', + }), 200); + + final switching = s.manager.switchOrg('org_2'); + final refreshing = s.manager.refreshNow(); + await Future.delayed(const Duration(milliseconds: 20)); + gate.complete(switched('org_2', token: 'token-org2')); + await Future.wait([switching, refreshing]); + + final stored = jsonDecode((await s.storage.getItem('authsome:session'))!) as Map; + expect(stored['refresh_token'], 'refresh-2'); + }); + + test('does not bring back a session that signed out during the switch', () async { + final s = _setup(signedIn: true); + await s.manager.initialize(); + final gate = Completer(); + s.routes['POST /v1/me/switch-org'] = (_) => gate.future; + + final switching = s.manager.switchOrg('org_2'); + await s.manager.signOut(); + gate.complete(switched('org_2', token: 'token-org2')); + await switching.catchError((_) {}); + + expect(s.manager.state, isA()); + expect(await s.storage.getItem('authsome:session'), isNull); + expect(s.manager.activeOrgId, isNull); + }); + + test('treats signing in as someone else as a sign-out and a sign-in', () async { + final s = _setup(signedIn: true, me: {'active_org_id': 'org_A'}); + await s.manager.initialize(); + final seen = []; + s.manager.events.listen(seen.add); + s.routes['POST /v1/signin'] = (_) => http.Response(jsonEncode({ + 'user': {..._me, 'id': 'usr_2'}, + 'session_token': 't2', + 'refresh_token': 'r2', + 'expires_at': _session['expires_at'], + }), 200); + + await s.manager.signIn('grace@test', 'pw'); + expect(seen, [AuthEvent.signedOut, AuthEvent.signedIn]); + expect(s.manager.activeOrgId, isNull); + }); + + test('refuses an untyped or ambiguous plugin lookup', () { + final untyped = _setup(plugins: [_ProbePlugin()]); + expect(() => untyped.manager.plugin(), throwsA(isA())); + + final twice = _setup(plugins: [_OtherPlugin(), _OtherNamePlugin()]); + expect(() => twice.manager.plugin(), throwsA(isA())); + }); + + test('clears the org when switching to none', () async { + final s = _setup(signedIn: true, me: {'active_org_id': 'org_A'}); + await s.manager.initialize(); + s.routes['POST /v1/me/switch-org'] = (_) => http.Response('{"session_id":"ses_1"}', 200); + await s.manager.switchOrg(''); + expect(s.manager.activeOrgId, isNull); + }); + }); +} + +class _OtherNamePlugin extends AuthPlugin { + @override + String get name => 'other-two'; + @override + String init(PluginContext ctx) => 'two'; } diff --git a/ui/packages/components/src/components/org-switcher.test.tsx b/ui/packages/components/src/components/org-switcher.test.tsx index c93ae6da..a6905e2e 100644 --- a/ui/packages/components/src/components/org-switcher.test.tsx +++ b/ui/packages/components/src/components/org-switcher.test.tsx @@ -37,6 +37,26 @@ describe("OrgSwitcher", () => { await waitFor(() => expect(screen.getByRole("button", { name: /Globex/ })).toBeTruthy()); }); + it("tells the user when a switch fails", async () => { + const onError = vi.fn(); + const { fetchFn } = routedFetch({ + "GET /v1/me": () => ({ ...makeUser(), active_org_id: "org_1" }), + "GET /v1/orgs": () => ({ organizations: orgs }), + "POST /v1/me/switch-org": () => new Response(JSON.stringify({ error: "conflict" }), { status: 409 }), + }); + + render(withProvider(, { fetch: fetchFn })); + await waitFor(() => expect(screen.getByRole("button", { name: /Acme/ })).toBeTruthy()); + + open("Acme"); + const item = await screen.findByRole("menuitem", { name: /Globex/ }); + await act(async () => { fireEvent.click(item); }); + + await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/couldn't switch/i)); + expect(onError).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: /Acme/ })).toBeTruthy(); + }); + it("leaves switching to the app when it passes onOrgChange", async () => { const onOrgChange = vi.fn(); const { fetchFn, calls } = routedFetch({ diff --git a/ui/packages/components/src/components/org-switcher.tsx b/ui/packages/components/src/components/org-switcher.tsx index a23a45dc..5ec12006 100644 --- a/ui/packages/components/src/components/org-switcher.tsx +++ b/ui/packages/components/src/components/org-switcher.tsx @@ -37,6 +37,11 @@ export interface OrgSwitcherProps { onCreateOrg?: () => void; /** The currently selected organization ID. Leave it out to show the session's. */ activeOrgId?: string; + /** + * Called when switching the session's organization fails. The switcher + * also shows a short message under itself. + */ + onError?: (error: unknown) => void; /** Additional CSS class names. */ className?: string; } @@ -49,19 +54,25 @@ export function OrgSwitcher({ onOrgChange, onCreateOrg, activeOrgId: activeOrgIdProp, + onError, className, }: OrgSwitcherProps) { const { client, session } = useAuth(); - const { orgId: sessionOrgId, switchOrg } = useActiveOrg(); + const { orgId: sessionOrgId, switchOrg, isSwitching } = useActiveOrg(); const activeOrgId = activeOrgIdProp ?? sessionOrgId ?? undefined; + const [switchError, setSwitchError] = useState(null); const select = (orgId: string) => { if (onOrgChange) { onOrgChange(orgId); return; } - // A failed switch leaves the session where it was, which is what the - // switcher already shows, so there's nothing to undo. - switchOrg(orgId).catch(() => {}); + setSwitchError(null); + // A failed switch leaves the session where it was, which the switcher + // already shows; say so rather than leave the click unanswered. + switchOrg(orgId).catch((err: unknown) => { + setSwitchError("Couldn't switch organization. Try again."); + onError?.(err); + }); }; const { organizations: orgsRaw, isLoading, reload } = useOrganizations(); const organizations = orgsRaw ?? []; @@ -124,6 +135,7 @@ export function OrgSwitcher({