@terreno/rtk 0.0.9

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.
@@ -0,0 +1,274 @@
1
+ // Note: we will open source this when we get a chance, so there should be no imports from private
2
+ // files.
3
+ import AsyncStorage from "@react-native-async-storage/async-storage";
4
+ import {createListenerMiddleware, createSlice, type PayloadAction} from "@reduxjs/toolkit";
5
+ import type {Api, BaseQueryFn, EndpointBuilder} from "@reduxjs/toolkit/query/react";
6
+ import * as SecureStore from "expo-secure-store";
7
+ import {useSelector} from "react-redux";
8
+
9
+ import {LOGOUT_ACTION_TYPE, type RootState} from "./constants";
10
+ import {IsWeb} from "./platform";
11
+
12
+ type AuthState = {
13
+ userId: string | null;
14
+ error: string | null;
15
+ lastTokenRefreshTimestamp: number | null;
16
+ };
17
+
18
+ export interface UserResponse {
19
+ data: {
20
+ userId: string;
21
+ token: string;
22
+ refreshToken: string;
23
+ };
24
+ }
25
+
26
+ export interface EmailLoginRequest {
27
+ email: string;
28
+ password: string;
29
+ }
30
+
31
+ export interface EmailSignupRequest {
32
+ email: string;
33
+ password: string;
34
+ // Extra data
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ export interface ResetPasswordRequest {
39
+ email: string;
40
+ password: string;
41
+ // Extra data
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ export interface GoogleLoginRequest {
46
+ idToken: string;
47
+ }
48
+
49
+ // Define a service using a base URL and expected endpoints
50
+ export function generateProfileEndpoints(
51
+ // biome-ignore lint/suspicious/noExplicitAny: Generic
52
+ builder: EndpointBuilder<BaseQueryFn<unknown, unknown, unknown>, any, string>,
53
+ path: string
54
+ ) {
55
+ return {
56
+ // This is a slightly different version of emailSignUp for creating another user using the
57
+ // auth/signup endpoint. This is useful for things like creating a user from an admin account.
58
+ // Unlike emailSignUp, this doesn't log in as the user.
59
+ createEmailUser: builder.mutation<UserResponse, EmailSignupRequest>({
60
+ invalidatesTags: [path, "conversations"],
61
+ query: ({email, password, ...body}) => ({
62
+ body: {email, password, ...body},
63
+ method: "POST",
64
+ url: `auth/signup`,
65
+ }),
66
+ }),
67
+ emailLogin: builder.mutation<UserResponse, EmailLoginRequest>({
68
+ extraOptions: {maxRetries: 0},
69
+ invalidatesTags: [path],
70
+ query: ({email, password}) => ({
71
+ body: {email, password},
72
+ method: "POST",
73
+ url: "auth/login",
74
+ }),
75
+ }),
76
+ emailSignUp: builder.mutation<UserResponse, EmailSignupRequest>({
77
+ invalidatesTags: [path],
78
+ query: ({email, password, ...body}) => ({
79
+ body: {email, password, ...body},
80
+ method: "POST",
81
+ url: `auth/signup`,
82
+ }),
83
+ }),
84
+ googleLogin: builder.mutation<UserResponse, GoogleLoginRequest>({
85
+ extraOptions: {maxRetries: 0},
86
+ invalidatesTags: [path],
87
+ query: (body) => ({
88
+ body,
89
+ method: "POST",
90
+ url: `/auth/google`,
91
+ }),
92
+ }),
93
+ resetPassword: builder.mutation<UserResponse, ResetPasswordRequest>({
94
+ extraOptions: {maxRetries: 0},
95
+ query: ({_id, password, oldPassword, newPassword, ...body}) => ({
96
+ body: {_id, newPassword, oldPassword, password, ...body},
97
+ method: "POST",
98
+ url: `/resetPassword`,
99
+ }),
100
+ }),
101
+ };
102
+ }
103
+
104
+ // biome-ignore lint/suspicious/noExplicitAny: Generic
105
+ export const generateAuthSlice = (api: Api<any, any, any, any, any>) => {
106
+ const authSlice = createSlice({
107
+ extraReducers: (builder) => {
108
+ builder.addMatcher(api.endpoints.emailLogin.matchFulfilled, () => {
109
+ console.debug("Login success");
110
+ });
111
+ builder.addMatcher(
112
+ api.endpoints.emailLogin.matchRejected,
113
+ // biome-ignore lint/suspicious/noExplicitAny: Generic
114
+ (state, action: PayloadAction<{data: any}>) => {
115
+ state.error = action.payload?.data?.message;
116
+ console.debug("Login rejected", action.payload?.data?.message);
117
+ }
118
+ );
119
+ builder.addMatcher(api.endpoints.emailLogin.matchPending, (state) => {
120
+ state.error = null;
121
+ console.debug("Login pending");
122
+ });
123
+ builder.addMatcher(api.endpoints.emailSignUp.matchFulfilled, () => {
124
+ console.debug("Signup success");
125
+ });
126
+ builder.addMatcher(
127
+ api.endpoints.emailSignUp.matchRejected,
128
+ // biome-ignore lint/suspicious/noExplicitAny: Generic
129
+ (state, action: PayloadAction<{data: any}>) => {
130
+ state.error = action.payload?.data?.message;
131
+ console.debug("Signup rejected", action.payload);
132
+ }
133
+ );
134
+ builder.addMatcher(api.endpoints.emailSignUp.matchPending, (state) => {
135
+ state.error = null;
136
+ console.debug("Signup pending");
137
+ });
138
+ },
139
+ initialState: {error: null, lastTokenRefreshTimestamp: null, userId: null} as AuthState,
140
+ name: "auth",
141
+ reducers: {
142
+ logout: (state) => {
143
+ state.userId = null;
144
+ state.lastTokenRefreshTimestamp = null;
145
+ },
146
+ setUserId: (state, {payload: {userId}}: PayloadAction<{userId: string}>) => {
147
+ state.userId = userId;
148
+ },
149
+ tokenRefreshedSuccess: (state) => {
150
+ state.lastTokenRefreshTimestamp = Date.now();
151
+ },
152
+ },
153
+ });
154
+ // Since we need to do async actions to store tokens in expo-secure-store,
155
+ // we need to use a listener middleware.
156
+ const loginListenerMiddleware = createListenerMiddleware();
157
+ loginListenerMiddleware.startListening({
158
+ // biome-ignore lint/suspicious/noExplicitAny: Generic
159
+ effect: async (action: any, listenerApi) => {
160
+ if (
161
+ action.payload?.token &&
162
+ (action.meta?.arg?.endpointName === "emailLogin" ||
163
+ action.meta?.arg?.endpointName === "emailSignUp" ||
164
+ action.meta?.arg?.endpointName === "googleLogin")
165
+ ) {
166
+ if (!IsWeb) {
167
+ if (!action.payload.token) {
168
+ console.error("No token found in app login response.", action.payload);
169
+ return;
170
+ }
171
+ try {
172
+ await SecureStore.setItemAsync("AUTH_TOKEN", action.payload.token);
173
+ await SecureStore.setItemAsync("REFRESH_TOKEN", action.payload.refreshToken);
174
+ console.debug("Saved auth token to secure storage.");
175
+ } catch (error) {
176
+ console.error(`Error setting auth token: ${error}`);
177
+ throw error;
178
+ }
179
+ } else {
180
+ if (!action.payload.token) {
181
+ console.error("No token found in web login response.", action.payload);
182
+ return;
183
+ }
184
+ // On web, we don't have secure storage, and cookie support is not in Expo yet,
185
+ // so this is what we're left with. This can be vulnerable to XSS attacks.
186
+ try {
187
+ // Check if we're in a browser environment (not SSR)
188
+ if (typeof window !== "undefined") {
189
+ await AsyncStorage.setItem("AUTH_TOKEN", action.payload.token);
190
+ await AsyncStorage.setItem("REFRESH_TOKEN", action.payload.refreshToken);
191
+ console.debug("Saved auth token to async storage.");
192
+ } else {
193
+ console.warn("Cannot store auth token: window is not defined (SSR context)");
194
+ }
195
+ } catch (error) {
196
+ console.error(`Error setting auth token: ${error}`);
197
+ throw error;
198
+ }
199
+ }
200
+ listenerApi.dispatch(authSlice.actions.setUserId({userId: action.payload.userId}));
201
+ }
202
+ },
203
+ type: "terreno-rtk/executeMutation/fulfilled",
204
+ });
205
+
206
+ // const clearLocalStorage = async (): Promise<void> => {
207
+ // try {
208
+ // const keys = await AsyncStorage.getAllKeys();
209
+ // const keysToRemove = keys.filter((key) => key.includes("formInstance"));
210
+ // if (keysToRemove.length > 0) {
211
+ // await AsyncStorage.multiRemove(keysToRemove);
212
+ // console.debug("Cleared local storage.");
213
+ // }
214
+ // } catch (error) {
215
+ // console.error("Error:", error);
216
+ // }
217
+ // };
218
+ // Since we need to do async actions to store tokens in expo-secure-store,
219
+ // we need to use a listener middleware.
220
+ const logoutListenerMiddleware = createListenerMiddleware();
221
+ logoutListenerMiddleware.startListening({
222
+ effect: async () => {
223
+ // TODO: We should only clear local storage when we're logging out, not disconnected.
224
+ // await clearLocalStorage();
225
+ if (!IsWeb) {
226
+ await SecureStore.deleteItemAsync("AUTH_TOKEN");
227
+ await SecureStore.deleteItemAsync("REFRESH_TOKEN");
228
+ } else {
229
+ // Check if we're in a browser environment (not SSR)
230
+ if (typeof window !== "undefined") {
231
+ await AsyncStorage.removeItem("AUTH_TOKEN");
232
+ await AsyncStorage.removeItem("REFRESH_TOKEN");
233
+ }
234
+ }
235
+ console.debug("Cleared auth token from secure storage as part of logout.");
236
+ },
237
+ type: LOGOUT_ACTION_TYPE,
238
+ });
239
+ return {
240
+ authReducer: authSlice.reducer,
241
+ authSlice,
242
+ logout: authSlice.actions.logout,
243
+ middleware: [logoutListenerMiddleware.middleware, loginListenerMiddleware.middleware],
244
+ setUserId: authSlice.actions.setUserId,
245
+ tokenRefreshedSuccess: authSlice.actions.tokenRefreshedSuccess,
246
+ };
247
+ };
248
+
249
+ export const selectCurrentUserId = (state: RootState): string | undefined => state.auth?.userId;
250
+ export const selectLastTokenRefreshTimestamp = (state: RootState): number | null =>
251
+ state.auth?.lastTokenRefreshTimestamp;
252
+
253
+ export const useSelectCurrentUserId = (): string | undefined => {
254
+ return useSelector((state: RootState): string | undefined => {
255
+ return state.auth?.userId;
256
+ });
257
+ };
258
+
259
+ export async function getAuthToken(): Promise<string | null> {
260
+ let token: string | null;
261
+
262
+ if (!IsWeb) {
263
+ token = await SecureStore.getItemAsync("AUTH_TOKEN");
264
+ } else {
265
+ // Check if we're in a browser environment (not SSR)
266
+ if (typeof window !== "undefined") {
267
+ token = await AsyncStorage.getItem("AUTH_TOKEN");
268
+ } else {
269
+ console.warn("Cannot get auth token: window is not defined (SSR context)");
270
+ token = null;
271
+ }
272
+ }
273
+ return token;
274
+ }
@@ -0,0 +1,105 @@
1
+ import Constants from "expo-constants";
2
+
3
+ // biome-ignore lint/suspicious/noExplicitAny: RootState is hard to type without becoming circular.
4
+ export type RootState = any;
5
+ export const LOGOUT_ACTION_TYPE = "auth/logout";
6
+ export const TOKEN_REFRESHED_SUCCESS = "auth/tokenRefreshedSuccess";
7
+
8
+ export const AUTH_DEBUG = Constants.expoConfig?.extra?.AUTH_DEBUG === "true";
9
+ if (AUTH_DEBUG) {
10
+ console.debug("AUTH_DEBUG is enabled");
11
+ }
12
+
13
+ export const logAuth = (...args: string[]): void => {
14
+ if (AUTH_DEBUG) {
15
+ console.debug(...args);
16
+ }
17
+ };
18
+
19
+ // Handy debug logging socket events, but not enabled by default.
20
+ // Can also be enabled by user feature flag.
21
+ const WEBSOCKETS_DEBUG = Constants.expoConfig?.extra?.WEBSOCKETS_DEBUG === "true";
22
+ if (WEBSOCKETS_DEBUG) {
23
+ console.debug("WEBSOCKETS_DEBUG is enabled");
24
+ }
25
+
26
+ // Handy debug logging for websockets, enabled by user.featureFlags.debugWebsockets.enabled or passing in true.
27
+ export const logSocket = (
28
+ user?: {featureFlags?: {debugWebsockets?: {enabled?: boolean}}} | boolean,
29
+ ...args: string[]
30
+ ): void => {
31
+ if (
32
+ typeof user === "boolean"
33
+ ? user
34
+ : user?.featureFlags?.debugWebsockets?.enabled || WEBSOCKETS_DEBUG
35
+ ) {
36
+ console.debug(`[websocket]`, ...args);
37
+ }
38
+ };
39
+
40
+ // When we use "expo publish", we want to point the API at the prod API. In the future,
41
+ // we'll want to point at the staging API, and probably have a development release channel.
42
+ if (Constants.expoGoConfig?.debuggerHost?.includes("exp.direct")) {
43
+ console.error(
44
+ "Expo Tunnel is not currently supported for connecting to the API, please use LAN or Local mode."
45
+ );
46
+ }
47
+
48
+ export let baseUrl: string;
49
+ export let baseWebsocketsUrl: string;
50
+ export let baseTasksUrl: string;
51
+
52
+ if (Constants.expoConfig?.extra?.BASE_URL) {
53
+ // For prod/staging
54
+ baseUrl = Constants.expoConfig?.extra?.BASE_URL;
55
+ baseWebsocketsUrl = `${baseUrl.replace("api.", "ws.")}/`;
56
+ baseTasksUrl = `${baseUrl.replace("api.", "tasks.")}/tasks`;
57
+
58
+ console.debug(
59
+ `Base URL set to apiUrl ${baseUrl} for env ${
60
+ Constants.expoConfig?.extra?.APP_ENV ?? "unknown"
61
+ }, websocket to ${baseWebsocketsUrl}, tasks to ${baseTasksUrl}`
62
+ );
63
+ } else if (process.env.EXPO_PUBLIC_API_URL) {
64
+ // For dev web
65
+ baseUrl = process.env.EXPO_PUBLIC_API_URL;
66
+ baseWebsocketsUrl = `${baseUrl.replace("api.", "ws.")}/`;
67
+ baseTasksUrl = `${baseUrl.replace("api.", "tasks.")}/tasks`;
68
+
69
+ console.debug(
70
+ `Base URL set to apiUrl ${baseUrl} for env ${
71
+ Constants.expoConfig?.extra?.APP_ENV ?? "unknown"
72
+ }, websocket to ${baseWebsocketsUrl}, tasks to ${baseTasksUrl}`
73
+ );
74
+ } else if (Constants.expoConfig?.hostUri) {
75
+ // For dev simulator/device
76
+ baseUrl = `http://${Constants.expoConfig?.hostUri?.split(`:`).shift()?.concat(":3000")}`;
77
+ baseWebsocketsUrl = `ws://${Constants.expoConfig?.hostUri?.split(`:`).shift()?.concat(":3000")}/`;
78
+ baseTasksUrl = `http://${Constants.expoConfig?.hostUri?.split(`:`).shift()?.concat(":3000")}/tasks`;
79
+ console.debug(
80
+ `Base URL set to hostUri ${baseUrl}, websocket to ${baseWebsocketsUrl}`,
81
+ Constants.expoConfig?.hostUri
82
+ );
83
+ } else if (Constants.experienceUrl) {
84
+ // For dev web
85
+ baseUrl = `http:${Constants.experienceUrl?.split(`:`)[1]?.concat(":3000")}`;
86
+ baseWebsocketsUrl = `ws:${Constants.experienceUrl?.split(`:`)[1]?.concat(":3000")}/`;
87
+ baseTasksUrl = `http:${Constants.experienceUrl?.split(`:`)[1]?.concat(":3000")}/tasks`;
88
+ console.debug(
89
+ `Base URL set to experienceUrl ${baseUrl}, websocket to ${baseWebsocketsUrl}`,
90
+ Constants.expoConfig?.hostUri
91
+ );
92
+ } else if (
93
+ !Constants.expoConfig?.extra?.BASE_URL &&
94
+ !Constants.expoConfig?.hostUri &&
95
+ !Constants.experienceUrl
96
+ ) {
97
+ // For dev web, which doesn't have experienceUrl for some reason?
98
+ baseUrl = `http://localhost:3000`;
99
+ baseWebsocketsUrl = `ws://localhost:3000/`;
100
+ baseTasksUrl = `http://localhost:3000/tasks`;
101
+ console.debug(`Base URL set to localhost ${baseUrl}, websocket to ${baseWebsocketsUrl}`);
102
+ } else {
103
+ console.error("No base URL found", Constants.expoConfig, Constants.experienceUrl);
104
+ throw new Error("No base URL found");
105
+ }