@planningcenter/chat-react-native 3.21.2-rc.4 → 3.21.2-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/contexts/session_context.d.ts +40 -0
- package/build/contexts/session_context.d.ts.map +1 -0
- package/build/contexts/session_context.js +131 -0
- package/build/contexts/session_context.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/contexts/session_context.tsx +420 -0
- package/src/contexts/session_context.tsx +234 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Session, ENV } from '../utils';
|
|
2
|
+
import { FailedResponse, OAuthToken } from '../types';
|
|
3
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
4
|
+
import React, { PropsWithChildren } from 'react';
|
|
5
|
+
import { StorageAdapter } from '../utils/native_adapters/storage_adapter';
|
|
6
|
+
export type SessionContextValue = {
|
|
7
|
+
env: ENV;
|
|
8
|
+
setEnv: (_env: ENV) => void;
|
|
9
|
+
session: Session;
|
|
10
|
+
token: OAuthToken | undefined;
|
|
11
|
+
handleUnauthorizedResponse: (_response: FailedResponse) => void;
|
|
12
|
+
logout: () => void;
|
|
13
|
+
setToken: (_token: OAuthToken) => void;
|
|
14
|
+
};
|
|
15
|
+
export declare const SessionContext: React.Context<SessionContextValue>;
|
|
16
|
+
export type SessionContextConfig = {
|
|
17
|
+
storage: StorageAdapter;
|
|
18
|
+
secureStorage: StorageAdapter;
|
|
19
|
+
refreshTokenFn: (params: {
|
|
20
|
+
refresh_token: string;
|
|
21
|
+
env: ENV;
|
|
22
|
+
}) => Promise<OAuthToken>;
|
|
23
|
+
onLogout: () => void | Promise<void>;
|
|
24
|
+
storageKeys?: {
|
|
25
|
+
env?: string;
|
|
26
|
+
sessions?: string;
|
|
27
|
+
};
|
|
28
|
+
alertConfig?: {
|
|
29
|
+
title?: string;
|
|
30
|
+
message?: string;
|
|
31
|
+
retryText?: string;
|
|
32
|
+
logoutText?: string;
|
|
33
|
+
};
|
|
34
|
+
defaultEnv?: ENV;
|
|
35
|
+
};
|
|
36
|
+
export declare function SessionContextProvider({ children, queryClient, config, }: PropsWithChildren<{
|
|
37
|
+
queryClient: QueryClient;
|
|
38
|
+
config: SessionContextConfig;
|
|
39
|
+
}>): React.JSX.Element;
|
|
40
|
+
//# sourceMappingURL=session_context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session_context.d.ts","sourceRoot":"","sources":["../../src/contexts/session_context.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;AACvC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAErD,OAAO,EAAE,WAAW,EAAe,MAAM,uBAAuB,CAAA;AAChE,OAAO,KAAK,EAAE,EAEZ,iBAAiB,EAKlB,MAAM,OAAO,CAAA;AAGd,OAAO,EAAE,cAAc,EAAE,MAAM,0CAA0C,CAAA;AAEzE,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,EAAE,GAAG,CAAA;IACR,MAAM,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,CAAA;IAC3B,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,UAAU,GAAG,SAAS,CAAA;IAC7B,0BAA0B,EAAE,CAAC,SAAS,EAAE,cAAc,KAAK,IAAI,CAAA;IAC/D,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,QAAQ,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,IAAI,CAAA;CACvC,CAAA;AAED,eAAO,MAAM,cAAc,oCAQzB,CAAA;AAaF,MAAM,MAAM,oBAAoB,GAAG;IAEjC,OAAO,EAAE,cAAc,CAAA;IACvB,aAAa,EAAE,cAAc,CAAA;IAG7B,cAAc,EAAE,CAAC,MAAM,EAAE;QAAE,aAAa,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,GAAG,CAAA;KAAE,KAAK,OAAO,CAAC,UAAU,CAAC,CAAA;IACpF,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAGpC,WAAW,CAAC,EAAE;QACZ,GAAG,CAAC,EAAE,MAAM,CAAA;QACZ,QAAQ,CAAC,EAAE,MAAM,CAAA;KAClB,CAAA;IACD,WAAW,CAAC,EAAE;QACZ,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,SAAS,CAAC,EAAE,MAAM,CAAA;QAClB,UAAU,CAAC,EAAE,MAAM,CAAA;KACpB,CAAA;IACD,UAAU,CAAC,EAAE,GAAG,CAAA;CACjB,CAAA;AAED,wBAAgB,sBAAsB,CAAC,EACrC,QAAQ,EACR,WAAW,EACX,MAAM,GACP,EAAE,iBAAiB,CAAC;IAAE,WAAW,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,oBAAoB,CAAA;CAAE,CAAC,qBA+J/E"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Session } from '../utils';
|
|
2
|
+
import { chatQueryClient } from './api_provider';
|
|
3
|
+
import { useMutation } from '@tanstack/react-query';
|
|
4
|
+
import React, { createContext, useCallback, useEffect, useMemo, useState, } from 'react';
|
|
5
|
+
import { Alert } from 'react-native';
|
|
6
|
+
import { useStorage } from '../hooks/use_storage';
|
|
7
|
+
export const SessionContext = createContext({
|
|
8
|
+
env: 'production',
|
|
9
|
+
setEnv: () => null,
|
|
10
|
+
session: new Session(),
|
|
11
|
+
token: undefined,
|
|
12
|
+
handleUnauthorizedResponse: () => null,
|
|
13
|
+
logout: () => null,
|
|
14
|
+
setToken: () => null,
|
|
15
|
+
});
|
|
16
|
+
const environments = ['production', 'staging', 'development'];
|
|
17
|
+
const initialSessions = environments.reduce((acc, env) => {
|
|
18
|
+
const sessionProps = { env, token: undefined };
|
|
19
|
+
acc[env] = new Session(sessionProps).toString();
|
|
20
|
+
return acc;
|
|
21
|
+
}, {});
|
|
22
|
+
export function SessionContextProvider({ children, queryClient, config, }) {
|
|
23
|
+
const { storage, secureStorage, refreshTokenFn, onLogout, storageKeys = {}, alertConfig = {}, defaultEnv = 'production', } = config;
|
|
24
|
+
const envKey = storageKeys.env || 'env';
|
|
25
|
+
const sessionsKey = storageKeys.sessions || 'sessions-storage';
|
|
26
|
+
const [env, setEnv] = useStorage(storage, envKey, defaultEnv);
|
|
27
|
+
const [sessions, setSessions] = useStorage(secureStorage, sessionsKey, initialSessions);
|
|
28
|
+
const [alertShown, setAlertShown] = useState(false);
|
|
29
|
+
const session = useMemo(() => Session.hydrate(sessions[env]), [sessions, env]);
|
|
30
|
+
const { token } = session;
|
|
31
|
+
const { mutate: refreshToken, isPending: isRefreshingToken, isError: isRefreshError, } = useMutation({
|
|
32
|
+
mutationKey: ['refresh-token', token?.refresh_token],
|
|
33
|
+
mutationFn: () => {
|
|
34
|
+
if (!token?.refresh_token) {
|
|
35
|
+
return Promise.reject(new Error('Refresh token is required'));
|
|
36
|
+
}
|
|
37
|
+
return refreshTokenFn({ refresh_token: token.refresh_token, env: session.env }).then(handleTokenUpdate);
|
|
38
|
+
},
|
|
39
|
+
onError: (t) => {
|
|
40
|
+
handleRefreshFailed(t);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const handleClearQueryClient = useCallback(() => {
|
|
44
|
+
chatQueryClient.clear();
|
|
45
|
+
queryClient.clear();
|
|
46
|
+
}, [queryClient]);
|
|
47
|
+
const handleSetEnv = useCallback((_env) => {
|
|
48
|
+
setEnv(_env);
|
|
49
|
+
handleClearQueryClient();
|
|
50
|
+
}, [handleClearQueryClient, setEnv]);
|
|
51
|
+
const handleLogout = useCallback(async () => {
|
|
52
|
+
handleClearQueryClient();
|
|
53
|
+
return setSessions({
|
|
54
|
+
...sessions,
|
|
55
|
+
[env]: new Session({ env }).toString(),
|
|
56
|
+
}).then(() => onLogout());
|
|
57
|
+
}, [env, sessions, setSessions, handleClearQueryClient, onLogout]);
|
|
58
|
+
const handleTokenUpdate = useCallback((t) => {
|
|
59
|
+
if (!t || !session)
|
|
60
|
+
return t;
|
|
61
|
+
session.token = t;
|
|
62
|
+
return setSessions({
|
|
63
|
+
...sessions,
|
|
64
|
+
[env]: session?.toString(),
|
|
65
|
+
}).then(() => t);
|
|
66
|
+
}, [env, session, sessions, setSessions]);
|
|
67
|
+
const handleRefreshFailed = useCallback((t) => {
|
|
68
|
+
if (t.status !== 401)
|
|
69
|
+
return;
|
|
70
|
+
// If we didn't fail because of an unauthorized response ( 401 ),
|
|
71
|
+
// it could be because we were unable to store the token.
|
|
72
|
+
// Do not log out.
|
|
73
|
+
const { errors = [] } = t;
|
|
74
|
+
// The code for a forced logout is "capuchin"
|
|
75
|
+
const isForcedLogout = errors.some((e) => /capuchin/i.test(e.detail || ''));
|
|
76
|
+
if (isForcedLogout) {
|
|
77
|
+
handleLogout();
|
|
78
|
+
}
|
|
79
|
+
}, [handleLogout]);
|
|
80
|
+
const handleUnauthorizedResponse = useCallback(async (response) => {
|
|
81
|
+
const { errors = [] } = response;
|
|
82
|
+
const isUnauthorized = errors.some((e) => /TRASH_PANDA/i.test(e.detail || ''));
|
|
83
|
+
if (isUnauthorized)
|
|
84
|
+
return;
|
|
85
|
+
const isForcedLogout = errors.some((e) => /capuchin/i.test(e.detail || ''));
|
|
86
|
+
const isExpiredToken = errors.some((e) => /baboon/i.test(e.detail || ''));
|
|
87
|
+
if (!isRefreshingToken || isExpiredToken || isForcedLogout) {
|
|
88
|
+
refreshToken();
|
|
89
|
+
}
|
|
90
|
+
}, [refreshToken, isRefreshingToken]);
|
|
91
|
+
const sessionContextValue = {
|
|
92
|
+
env: session.env,
|
|
93
|
+
token: session.token,
|
|
94
|
+
session,
|
|
95
|
+
logout: handleLogout,
|
|
96
|
+
setToken: handleTokenUpdate,
|
|
97
|
+
handleUnauthorizedResponse,
|
|
98
|
+
setEnv: handleSetEnv,
|
|
99
|
+
};
|
|
100
|
+
const alertTitle = alertConfig.title || 'Oops';
|
|
101
|
+
const alertMessage = alertConfig.message || 'Something went wrong with your login!';
|
|
102
|
+
const retryText = alertConfig.retryText || 'Keep trying';
|
|
103
|
+
const logoutText = alertConfig.logoutText || 'Logout';
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (isRefreshError && !alertShown && token?.refresh_token) {
|
|
106
|
+
setAlertShown(true);
|
|
107
|
+
Alert.alert(alertTitle, alertMessage, [
|
|
108
|
+
{
|
|
109
|
+
text: retryText,
|
|
110
|
+
onPress: () => {
|
|
111
|
+
refreshToken();
|
|
112
|
+
setAlertShown(false);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{ text: logoutText, onPress: () => handleLogout() },
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
}, [
|
|
119
|
+
isRefreshError,
|
|
120
|
+
handleLogout,
|
|
121
|
+
alertShown,
|
|
122
|
+
refreshToken,
|
|
123
|
+
token?.refresh_token,
|
|
124
|
+
alertTitle,
|
|
125
|
+
alertMessage,
|
|
126
|
+
retryText,
|
|
127
|
+
logoutText,
|
|
128
|
+
]);
|
|
129
|
+
return <SessionContext.Provider value={sessionContextValue}>{children}</SessionContext.Provider>;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=session_context.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session_context.js","sourceRoot":"","sources":["../../src/contexts/session_context.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAO,MAAM,UAAU,CAAA;AAEvC,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,EAAe,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAChE,OAAO,KAAK,EAAE,EACZ,aAAa,EAEb,WAAW,EACX,SAAS,EACT,OAAO,EACP,QAAQ,GACT,MAAM,OAAO,CAAA;AACd,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAA;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAajD,MAAM,CAAC,MAAM,cAAc,GAAG,aAAa,CAAsB;IAC/D,GAAG,EAAE,YAAY;IACjB,MAAM,EAAE,GAAG,EAAE,CAAC,IAAI;IAClB,OAAO,EAAE,IAAI,OAAO,EAAE;IACtB,KAAK,EAAE,SAAS;IAChB,0BAA0B,EAAE,GAAG,EAAE,CAAC,IAAI;IACtC,MAAM,EAAE,GAAG,EAAE,CAAC,IAAI;IAClB,QAAQ,EAAE,GAAG,EAAE,CAAC,IAAI;CACrB,CAAC,CAAA;AAGF,MAAM,YAAY,GAAU,CAAC,YAAY,EAAE,SAAS,EAAE,aAAa,CAAC,CAAA;AAEpE,MAAM,eAAe,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IACvD,MAAM,YAAY,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,SAAS,EAAE,CAAA;IAE9C,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,OAAO,CAAC,YAAY,CAAC,CAAC,QAAQ,EAAE,CAAA;IAE/C,OAAO,GAAG,CAAA;AACZ,CAAC,EAAE,EAAc,CAAC,CAAA;AAyBlB,MAAM,UAAU,sBAAsB,CAAC,EACrC,QAAQ,EACR,WAAW,EACX,MAAM,GACwE;IAC9E,MAAM,EACJ,OAAO,EACP,aAAa,EACb,cAAc,EACd,QAAQ,EACR,WAAW,GAAG,EAAE,EAChB,WAAW,GAAG,EAAE,EAChB,UAAU,GAAG,YAAY,GAC1B,GAAG,MAAM,CAAA;IAEV,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,IAAI,KAAK,CAAA;IACvC,MAAM,WAAW,GAAG,WAAW,CAAC,QAAQ,IAAI,kBAAkB,CAAA;IAE9D,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,UAAU,CAAM,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,CAAA;IAClE,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,UAAU,CAAW,aAAa,EAAE,WAAW,EAAE,eAAe,CAAC,CAAA;IACjG,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IACnD,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAa,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC,CAAA;IAC1F,MAAM,EAAE,KAAK,EAAE,GAAG,OAAO,CAAA;IAEzB,MAAM,EACJ,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,iBAAiB,EAC5B,OAAO,EAAE,cAAc,GACxB,GAAG,WAAW,CAAC;QACd,WAAW,EAAE,CAAC,eAAe,EAAE,KAAK,EAAE,aAAa,CAAC;QACpD,UAAU,EAAE,GAAG,EAAE;YACf,IAAI,CAAC,KAAK,EAAE,aAAa,EAAE,CAAC;gBAC1B,OAAO,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,CAAC,CAAC,CAAA;YAC/D,CAAC;YACD,OAAO,cAAc,CAAC,EAAE,aAAa,EAAE,KAAK,CAAC,aAAa,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,CAClF,iBAAiB,CAClB,CAAA;QACH,CAAC;QACD,OAAO,EAAE,CAAC,CAA0B,EAAE,EAAE;YACtC,mBAAmB,CAAC,CAAC,CAAC,CAAA;QACxB,CAAC;KACF,CAAC,CAAA;IAEF,MAAM,sBAAsB,GAAG,WAAW,CAAC,GAAG,EAAE;QAC9C,eAAe,CAAC,KAAK,EAAE,CAAA;QACvB,WAAW,CAAC,KAAK,EAAE,CAAA;IACrB,CAAC,EAAE,CAAC,WAAW,CAAC,CAAC,CAAA;IAEjB,MAAM,YAAY,GAAG,WAAW,CAC9B,CAAC,IAAS,EAAE,EAAE;QACZ,MAAM,CAAC,IAAI,CAAC,CAAA;QACZ,sBAAsB,EAAE,CAAA;IAC1B,CAAC,EACD,CAAC,sBAAsB,EAAE,MAAM,CAAC,CACjC,CAAA;IAED,MAAM,YAAY,GAAG,WAAW,CAAC,KAAK,IAAI,EAAE;QAC1C,sBAAsB,EAAE,CAAA;QAExB,OAAO,WAAW,CAAC;YACjB,GAAG,QAAQ;YACX,CAAC,GAAG,CAAC,EAAE,IAAI,OAAO,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,QAAQ,EAAE;SACvC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAC,CAAA;IAC3B,CAAC,EAAE,CAAC,GAAG,EAAE,QAAQ,EAAE,WAAW,EAAE,sBAAsB,EAAE,QAAQ,CAAC,CAAC,CAAA;IAElE,MAAM,iBAAiB,GAAG,WAAW,CACnC,CAAC,CAAa,EAAE,EAAE;QAChB,IAAI,CAAC,CAAC,IAAI,CAAC,OAAO;YAAE,OAAO,CAAC,CAAA;QAE5B,OAAO,CAAC,KAAK,GAAG,CAAC,CAAA;QAEjB,OAAO,WAAW,CAAC;YACjB,GAAG,QAAQ;YACX,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE;SAC3B,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAA;IAClB,CAAC,EACD,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,CAAC,CACtC,CAAA;IAED,MAAM,mBAAmB,GAAG,WAAW,CACrC,CAAC,CAA0B,EAAE,EAAE;QAC7B,IAAI,CAAC,CAAC,MAAM,KAAK,GAAG;YAAE,OAAM;QAC5B,iEAAiE;QACjE,yDAAyD;QACzD,kBAAkB;QAElB,MAAM,EAAE,MAAM,GAAG,EAAE,EAAE,GAAG,CAAC,CAAA;QACzB,6CAA6C;QAC7C,MAAM,cAAc,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAsB,EAAE,EAAE,CAC5D,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,IAAI,EAAE,CAAC,CACjC,CAAA;QAED,IAAI,cAAc,EAAE,CAAC;YACnB,YAAY,EAAE,CAAA;QAChB,CAAC;IACH,CAAC,EACD,CAAC,YAAY,CAAC,CACf,CAAA;IAED,MAAM,0BAA0B,GAAG,WAAW,CAC5C,KAAK,EAAE,QAAwB,EAAE,EAAE;QACjC,MAAM,EAAE,MAAM,GAAG,EAAE,EAAE,GAAG,QAAQ,CAAA;QAEhC,MAAM,cAAc,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAsB,EAAE,EAAE,CAC5D,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,IAAI,EAAE,CAAC,CACpC,CAAA;QAED,IAAI,cAAc;YAAE,OAAM;QAE1B,MAAM,cAAc,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAsB,EAAE,EAAE,CAC5D,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,IAAI,EAAE,CAAC,CACjC,CAAA;QACD,MAAM,cAAc,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAsB,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,CAAA;QAE9F,IAAI,CAAC,iBAAiB,IAAI,cAAc,IAAI,cAAc,EAAE,CAAC;YAC3D,YAAY,EAAE,CAAA;QAChB,CAAC;IACH,CAAC,EACD,CAAC,YAAY,EAAE,iBAAiB,CAAC,CAClC,CAAA;IAED,MAAM,mBAAmB,GAAwB;QAC/C,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,OAAO;QACP,MAAM,EAAE,YAAY;QACpB,QAAQ,EAAE,iBAAiB;QAC3B,0BAA0B;QAC1B,MAAM,EAAE,YAAY;KACrB,CAAA;IAED,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,IAAI,MAAM,CAAA;IAC9C,MAAM,YAAY,GAAG,WAAW,CAAC,OAAO,IAAI,uCAAuC,CAAA;IACnF,MAAM,SAAS,GAAG,WAAW,CAAC,SAAS,IAAI,aAAa,CAAA;IACxD,MAAM,UAAU,GAAG,WAAW,CAAC,UAAU,IAAI,QAAQ,CAAA;IAErD,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,cAAc,IAAI,CAAC,UAAU,IAAI,KAAK,EAAE,aAAa,EAAE,CAAC;YAC1D,aAAa,CAAC,IAAI,CAAC,CAAA;YACnB,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,YAAY,EAAE;gBACpC;oBACE,IAAI,EAAE,SAAS;oBACf,OAAO,EAAE,GAAG,EAAE;wBACZ,YAAY,EAAE,CAAA;wBACd,aAAa,CAAC,KAAK,CAAC,CAAA;oBACtB,CAAC;iBACF;gBACD,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,YAAY,EAAE,EAAE;aACpD,CAAC,CAAA;QACJ,CAAC;IACH,CAAC,EAAE;QACD,cAAc;QACd,YAAY;QACZ,UAAU;QACV,YAAY;QACZ,KAAK,EAAE,aAAa;QACpB,UAAU;QACV,YAAY;QACZ,SAAS;QACT,UAAU;KACX,CAAC,CAAA;IAEF,OAAO,CAAC,cAAc,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAA;AAClG,CAAC","sourcesContent":["import { Session, ENV } from '../utils'\nimport { FailedResponse, OAuthToken } from '../types'\nimport { chatQueryClient } from './api_provider'\nimport { QueryClient, useMutation } from '@tanstack/react-query'\nimport React, {\n createContext,\n PropsWithChildren,\n useCallback,\n useEffect,\n useMemo,\n useState,\n} from 'react'\nimport { Alert } from 'react-native'\nimport { useStorage } from '../hooks/use_storage'\nimport { StorageAdapter } from '../utils/native_adapters/storage_adapter'\n\nexport type SessionContextValue = {\n env: ENV\n setEnv: (_env: ENV) => void\n session: Session\n token: OAuthToken | undefined\n handleUnauthorizedResponse: (_response: FailedResponse) => void\n logout: () => void\n setToken: (_token: OAuthToken) => void\n}\n\nexport const SessionContext = createContext<SessionContextValue>({\n env: 'production',\n setEnv: () => null,\n session: new Session(),\n token: undefined,\n handleUnauthorizedResponse: () => null,\n logout: () => null,\n setToken: () => null,\n})\n\ntype Sessions = Record<ENV, string>\nconst environments: ENV[] = ['production', 'staging', 'development']\n\nconst initialSessions = environments.reduce((acc, env) => {\n const sessionProps = { env, token: undefined }\n\n acc[env] = new Session(sessionProps).toString()\n\n return acc\n}, {} as Sessions)\n\nexport type SessionContextConfig = {\n // Storage adapters\n storage: StorageAdapter // For non-sensitive data (env preference)\n secureStorage: StorageAdapter // For sensitive data (tokens/sessions)\n\n // Functional callbacks\n refreshTokenFn: (params: { refresh_token: string; env: ENV }) => Promise<OAuthToken>\n onLogout: () => void | Promise<void>\n\n // Optional configuration\n storageKeys?: {\n env?: string\n sessions?: string\n }\n alertConfig?: {\n title?: string\n message?: string\n retryText?: string\n logoutText?: string\n }\n defaultEnv?: ENV\n}\n\nexport function SessionContextProvider({\n children,\n queryClient,\n config,\n}: PropsWithChildren<{ queryClient: QueryClient; config: SessionContextConfig }>) {\n const {\n storage,\n secureStorage,\n refreshTokenFn,\n onLogout,\n storageKeys = {},\n alertConfig = {},\n defaultEnv = 'production',\n } = config\n\n const envKey = storageKeys.env || 'env'\n const sessionsKey = storageKeys.sessions || 'sessions-storage'\n\n const [env, setEnv] = useStorage<ENV>(storage, envKey, defaultEnv)\n const [sessions, setSessions] = useStorage<Sessions>(secureStorage, sessionsKey, initialSessions)\n const [alertShown, setAlertShown] = useState(false)\n const session = useMemo(() => Session.hydrate<OAuthToken>(sessions[env]), [sessions, env])\n const { token } = session\n\n const {\n mutate: refreshToken,\n isPending: isRefreshingToken,\n isError: isRefreshError,\n } = useMutation({\n mutationKey: ['refresh-token', token?.refresh_token],\n mutationFn: () => {\n if (!token?.refresh_token) {\n return Promise.reject(new Error('Refresh token is required'))\n }\n return refreshTokenFn({ refresh_token: token.refresh_token, env: session.env }).then(\n handleTokenUpdate\n )\n },\n onError: (t: Partial<FailedResponse>) => {\n handleRefreshFailed(t)\n },\n })\n\n const handleClearQueryClient = useCallback(() => {\n chatQueryClient.clear()\n queryClient.clear()\n }, [queryClient])\n\n const handleSetEnv = useCallback(\n (_env: ENV) => {\n setEnv(_env)\n handleClearQueryClient()\n },\n [handleClearQueryClient, setEnv]\n )\n\n const handleLogout = useCallback(async () => {\n handleClearQueryClient()\n\n return setSessions({\n ...sessions,\n [env]: new Session({ env }).toString(),\n }).then(() => onLogout())\n }, [env, sessions, setSessions, handleClearQueryClient, onLogout])\n\n const handleTokenUpdate = useCallback(\n (t: OAuthToken) => {\n if (!t || !session) return t\n\n session.token = t\n\n return setSessions({\n ...sessions,\n [env]: session?.toString(),\n }).then(() => t)\n },\n [env, session, sessions, setSessions]\n )\n\n const handleRefreshFailed = useCallback(\n (t: Partial<FailedResponse>) => {\n if (t.status !== 401) return\n // If we didn't fail because of an unauthorized response ( 401 ),\n // it could be because we were unable to store the token.\n // Do not log out.\n\n const { errors = [] } = t\n // The code for a forced logout is \"capuchin\"\n const isForcedLogout = errors.some((e: { detail?: string }) =>\n /capuchin/i.test(e.detail || '')\n )\n\n if (isForcedLogout) {\n handleLogout()\n }\n },\n [handleLogout]\n )\n\n const handleUnauthorizedResponse = useCallback(\n async (response: FailedResponse) => {\n const { errors = [] } = response\n\n const isUnauthorized = errors.some((e: { detail?: string }) =>\n /TRASH_PANDA/i.test(e.detail || '')\n )\n\n if (isUnauthorized) return\n\n const isForcedLogout = errors.some((e: { detail?: string }) =>\n /capuchin/i.test(e.detail || '')\n )\n const isExpiredToken = errors.some((e: { detail?: string }) => /baboon/i.test(e.detail || ''))\n\n if (!isRefreshingToken || isExpiredToken || isForcedLogout) {\n refreshToken()\n }\n },\n [refreshToken, isRefreshingToken]\n )\n\n const sessionContextValue: SessionContextValue = {\n env: session.env,\n token: session.token,\n session,\n logout: handleLogout,\n setToken: handleTokenUpdate,\n handleUnauthorizedResponse,\n setEnv: handleSetEnv,\n }\n\n const alertTitle = alertConfig.title || 'Oops'\n const alertMessage = alertConfig.message || 'Something went wrong with your login!'\n const retryText = alertConfig.retryText || 'Keep trying'\n const logoutText = alertConfig.logoutText || 'Logout'\n\n useEffect(() => {\n if (isRefreshError && !alertShown && token?.refresh_token) {\n setAlertShown(true)\n Alert.alert(alertTitle, alertMessage, [\n {\n text: retryText,\n onPress: () => {\n refreshToken()\n setAlertShown(false)\n },\n },\n { text: logoutText, onPress: () => handleLogout() },\n ])\n }\n }, [\n isRefreshError,\n handleLogout,\n alertShown,\n refreshToken,\n token?.refresh_token,\n alertTitle,\n alertMessage,\n retryText,\n logoutText,\n ])\n\n return <SessionContext.Provider value={sessionContextValue}>{children}</SessionContext.Provider>\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planningcenter/chat-react-native",
|
|
3
|
-
"version": "3.21.2-rc.
|
|
3
|
+
"version": "3.21.2-rc.5",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"react-native-url-polyfill": "^2.0.0",
|
|
59
59
|
"typescript": "<5.6.0"
|
|
60
60
|
},
|
|
61
|
-
"gitHead": "
|
|
61
|
+
"gitHead": "c1a1960f32f77300be55537e4b34860322c12ff7"
|
|
62
62
|
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react-hooks'
|
|
2
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
3
|
+
import React, { useContext, Suspense } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
SessionContext,
|
|
6
|
+
SessionContextProvider,
|
|
7
|
+
SessionContextConfig,
|
|
8
|
+
} from '../../contexts/session_context'
|
|
9
|
+
import { StorageAdapter } from '../../utils/native_adapters/storage_adapter'
|
|
10
|
+
import { OAuthToken, FailedResponse } from '../../types'
|
|
11
|
+
|
|
12
|
+
jest.useFakeTimers()
|
|
13
|
+
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
jest.useRealTimers()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
let mockStorage: StorageAdapter
|
|
19
|
+
let mockSecureStorage: StorageAdapter
|
|
20
|
+
let mockRefreshTokenFn: jest.Mock
|
|
21
|
+
let mockOnLogout: jest.Mock
|
|
22
|
+
|
|
23
|
+
const createMockStorage = (): StorageAdapter => {
|
|
24
|
+
const storage: Record<string, string> = {}
|
|
25
|
+
return new StorageAdapter({
|
|
26
|
+
getItem: async (key: string) => {
|
|
27
|
+
return storage[key] || null
|
|
28
|
+
},
|
|
29
|
+
setItem: async (key: string, value: string) => {
|
|
30
|
+
storage[key] = value
|
|
31
|
+
},
|
|
32
|
+
removeItem: async (key: string) => {
|
|
33
|
+
delete storage[key]
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Helper to wait for Suspense and async operations to resolve
|
|
39
|
+
const waitForQuery = async () => {
|
|
40
|
+
await act(async () => {
|
|
41
|
+
await Promise.resolve()
|
|
42
|
+
await Promise.resolve()
|
|
43
|
+
await Promise.resolve()
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const createWrapper = (config: SessionContextConfig) => {
|
|
48
|
+
const queryClient = new QueryClient({
|
|
49
|
+
defaultOptions: {
|
|
50
|
+
queries: {
|
|
51
|
+
retry: false,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return ({ children }: { children: React.ReactNode }) => (
|
|
57
|
+
<QueryClientProvider client={queryClient}>
|
|
58
|
+
<Suspense fallback={null}>
|
|
59
|
+
<SessionContextProvider queryClient={queryClient} config={config}>
|
|
60
|
+
{children}
|
|
61
|
+
</SessionContextProvider>
|
|
62
|
+
</Suspense>
|
|
63
|
+
</QueryClientProvider>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
mockStorage = createMockStorage()
|
|
69
|
+
mockSecureStorage = createMockStorage()
|
|
70
|
+
mockRefreshTokenFn = jest.fn()
|
|
71
|
+
mockOnLogout = jest.fn()
|
|
72
|
+
|
|
73
|
+
// Set default env
|
|
74
|
+
mockStorage.setItem('env', 'production')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
jest.restoreAllMocks()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const createConfig = (overrides?: Partial<SessionContextConfig>): SessionContextConfig => ({
|
|
82
|
+
storage: mockStorage,
|
|
83
|
+
secureStorage: mockSecureStorage,
|
|
84
|
+
refreshTokenFn: mockRefreshTokenFn,
|
|
85
|
+
onLogout: mockOnLogout,
|
|
86
|
+
...overrides,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const useSessionContext = () => {
|
|
90
|
+
return useContext(SessionContext)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('SessionContextProvider', () => {
|
|
94
|
+
describe('initialization', () => {
|
|
95
|
+
it('should initialize with default environment', async () => {
|
|
96
|
+
const config = createConfig()
|
|
97
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
98
|
+
wrapper: createWrapper(config),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await waitForQuery()
|
|
102
|
+
|
|
103
|
+
expect(result.current.env).toBe('production')
|
|
104
|
+
expect(result.current.token).toBeUndefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should use custom default environment', async () => {
|
|
108
|
+
await mockStorage.setItem('env', 'staging')
|
|
109
|
+
const config = createConfig({ defaultEnv: 'staging' })
|
|
110
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
111
|
+
wrapper: createWrapper(config),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await waitForQuery()
|
|
115
|
+
|
|
116
|
+
expect(result.current.env).toBe('staging')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('logout', () => {
|
|
121
|
+
it('should clear sessions and call onLogout', async () => {
|
|
122
|
+
const token: OAuthToken = {
|
|
123
|
+
access_token: 'test-token',
|
|
124
|
+
refresh_token: 'test-refresh',
|
|
125
|
+
expires_in: 3600,
|
|
126
|
+
token_type: 'Bearer',
|
|
127
|
+
scope: 'read write',
|
|
128
|
+
created_at: Date.now(),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Set up a session with a token
|
|
132
|
+
const sessionString = JSON.stringify({ env: 'production', token })
|
|
133
|
+
await mockSecureStorage.setItem(
|
|
134
|
+
'sessions-storage',
|
|
135
|
+
JSON.stringify({ production: sessionString })
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const config = createConfig()
|
|
139
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
140
|
+
wrapper: createWrapper(config),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
await waitForQuery()
|
|
144
|
+
|
|
145
|
+
expect(result.current.token).toBeDefined()
|
|
146
|
+
|
|
147
|
+
// Logout
|
|
148
|
+
await act(async () => {
|
|
149
|
+
await result.current.logout()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
await waitForQuery()
|
|
153
|
+
|
|
154
|
+
expect(mockOnLogout).toHaveBeenCalledTimes(1)
|
|
155
|
+
|
|
156
|
+
// Verify session was cleared
|
|
157
|
+
const sessionsAfterLogout = await mockSecureStorage.getItem('sessions-storage')
|
|
158
|
+
const parsedSessions = sessionsAfterLogout ? JSON.parse(sessionsAfterLogout) : {}
|
|
159
|
+
const productionSession = parsedSessions.production
|
|
160
|
+
? JSON.parse(parsedSessions.production)
|
|
161
|
+
: null
|
|
162
|
+
|
|
163
|
+
expect(productionSession.token).toBeUndefined()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should clear query clients on logout', async () => {
|
|
167
|
+
const config = createConfig()
|
|
168
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
169
|
+
wrapper: createWrapper(config),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
await waitForQuery()
|
|
173
|
+
|
|
174
|
+
// Note: We can't spy on the queryClient since it's created inside createWrapper
|
|
175
|
+
// But we can verify logout was called
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await result.current.logout()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(mockOnLogout).toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('token refresh', () => {
|
|
185
|
+
it('should refresh token when handleUnauthorizedResponse is called with expired token', async () => {
|
|
186
|
+
const token: OAuthToken = {
|
|
187
|
+
access_token: 'old-token',
|
|
188
|
+
refresh_token: 'refresh-token',
|
|
189
|
+
expires_in: 3600,
|
|
190
|
+
token_type: 'Bearer',
|
|
191
|
+
scope: 'read write',
|
|
192
|
+
created_at: Date.now(),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const newToken: OAuthToken = {
|
|
196
|
+
access_token: 'new-token',
|
|
197
|
+
refresh_token: 'new-refresh-token',
|
|
198
|
+
expires_in: 3600,
|
|
199
|
+
token_type: 'Bearer',
|
|
200
|
+
scope: 'read write',
|
|
201
|
+
created_at: Date.now(),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
mockRefreshTokenFn.mockResolvedValue(newToken)
|
|
205
|
+
|
|
206
|
+
const sessionString = JSON.stringify({ env: 'production', token })
|
|
207
|
+
await mockSecureStorage.setItem(
|
|
208
|
+
'sessions-storage',
|
|
209
|
+
JSON.stringify({ production: sessionString })
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const config = createConfig()
|
|
213
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
214
|
+
wrapper: createWrapper(config),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
await waitForQuery()
|
|
218
|
+
|
|
219
|
+
expect(result.current.token).toBeDefined()
|
|
220
|
+
|
|
221
|
+
const failedResponse: FailedResponse = {
|
|
222
|
+
status: 401,
|
|
223
|
+
statusText: 'Unauthorized',
|
|
224
|
+
errors: [{ detail: 'baboon', title: 'Token Expired', status: '401' }],
|
|
225
|
+
} as FailedResponse
|
|
226
|
+
|
|
227
|
+
await act(async () => {
|
|
228
|
+
result.current.handleUnauthorizedResponse(failedResponse)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
await waitForQuery()
|
|
232
|
+
|
|
233
|
+
expect(mockRefreshTokenFn).toHaveBeenCalledWith({
|
|
234
|
+
refresh_token: 'refresh-token',
|
|
235
|
+
env: 'production',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
await waitForQuery()
|
|
239
|
+
|
|
240
|
+
const sessions = await mockSecureStorage.getItem('sessions-storage')
|
|
241
|
+
expect(sessions).toContain('new-token')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('should handle multiple unauthorized responses', async () => {
|
|
245
|
+
const token: OAuthToken = {
|
|
246
|
+
access_token: 'old-token',
|
|
247
|
+
refresh_token: 'refresh-token',
|
|
248
|
+
expires_in: 3600,
|
|
249
|
+
token_type: 'Bearer',
|
|
250
|
+
scope: 'read write',
|
|
251
|
+
created_at: Date.now(),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const newToken: OAuthToken = {
|
|
255
|
+
access_token: 'new-token',
|
|
256
|
+
refresh_token: 'new-refresh-token',
|
|
257
|
+
expires_in: 3600,
|
|
258
|
+
token_type: 'Bearer',
|
|
259
|
+
scope: 'read write',
|
|
260
|
+
created_at: Date.now(),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
mockRefreshTokenFn.mockResolvedValue(newToken)
|
|
264
|
+
|
|
265
|
+
const sessionString = JSON.stringify({ env: 'production', token })
|
|
266
|
+
await mockSecureStorage.setItem(
|
|
267
|
+
'sessions-storage',
|
|
268
|
+
JSON.stringify({ production: sessionString })
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
const config = createConfig()
|
|
272
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
273
|
+
wrapper: createWrapper(config),
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await waitForQuery()
|
|
277
|
+
|
|
278
|
+
expect(result.current.token).toBeDefined()
|
|
279
|
+
|
|
280
|
+
const failedResponse: FailedResponse = {
|
|
281
|
+
status: 401,
|
|
282
|
+
statusText: 'Unauthorized',
|
|
283
|
+
errors: [{ detail: 'other-error', title: 'Unauthorized', status: '401' }],
|
|
284
|
+
} as FailedResponse
|
|
285
|
+
|
|
286
|
+
// Call handleUnauthorizedResponse - should trigger refresh
|
|
287
|
+
await act(async () => {
|
|
288
|
+
result.current.handleUnauthorizedResponse(failedResponse)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
await waitForQuery()
|
|
292
|
+
|
|
293
|
+
// Verify refresh was called
|
|
294
|
+
expect(mockRefreshTokenFn).toHaveBeenCalled()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('should logout on forced logout error (capuchin)', async () => {
|
|
298
|
+
const token: OAuthToken = {
|
|
299
|
+
access_token: 'old-token',
|
|
300
|
+
refresh_token: 'refresh-token',
|
|
301
|
+
expires_in: 3600,
|
|
302
|
+
token_type: 'Bearer',
|
|
303
|
+
scope: 'read write',
|
|
304
|
+
created_at: Date.now(),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const sessionString = JSON.stringify({ env: 'production', token })
|
|
308
|
+
await mockSecureStorage.setItem(
|
|
309
|
+
'sessions-storage',
|
|
310
|
+
JSON.stringify({ production: sessionString })
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
mockRefreshTokenFn.mockRejectedValue({
|
|
314
|
+
status: 401,
|
|
315
|
+
errors: [{ detail: 'capuchin', title: 'Forced Logout', status: '401' }],
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const config = createConfig()
|
|
319
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
320
|
+
wrapper: createWrapper(config),
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
await waitForQuery()
|
|
324
|
+
|
|
325
|
+
expect(result.current.token).toBeDefined()
|
|
326
|
+
|
|
327
|
+
// Trigger refresh that will fail with capuchin
|
|
328
|
+
const failedResponse: FailedResponse = {
|
|
329
|
+
status: 401,
|
|
330
|
+
statusText: 'Unauthorized',
|
|
331
|
+
errors: [{ detail: 'baboon', title: 'Token Expired', status: '401' }],
|
|
332
|
+
} as FailedResponse
|
|
333
|
+
|
|
334
|
+
await act(async () => {
|
|
335
|
+
result.current.handleUnauthorizedResponse(failedResponse)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
await waitForQuery()
|
|
339
|
+
|
|
340
|
+
expect(mockRefreshTokenFn).toHaveBeenCalled()
|
|
341
|
+
|
|
342
|
+
await waitForQuery()
|
|
343
|
+
|
|
344
|
+
expect(mockOnLogout).toHaveBeenCalled()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('should ignore TRASH_PANDA errors', async () => {
|
|
348
|
+
const config = createConfig()
|
|
349
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
350
|
+
wrapper: createWrapper(config),
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
await waitForQuery()
|
|
354
|
+
|
|
355
|
+
const failedResponse: FailedResponse = {
|
|
356
|
+
status: 401,
|
|
357
|
+
statusText: 'Unauthorized',
|
|
358
|
+
errors: [{ detail: 'TRASH_PANDA', title: 'Unauthorized', status: '401' }],
|
|
359
|
+
} as FailedResponse
|
|
360
|
+
|
|
361
|
+
await act(async () => {
|
|
362
|
+
result.current.handleUnauthorizedResponse(failedResponse)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
await waitForQuery()
|
|
366
|
+
|
|
367
|
+
expect(mockRefreshTokenFn).not.toHaveBeenCalled()
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe('setToken', () => {
|
|
372
|
+
it('should update token in storage', async () => {
|
|
373
|
+
const config = createConfig()
|
|
374
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
375
|
+
wrapper: createWrapper(config),
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
await waitForQuery()
|
|
379
|
+
|
|
380
|
+
const newToken: OAuthToken = {
|
|
381
|
+
access_token: 'new-token',
|
|
382
|
+
refresh_token: 'new-refresh',
|
|
383
|
+
expires_in: 3600,
|
|
384
|
+
token_type: 'Bearer',
|
|
385
|
+
scope: 'read write',
|
|
386
|
+
created_at: Date.now(),
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
await act(async () => {
|
|
390
|
+
await result.current.setToken(newToken)
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// Verify storage was updated correctly
|
|
394
|
+
const sessions = await mockSecureStorage.getItem('sessions-storage')
|
|
395
|
+
expect(sessions).toBeTruthy()
|
|
396
|
+
const parsedSessions = JSON.parse(sessions!)
|
|
397
|
+
const productionSession = JSON.parse(parsedSessions.production)
|
|
398
|
+
expect(productionSession.token.access_token).toBe('new-token')
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
describe('setEnv', () => {
|
|
403
|
+
it('should change environment and clear query clients', async () => {
|
|
404
|
+
const config = createConfig()
|
|
405
|
+
const { result } = renderHook(() => useSessionContext(), {
|
|
406
|
+
wrapper: createWrapper(config),
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
await waitForQuery()
|
|
410
|
+
|
|
411
|
+
await act(async () => {
|
|
412
|
+
result.current.setEnv('staging')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// Verify storage was updated correctly (it's JSON stringified)
|
|
416
|
+
const env = await mockStorage.getItem('env')
|
|
417
|
+
expect(env).toBe('"staging"')
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
})
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { Session, ENV } from '../utils'
|
|
2
|
+
import { FailedResponse, OAuthToken } from '../types'
|
|
3
|
+
import { chatQueryClient } from './api_provider'
|
|
4
|
+
import { QueryClient, useMutation } from '@tanstack/react-query'
|
|
5
|
+
import React, {
|
|
6
|
+
createContext,
|
|
7
|
+
PropsWithChildren,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react'
|
|
13
|
+
import { Alert } from 'react-native'
|
|
14
|
+
import { useStorage } from '../hooks/use_storage'
|
|
15
|
+
import { StorageAdapter } from '../utils/native_adapters/storage_adapter'
|
|
16
|
+
|
|
17
|
+
export type SessionContextValue = {
|
|
18
|
+
env: ENV
|
|
19
|
+
setEnv: (_env: ENV) => void
|
|
20
|
+
session: Session
|
|
21
|
+
token: OAuthToken | undefined
|
|
22
|
+
handleUnauthorizedResponse: (_response: FailedResponse) => void
|
|
23
|
+
logout: () => void
|
|
24
|
+
setToken: (_token: OAuthToken) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const SessionContext = createContext<SessionContextValue>({
|
|
28
|
+
env: 'production',
|
|
29
|
+
setEnv: () => null,
|
|
30
|
+
session: new Session(),
|
|
31
|
+
token: undefined,
|
|
32
|
+
handleUnauthorizedResponse: () => null,
|
|
33
|
+
logout: () => null,
|
|
34
|
+
setToken: () => null,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
type Sessions = Record<ENV, string>
|
|
38
|
+
const environments: ENV[] = ['production', 'staging', 'development']
|
|
39
|
+
|
|
40
|
+
const initialSessions = environments.reduce((acc, env) => {
|
|
41
|
+
const sessionProps = { env, token: undefined }
|
|
42
|
+
|
|
43
|
+
acc[env] = new Session(sessionProps).toString()
|
|
44
|
+
|
|
45
|
+
return acc
|
|
46
|
+
}, {} as Sessions)
|
|
47
|
+
|
|
48
|
+
export type SessionContextConfig = {
|
|
49
|
+
// Storage adapters
|
|
50
|
+
storage: StorageAdapter // For non-sensitive data (env preference)
|
|
51
|
+
secureStorage: StorageAdapter // For sensitive data (tokens/sessions)
|
|
52
|
+
|
|
53
|
+
// Functional callbacks
|
|
54
|
+
refreshTokenFn: (params: { refresh_token: string; env: ENV }) => Promise<OAuthToken>
|
|
55
|
+
onLogout: () => void | Promise<void>
|
|
56
|
+
|
|
57
|
+
// Optional configuration
|
|
58
|
+
storageKeys?: {
|
|
59
|
+
env?: string
|
|
60
|
+
sessions?: string
|
|
61
|
+
}
|
|
62
|
+
alertConfig?: {
|
|
63
|
+
title?: string
|
|
64
|
+
message?: string
|
|
65
|
+
retryText?: string
|
|
66
|
+
logoutText?: string
|
|
67
|
+
}
|
|
68
|
+
defaultEnv?: ENV
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function SessionContextProvider({
|
|
72
|
+
children,
|
|
73
|
+
queryClient,
|
|
74
|
+
config,
|
|
75
|
+
}: PropsWithChildren<{ queryClient: QueryClient; config: SessionContextConfig }>) {
|
|
76
|
+
const {
|
|
77
|
+
storage,
|
|
78
|
+
secureStorage,
|
|
79
|
+
refreshTokenFn,
|
|
80
|
+
onLogout,
|
|
81
|
+
storageKeys = {},
|
|
82
|
+
alertConfig = {},
|
|
83
|
+
defaultEnv = 'production',
|
|
84
|
+
} = config
|
|
85
|
+
|
|
86
|
+
const envKey = storageKeys.env || 'env'
|
|
87
|
+
const sessionsKey = storageKeys.sessions || 'sessions-storage'
|
|
88
|
+
|
|
89
|
+
const [env, setEnv] = useStorage<ENV>(storage, envKey, defaultEnv)
|
|
90
|
+
const [sessions, setSessions] = useStorage<Sessions>(secureStorage, sessionsKey, initialSessions)
|
|
91
|
+
const [alertShown, setAlertShown] = useState(false)
|
|
92
|
+
const session = useMemo(() => Session.hydrate<OAuthToken>(sessions[env]), [sessions, env])
|
|
93
|
+
const { token } = session
|
|
94
|
+
|
|
95
|
+
const {
|
|
96
|
+
mutate: refreshToken,
|
|
97
|
+
isPending: isRefreshingToken,
|
|
98
|
+
isError: isRefreshError,
|
|
99
|
+
} = useMutation({
|
|
100
|
+
mutationKey: ['refresh-token', token?.refresh_token],
|
|
101
|
+
mutationFn: () => {
|
|
102
|
+
if (!token?.refresh_token) {
|
|
103
|
+
return Promise.reject(new Error('Refresh token is required'))
|
|
104
|
+
}
|
|
105
|
+
return refreshTokenFn({ refresh_token: token.refresh_token, env: session.env }).then(
|
|
106
|
+
handleTokenUpdate
|
|
107
|
+
)
|
|
108
|
+
},
|
|
109
|
+
onError: (t: Partial<FailedResponse>) => {
|
|
110
|
+
handleRefreshFailed(t)
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const handleClearQueryClient = useCallback(() => {
|
|
115
|
+
chatQueryClient.clear()
|
|
116
|
+
queryClient.clear()
|
|
117
|
+
}, [queryClient])
|
|
118
|
+
|
|
119
|
+
const handleSetEnv = useCallback(
|
|
120
|
+
(_env: ENV) => {
|
|
121
|
+
setEnv(_env)
|
|
122
|
+
handleClearQueryClient()
|
|
123
|
+
},
|
|
124
|
+
[handleClearQueryClient, setEnv]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const handleLogout = useCallback(async () => {
|
|
128
|
+
handleClearQueryClient()
|
|
129
|
+
|
|
130
|
+
return setSessions({
|
|
131
|
+
...sessions,
|
|
132
|
+
[env]: new Session({ env }).toString(),
|
|
133
|
+
}).then(() => onLogout())
|
|
134
|
+
}, [env, sessions, setSessions, handleClearQueryClient, onLogout])
|
|
135
|
+
|
|
136
|
+
const handleTokenUpdate = useCallback(
|
|
137
|
+
(t: OAuthToken) => {
|
|
138
|
+
if (!t || !session) return t
|
|
139
|
+
|
|
140
|
+
session.token = t
|
|
141
|
+
|
|
142
|
+
return setSessions({
|
|
143
|
+
...sessions,
|
|
144
|
+
[env]: session?.toString(),
|
|
145
|
+
}).then(() => t)
|
|
146
|
+
},
|
|
147
|
+
[env, session, sessions, setSessions]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const handleRefreshFailed = useCallback(
|
|
151
|
+
(t: Partial<FailedResponse>) => {
|
|
152
|
+
if (t.status !== 401) return
|
|
153
|
+
// If we didn't fail because of an unauthorized response ( 401 ),
|
|
154
|
+
// it could be because we were unable to store the token.
|
|
155
|
+
// Do not log out.
|
|
156
|
+
|
|
157
|
+
const { errors = [] } = t
|
|
158
|
+
// The code for a forced logout is "capuchin"
|
|
159
|
+
const isForcedLogout = errors.some((e: { detail?: string }) =>
|
|
160
|
+
/capuchin/i.test(e.detail || '')
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if (isForcedLogout) {
|
|
164
|
+
handleLogout()
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
[handleLogout]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const handleUnauthorizedResponse = useCallback(
|
|
171
|
+
async (response: FailedResponse) => {
|
|
172
|
+
const { errors = [] } = response
|
|
173
|
+
|
|
174
|
+
const isUnauthorized = errors.some((e: { detail?: string }) =>
|
|
175
|
+
/TRASH_PANDA/i.test(e.detail || '')
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if (isUnauthorized) return
|
|
179
|
+
|
|
180
|
+
const isForcedLogout = errors.some((e: { detail?: string }) =>
|
|
181
|
+
/capuchin/i.test(e.detail || '')
|
|
182
|
+
)
|
|
183
|
+
const isExpiredToken = errors.some((e: { detail?: string }) => /baboon/i.test(e.detail || ''))
|
|
184
|
+
|
|
185
|
+
if (!isRefreshingToken || isExpiredToken || isForcedLogout) {
|
|
186
|
+
refreshToken()
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
[refreshToken, isRefreshingToken]
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const sessionContextValue: SessionContextValue = {
|
|
193
|
+
env: session.env,
|
|
194
|
+
token: session.token,
|
|
195
|
+
session,
|
|
196
|
+
logout: handleLogout,
|
|
197
|
+
setToken: handleTokenUpdate,
|
|
198
|
+
handleUnauthorizedResponse,
|
|
199
|
+
setEnv: handleSetEnv,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const alertTitle = alertConfig.title || 'Oops'
|
|
203
|
+
const alertMessage = alertConfig.message || 'Something went wrong with your login!'
|
|
204
|
+
const retryText = alertConfig.retryText || 'Keep trying'
|
|
205
|
+
const logoutText = alertConfig.logoutText || 'Logout'
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (isRefreshError && !alertShown && token?.refresh_token) {
|
|
209
|
+
setAlertShown(true)
|
|
210
|
+
Alert.alert(alertTitle, alertMessage, [
|
|
211
|
+
{
|
|
212
|
+
text: retryText,
|
|
213
|
+
onPress: () => {
|
|
214
|
+
refreshToken()
|
|
215
|
+
setAlertShown(false)
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{ text: logoutText, onPress: () => handleLogout() },
|
|
219
|
+
])
|
|
220
|
+
}
|
|
221
|
+
}, [
|
|
222
|
+
isRefreshError,
|
|
223
|
+
handleLogout,
|
|
224
|
+
alertShown,
|
|
225
|
+
refreshToken,
|
|
226
|
+
token?.refresh_token,
|
|
227
|
+
alertTitle,
|
|
228
|
+
alertMessage,
|
|
229
|
+
retryText,
|
|
230
|
+
logoutText,
|
|
231
|
+
])
|
|
232
|
+
|
|
233
|
+
return <SessionContext.Provider value={sessionContextValue}>{children}</SessionContext.Provider>
|
|
234
|
+
}
|