Skip to content

Commit fbcdb09

Browse files
roymilohclaude
andcommitted
Add onAuthStateChange listener and refactor popup auth to use setToken
- Add onAuthStateChange(callback) to auth module, fires SIGNED_IN, SIGNED_OUT, and TOKEN_REFRESHED events with an unsubscribe pattern - Refactor popup OAuth to call setToken instead of redirecting, so auth state propagates via events without a page reload - Export AuthEvent, AuthEventData, AuthStateChangeCallback types - Add unit tests for onAuthStateChange Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4a82224 commit fbcdb09

4 files changed

Lines changed: 177 additions & 23 deletions

File tree

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export type {
5050

5151
export type {
5252
AuthModule,
53+
AuthEvent,
54+
AuthEventData,
55+
AuthStateChangeCallback,
5356
LoginResponse,
5457
RegisterParams,
5558
VerifyOtpParams,

src/modules/auth.ts

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { AxiosInstance } from "axios";
22
import {
33
AuthModule,
44
AuthModuleOptions,
5+
AuthEvent,
6+
AuthEventData,
7+
AuthStateChangeCallback,
58
VerifyOtpParams,
69
ChangePasswordParams,
710
ResetPasswordParams,
@@ -14,18 +17,17 @@ function isInsideIframe(): boolean {
1417

1518
/**
1619
* Opens a URL in a centered popup and waits for the backend to postMessage
17-
* the auth result back. On success, redirects the current window to
18-
* redirectUrl with the token params appended, preserving the same behaviour
19-
* as a normal full-page redirect flow.
20+
* the auth result back. On success, calls onToken so the SDK can set the
21+
* token and fire auth state change events — no page redirect needed.
2022
*
2123
* @param url - The login URL to open in the popup (should include popup_origin).
22-
* @param redirectUrl - Where to redirect after auth (the original fromUrl).
2324
* @param expectedOrigin - The origin we expect the postMessage to come from.
25+
* @param onToken - Callback invoked with the access_token when auth completes.
2426
*/
2527
function loginViaPopup(
2628
url: string,
27-
redirectUrl: string,
28-
expectedOrigin: string
29+
expectedOrigin: string,
30+
onToken: (accessToken: string) => void
2931
): void {
3032
const width = 500;
3133
const height = 600;
@@ -54,19 +56,7 @@ function loginViaPopup(
5456
if (!event.data?.access_token) return;
5557

5658
cleanup();
57-
58-
// Append the token params to redirectUrl so the app processes them
59-
// exactly as it would from a normal OAuth callback redirect.
60-
const callbackUrl = new URL(redirectUrl);
61-
const { access_token, is_new_user } = event.data;
62-
63-
callbackUrl.searchParams.set("access_token", access_token);
64-
65-
if (is_new_user != null) {
66-
callbackUrl.searchParams.set("is_new_user", String(is_new_user));
67-
}
68-
69-
window.location.href = callbackUrl.toString();
59+
onToken(event.data.access_token);
7060
};
7161

7262
// Only used to detect the user closing the popup before auth completes
@@ -93,6 +83,13 @@ export function createAuthModule(
9383
appId: string,
9484
options: AuthModuleOptions
9585
): AuthModule {
86+
const listeners = new Set<AuthStateChangeCallback>();
87+
let hasToken = false;
88+
89+
function notify(event: AuthEvent, data: AuthEventData = {}) {
90+
listeners.forEach((cb) => cb(event, data));
91+
}
92+
9693
return {
9794
// Get current user information
9895
async me() {
@@ -148,7 +145,9 @@ export function createAuthModule(
148145
// blocking iframe navigation.
149146
if (isInsideIframe()) {
150147
const popupLoginUrl = `${loginUrl}&popup_origin=${encodeURIComponent(window.location.origin)}`;
151-
return loginViaPopup(popupLoginUrl, redirectUrl, window.location.origin);
148+
return loginViaPopup(popupLoginUrl, window.location.origin, (token) => {
149+
this.setToken(token);
150+
});
152151
}
153152

154153
// Default: full-page redirect
@@ -159,6 +158,8 @@ export function createAuthModule(
159158
logout(redirectUrl?: string) {
160159
// Remove token from axios headers (always do this)
161160
delete axios.defaults.headers.common["Authorization"];
161+
hasToken = false;
162+
notify("SIGNED_OUT");
162163

163164
// Only do the rest if in a browser environment
164165
if (typeof window !== "undefined") {
@@ -186,6 +187,8 @@ export function createAuthModule(
186187
setToken(token: string, saveToStorage = true) {
187188
if (!token) return;
188189

190+
const event: AuthEvent = hasToken ? "TOKEN_REFRESHED" : "SIGNED_IN";
191+
189192
// handle token change for axios clients
190193
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
191194
functionsAxiosClient.defaults.headers.common[
@@ -206,6 +209,9 @@ export function createAuthModule(
206209
console.error("Failed to save token to localStorage:", e);
207210
}
208211
}
212+
213+
hasToken = true;
214+
notify(event, { access_token: token });
209215
},
210216

211217
// Login using username and password
@@ -311,5 +317,13 @@ export function createAuthModule(
311317
new_password: newPassword,
312318
});
313319
},
320+
321+
// Subscribe to auth state changes
322+
onAuthStateChange(callback: AuthStateChangeCallback) {
323+
listeners.add(callback);
324+
return () => {
325+
listeners.delete(callback);
326+
};
327+
},
314328
};
315329
}

src/modules/auth.types.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,29 @@ export interface AuthModuleOptions {
102102
appBaseUrl: string;
103103
}
104104

105+
/**
106+
* Auth state change event types.
107+
*/
108+
export type AuthEvent = "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED";
109+
110+
/**
111+
* Data passed to auth state change callbacks.
112+
*/
113+
export interface AuthEventData {
114+
/** JWT access token, present on SIGNED_IN and TOKEN_REFRESHED events. */
115+
access_token?: string;
116+
/** User data, present on SIGNED_IN when available. */
117+
user?: User;
118+
}
119+
120+
/**
121+
* Callback for auth state changes.
122+
*/
123+
export type AuthStateChangeCallback = (
124+
event: AuthEvent,
125+
data: AuthEventData
126+
) => void;
127+
105128
/**
106129
* Authentication module for managing user authentication and authorization. The module automatically stores tokens in local storage when available and manages authorization headers for API requests.
107130
*
@@ -507,4 +530,34 @@ export interface AuthModule {
507530
* ```
508531
*/
509532
changePassword(params: ChangePasswordParams): Promise<any>;
533+
534+
/**
535+
* Registers a callback that fires whenever the authentication state changes.
536+
*
537+
* Events:
538+
* - `SIGNED_IN` — fired after a successful login (email/password, OAuth, or popup).
539+
* - `SIGNED_OUT` — fired after logout.
540+
* - `TOKEN_REFRESHED` — fired when `setToken` is called while already authenticated.
541+
*
542+
* Returns an unsubscribe function. Call it to stop receiving events.
543+
*
544+
* @param callback - Function called with the event type and associated data.
545+
* @returns Unsubscribe function.
546+
*
547+
* @example
548+
* ```typescript
549+
* // In a React AuthContext provider
550+
* useEffect(() => {
551+
* const unsubscribe = base44.auth.onAuthStateChange((event, data) => {
552+
* if (event === 'SIGNED_IN') {
553+
* setUser(data.user);
554+
* } else if (event === 'SIGNED_OUT') {
555+
* setUser(null);
556+
* }
557+
* });
558+
* return unsubscribe;
559+
* }, []);
560+
* ```
561+
*/
562+
onAuthStateChange(callback: AuthStateChangeCallback): () => void;
510563
}

tests/unit/auth.test.js

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -632,15 +632,99 @@ describe('Auth Module', () => {
632632
// Mock network error
633633
scope.get(`/api/apps/${appId}/entities/User/me`)
634634
.replyWithError('Network error');
635-
635+
636636
// Call the API
637637
const result = await base44.auth.isAuthenticated();
638-
638+
639639
// Verify the response
640640
expect(result).toBe(false);
641-
641+
642642
// Verify all mocks were called
643643
expect(scope.isDone()).toBe(true);
644644
});
645645
});
646+
647+
describe('onAuthStateChange()', () => {
648+
test('should fire SIGNED_IN when setToken is called for the first time', () => {
649+
const callback = vi.fn();
650+
base44.auth.onAuthStateChange(callback);
651+
652+
base44.auth.setToken('token-1', false);
653+
654+
expect(callback).toHaveBeenCalledTimes(1);
655+
expect(callback).toHaveBeenCalledWith('SIGNED_IN', { access_token: 'token-1' });
656+
});
657+
658+
test('should fire TOKEN_REFRESHED when setToken is called again', () => {
659+
const callback = vi.fn();
660+
base44.auth.onAuthStateChange(callback);
661+
662+
base44.auth.setToken('token-1', false);
663+
base44.auth.setToken('token-2', false);
664+
665+
expect(callback).toHaveBeenCalledTimes(2);
666+
expect(callback).toHaveBeenCalledWith('TOKEN_REFRESHED', { access_token: 'token-2' });
667+
});
668+
669+
test('should fire SIGNED_OUT on logout', () => {
670+
const callback = vi.fn();
671+
base44.auth.onAuthStateChange(callback);
672+
673+
base44.auth.logout();
674+
675+
expect(callback).toHaveBeenCalledTimes(1);
676+
expect(callback).toHaveBeenCalledWith('SIGNED_OUT', {});
677+
});
678+
679+
test('should fire SIGNED_IN on loginViaEmailPassword', async () => {
680+
const callback = vi.fn();
681+
base44.auth.onAuthStateChange(callback);
682+
683+
scope.post(`/api/apps/${appId}/auth/login`)
684+
.reply(200, { access_token: 'login-token', user: { id: 'u1', email: 'a@b.com' } });
685+
686+
await base44.auth.loginViaEmailPassword('a@b.com', 'pass');
687+
688+
expect(callback).toHaveBeenCalledWith('SIGNED_IN', { access_token: 'login-token' });
689+
});
690+
691+
test('should stop firing after unsubscribe', () => {
692+
const callback = vi.fn();
693+
const unsubscribe = base44.auth.onAuthStateChange(callback);
694+
695+
base44.auth.setToken('token-1', false);
696+
expect(callback).toHaveBeenCalledTimes(1);
697+
698+
unsubscribe();
699+
700+
base44.auth.setToken('token-2', false);
701+
expect(callback).toHaveBeenCalledTimes(1); // no additional call
702+
});
703+
704+
test('should support multiple listeners', () => {
705+
const cb1 = vi.fn();
706+
const cb2 = vi.fn();
707+
base44.auth.onAuthStateChange(cb1);
708+
base44.auth.onAuthStateChange(cb2);
709+
710+
base44.auth.setToken('token-1', false);
711+
712+
expect(cb1).toHaveBeenCalledTimes(1);
713+
expect(cb2).toHaveBeenCalledTimes(1);
714+
});
715+
716+
test('should fire SIGNED_IN again after logout and new setToken', () => {
717+
const callback = vi.fn();
718+
base44.auth.onAuthStateChange(callback);
719+
720+
base44.auth.setToken('token-1', false);
721+
expect(callback).toHaveBeenCalledWith('SIGNED_IN', { access_token: 'token-1' });
722+
723+
base44.auth.logout();
724+
expect(callback).toHaveBeenCalledWith('SIGNED_OUT', {});
725+
726+
base44.auth.setToken('token-2', false);
727+
expect(callback).toHaveBeenCalledWith('SIGNED_IN', { access_token: 'token-2' });
728+
});
729+
});
646730
});

0 commit comments

Comments
 (0)