Skip to content

Commit 03705a3

Browse files
Add way for users to listen for accessToken changes (#21)
* Add way for users to listen for accessToken changes Right now you can only listen for changes to the logged in state. This adds a second observer option where you can listen for any changes made to the access token. * Renamed class version of org type for consistency * Explicitly specify user fields
1 parent 39a2ca1 commit 03705a3

File tree

6 files changed

+140
-37
lines changed

6 files changed

+140
-37
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "git",
66
"url": "https://github.com/PropelAuth/javascript"
77
},
8-
"version": "2.0.8",
8+
"version": "2.0.10",
99
"keywords": [
1010
"auth",
1111
"user",

src/api.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { AccessHelper, getAccessHelper } from "./access_helper"
22
import { OrgIdToOrgMemberInfo } from "./org"
33
import { getOrgHelper, OrgHelper } from "./org_helper"
4-
import { UserClass } from "./user"
4+
import { convertOrgIdToOrgMemberInfo, UserClass } from "./user"
55

66
export type User = {
77
userId: string
@@ -19,6 +19,7 @@ export type User = {
1919
locked: boolean
2020
enabled: boolean
2121
mfaEnabled: boolean
22+
canCreateOrgs: boolean
2223

2324
createdAt: number
2425
lastActiveAt: number
@@ -107,7 +108,8 @@ function parseResponse(res: Response): Promise<AuthenticationInfo> {
107108
return res.text().then(
108109
(httpResponse) => {
109110
try {
110-
return parseJsonConvertingSnakeToCamel(httpResponse)
111+
const authInfoWithoutUserClass = parseJsonConvertingSnakeToCamel(httpResponse)
112+
return withExtraArgs(authInfoWithoutUserClass)
111113
} catch (e) {
112114
console.error("Unable to process authentication response", e)
113115
return Promise.reject({
@@ -148,8 +150,6 @@ export function parseJsonConvertingSnakeToCamel(str: string): AuthenticationInfo
148150
this.expiresAtSeconds = value
149151
} else if (key === "org_id_to_org_member_info") {
150152
this.orgIdToOrgMemberInfo = value
151-
this.orgHelper = getOrgHelper(value)
152-
this.accessHelper = getAccessHelper(value)
153153
} else if (key === "user_id") {
154154
this.userId = value
155155
} else if (key === "email_confirmed") {
@@ -164,6 +164,8 @@ export function parseJsonConvertingSnakeToCamel(str: string): AuthenticationInfo
164164
this.mfaEnabled = value
165165
} else if (key === "has_password") {
166166
this.hasPassword = value
167+
} else if (key === "can_create_orgs") {
168+
this.canCreateOrgs = value
167169
} else if (key === "created_at") {
168170
this.createdAt = value
169171
} else if (key === "last_active_at") {
@@ -178,6 +180,32 @@ export function parseJsonConvertingSnakeToCamel(str: string): AuthenticationInfo
178180
})
179181
}
180182

183+
function withExtraArgs(authInfoWithoutExtraArgs: AuthenticationInfo): Promise<AuthenticationInfo> {
184+
if (authInfoWithoutExtraArgs.orgIdToOrgMemberInfo) {
185+
authInfoWithoutExtraArgs.orgHelper = getOrgHelper(authInfoWithoutExtraArgs.orgIdToOrgMemberInfo)
186+
authInfoWithoutExtraArgs.accessHelper = getAccessHelper(authInfoWithoutExtraArgs.orgIdToOrgMemberInfo)
187+
}
188+
authInfoWithoutExtraArgs.userClass = new UserClass(
189+
{
190+
userId: authInfoWithoutExtraArgs.user.userId,
191+
email: authInfoWithoutExtraArgs.user.email,
192+
createdAt: authInfoWithoutExtraArgs.user.createdAt,
193+
firstName: authInfoWithoutExtraArgs.user.firstName,
194+
lastName: authInfoWithoutExtraArgs.user.lastName,
195+
username: authInfoWithoutExtraArgs.user.username,
196+
properties: authInfoWithoutExtraArgs.user.properties,
197+
pictureUrl: authInfoWithoutExtraArgs.user.pictureUrl,
198+
hasPassword: authInfoWithoutExtraArgs.user.hasPassword,
199+
hasMfaEnabled: authInfoWithoutExtraArgs.user.mfaEnabled,
200+
canCreateOrgs: authInfoWithoutExtraArgs.user.canCreateOrgs,
201+
legacyUserId: authInfoWithoutExtraArgs.user.legacyUserId,
202+
impersonatorUserId: authInfoWithoutExtraArgs.impersonatorUserId,
203+
},
204+
convertOrgIdToOrgMemberInfo(authInfoWithoutExtraArgs.orgIdToOrgMemberInfo)
205+
)
206+
return Promise.resolve(authInfoWithoutExtraArgs)
207+
}
208+
181209
function logCorsError() {
182210
console.error(
183211
"Request to PropelAuth failed due to a CORS error. There are a few likely causes: \n" +

src/client.ts

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import {AuthenticationInfo, fetchAuthenticationInfo, logout} from "./api"
2-
import {currentTimeSeconds, getLocalStorageNumber, hasLocalStorage, hasWindow} from "./helpers"
1+
import { AuthenticationInfo, fetchAuthenticationInfo, logout } from "./api"
2+
import { currentTimeSeconds, getLocalStorageNumber, hasLocalStorage, hasWindow } from "./helpers"
33

44
const LOGGED_IN_AT_KEY = "__PROPEL_AUTH_LOGGED_IN_AT"
55
const LOGGED_OUT_AT_KEY = "__PROPEL_AUTH_LOGGED_OUT_AT"
6-
const AUTH_TOKEN_REFRESH_BEFORE_EXPIRATION_SECONDS = 4 * 60
7-
const DEBOUNCE_DURATION_FOR_REFOCUS_SECONDS = 4 * 60
6+
const AUTH_TOKEN_REFRESH_BEFORE_EXPIRATION_SECONDS = 10 * 60
7+
const DEBOUNCE_DURATION_FOR_REFOCUS_SECONDS = 60
88

99
export interface RedirectToSignupOptions {
1010
postSignupRedirectUrl: string
@@ -95,7 +95,6 @@ export interface IAuthClient {
9595
*/
9696
redirectToSetupSAMLPage(orgId: string): void
9797

98-
9998
/**
10099
* Adds an observer which is called whenever the users logs in or logs out.
101100
*/
@@ -106,6 +105,16 @@ export interface IAuthClient {
106105
*/
107106
removeLoggedInChangeObserver(observer: (isLoggedIn: boolean) => void): void
108107

108+
/**
109+
* Adds an observer which is called whenever the access token changes.
110+
*/
111+
addAccessTokenChangeObserver(observer: (accessToken: string | undefined) => void): void
112+
113+
/**
114+
* Removes the observer
115+
*/
116+
removeAccessTokenChangeObserver(observer: (accessToken: string | undefined) => void): void
117+
109118
/**
110119
* Cleanup the auth client if you no longer need it.
111120
*/
@@ -131,6 +140,7 @@ interface ClientState {
131140
initialLoadFinished: boolean
132141
authenticationInfo: AuthenticationInfo | null
133142
observers: ((isLoggedIn: boolean) => void)[]
143+
accessTokenObservers: ((accessToken: string | undefined) => void)[]
134144
lastLoggedInAtMessage: number | null
135145
lastLoggedOutAtMessage: number | null
136146
refreshInterval: number | null
@@ -161,6 +171,7 @@ export function createClient(authOptions: IAuthOptions): IAuthClient {
161171
initialLoadFinished: false,
162172
authenticationInfo: null,
163173
observers: [],
174+
accessTokenObservers: [],
164175
lastLoggedInAtMessage: getLocalStorageNumber(LOGGED_IN_AT_KEY),
165176
lastLoggedOutAtMessage: getLocalStorageNumber(LOGGED_OUT_AT_KEY),
166177
authUrl: authOptions.authUrl,
@@ -178,6 +189,15 @@ export function createClient(authOptions: IAuthOptions): IAuthClient {
178189
}
179190
}
180191

192+
function notifyObserversOfAccessTokenChange(accessToken: string | undefined) {
193+
for (let i = 0; i < clientState.accessTokenObservers.length; i++) {
194+
const observer = clientState.accessTokenObservers[i]
195+
if (observer) {
196+
observer(accessToken)
197+
}
198+
}
199+
}
200+
181201
function userJustLoggedOut(accessToken: string | undefined, previousAccessToken: string | undefined) {
182202
// Edge case: the first time we go to the page, if we can't load the
183203
// auth token we should treat it as a logout event
@@ -217,6 +237,10 @@ export function createClient(authOptions: IAuthOptions): IAuthClient {
217237
updateLastLoggedInAt()
218238
}
219239

240+
if (previousAccessToken !== accessToken) {
241+
notifyObserversOfAccessTokenChange(accessToken)
242+
}
243+
220244
clientState.lastRefresh = currentTimeSeconds()
221245
clientState.initialLoadFinished = true
222246
}
@@ -242,17 +266,17 @@ export function createClient(authOptions: IAuthOptions): IAuthClient {
242266
const getSignupPageUrl = (options?: RedirectToSignupOptions) => {
243267
let qs = ""
244268
if (options && options.postSignupRedirectUrl) {
245-
const encode = window ? window.btoa : btoa;
246-
qs = new URLSearchParams({"rt": encode(options.postSignupRedirectUrl)}).toString()
269+
const encode = window ? window.btoa : btoa
270+
qs = new URLSearchParams({ rt: encode(options.postSignupRedirectUrl) }).toString()
247271
}
248272
return `${clientState.authUrl}/signup?${qs}`
249273
}
250274

251275
const getLoginPageUrl = (options?: RedirectToLoginOptions) => {
252276
let qs = ""
253277
if (options && options.postLoginRedirectUrl) {
254-
const encode = window ? window.btoa : btoa;
255-
qs = new URLSearchParams({"rt": encode(options.postLoginRedirectUrl)}).toString()
278+
const encode = window ? window.btoa : btoa
279+
qs = new URLSearchParams({ rt: encode(options.postLoginRedirectUrl) }).toString()
256280
}
257281
return `${clientState.authUrl}/login?${qs}`
258282
}
@@ -298,6 +322,26 @@ export function createClient(authOptions: IAuthOptions): IAuthClient {
298322
}
299323
},
300324

325+
addAccessTokenChangeObserver(observer: (accessToken: string | undefined) => void) {
326+
const hasObserver = clientState.accessTokenObservers.includes(observer)
327+
if (hasObserver) {
328+
console.error("Observer has been attached already.")
329+
} else if (!observer) {
330+
console.error("Cannot add a null observer")
331+
} else {
332+
clientState.accessTokenObservers.push(observer)
333+
}
334+
},
335+
336+
removeAccessTokenChangeObserver(observer: (accessToken: string | undefined) => void) {
337+
const observerIndex = clientState.accessTokenObservers.indexOf(observer)
338+
if (observerIndex === -1) {
339+
console.error("Cannot find observer to remove")
340+
} else {
341+
clientState.accessTokenObservers.splice(observerIndex, 1)
342+
}
343+
},
344+
301345
async getAuthenticationInfoOrNull(forceRefresh?: boolean): Promise<AuthenticationInfo | null> {
302346
const currentTimeSecs = currentTimeSeconds()
303347
if (forceRefresh) {
@@ -375,6 +419,7 @@ export function createClient(authOptions: IAuthOptions): IAuthClient {
375419

376420
destroy() {
377421
clientState.observers = []
422+
clientState.accessTokenObservers = []
378423
window.removeEventListener("storage", onStorageChange)
379424
if (clientState.refreshInterval) {
380425
clearInterval(clientState.refreshInterval)
@@ -412,7 +457,10 @@ export function createClient(authOptions: IAuthOptions): IAuthClient {
412457
// If we were offline or on a different tab, when we return, refetch auth info
413458
// Some browsers trigger focus more often than we'd like, so we'll debounce a little here as well
414459
const onOnlineOrFocus = async function () {
415-
if (clientState.lastRefresh && currentTimeSeconds() > clientState.lastRefresh + DEBOUNCE_DURATION_FOR_REFOCUS_SECONDS) {
460+
if (
461+
clientState.lastRefresh &&
462+
currentTimeSeconds() > clientState.lastRefresh + DEBOUNCE_DURATION_FOR_REFOCUS_SECONDS
463+
) {
416464
await forceRefreshToken(true)
417465
} else {
418466
await client.getAuthenticationInfoOrNull()

src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ export type { AccessHelper, AccessHelperWithOrg } from "./access_helper"
22
export type { AuthenticationInfo, User } from "./api"
33
export { createClient } from "./client"
44
export type { IAuthClient, IAuthOptions, RedirectToLoginOptions, RedirectToSignupOptions } from "./client"
5+
export { ACTIVE_ORG_ID_COOKIE_NAME } from "./cookies"
56
export { getActiveOrgId, setActiveOrgId } from "./org"
67
export type { OrgIdToOrgMemberInfo, OrgMemberInfo } from "./org"
78
export type { OrgHelper } from "./org_helper"
8-
export type { OrgIdToUserOrgInfo } from "./user"
9-
export { UserClass, UserOrgInfo } from "./user"
10-
9+
export { OrgMemberInfoClass, UserClass } from "./user"
10+
export type { OrgIdToOrgMemberInfoClass, UserFields, UserProperties } from "./user"

src/user.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { UserClass, UserOrgInfo } from "./user"
1+
import { UserClass, OrgMemberInfoClass } from "./user"
22

3-
const mockUserOrgInfo = new UserOrgInfo(
3+
const mockUserOrgInfo = new OrgMemberInfoClass(
44
"mockOrgId",
55
"Mock Org Name",
66
{},
@@ -95,8 +95,8 @@ describe("User", () => {
9595
expect(mockUserOrgInfo.hasAllPermissions(["user::create", "user::update"])).toEqual(false)
9696
})
9797
it("should parse a org member info from JSON string", () => {
98-
expect(UserOrgInfo.fromJSON(JSON.stringify(mockUserOrgInfo))).toEqual(mockUserOrgInfo)
99-
expect(() => UserOrgInfo.fromJSON("invalid json")).toThrowError()
98+
expect(OrgMemberInfoClass.fromJSON(JSON.stringify(mockUserOrgInfo))).toEqual(mockUserOrgInfo)
99+
expect(() => OrgMemberInfoClass.fromJSON("invalid json")).toThrowError()
100100
})
101101
})
102102
})

0 commit comments

Comments
 (0)