@rebasepro/auth 0.0.1-canary.f81da60 → 0.1.2

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/dist/types.d.ts CHANGED
@@ -4,8 +4,14 @@ import { AuthController, Role, User } from "@rebasepro/types";
4
4
  * with additional methods for email/password and Google login
5
5
  */
6
6
  export type RebaseAuthController = AuthController & {
7
- /** Login with Google token (ID token from One Tap, or access token from popup) */
8
- googleLogin: (token: string, tokenType?: "idToken" | "accessToken") => Promise<void>;
7
+ /** Login with Google accepts legacy (token, tokenType) or code-flow payload */
8
+ googleLogin: {
9
+ (token: string, tokenType?: "idToken" | "accessToken"): Promise<void>;
10
+ (payload: {
11
+ code: string;
12
+ redirectUri: string;
13
+ }): Promise<void>;
14
+ };
9
15
  /** Generic OAuth login — works with any provider. Posts payload to /auth/{providerId}. */
10
16
  oauthLogin: (providerId: string, payload: Record<string, unknown>) => Promise<void>;
11
17
  /** Login with email and password */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/auth",
3
3
  "type": "module",
4
- "version": "0.0.1-canary.f81da60",
4
+ "version": "0.1.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -11,9 +11,9 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "source": "src/index.ts",
13
13
  "dependencies": {
14
- "@rebasepro/core": "0.0.1-canary.f81da60",
15
- "@rebasepro/ui": "0.0.1-canary.f81da60",
16
- "@rebasepro/types": "0.0.1-canary.f81da60"
14
+ "@rebasepro/types": "0.1.2",
15
+ "@rebasepro/ui": "0.1.2",
16
+ "@rebasepro/core": "0.1.2"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": ">=19.0.0",
package/src/api.ts CHANGED
@@ -103,13 +103,33 @@ password })
103
103
  }
104
104
 
105
105
  /**
106
- * Login with Google token (ID token or access token)
106
+ * Google login payload one of the three supported flows.
107
107
  */
108
- export async function googleLogin(token: string, tokenType: "idToken" | "accessToken" = "idToken"): Promise<AuthResponse> {
108
+ export type GoogleLoginPayload =
109
+ | { idToken: string }
110
+ | { accessToken: string }
111
+ | { code: string; redirectUri: string };
112
+
113
+ /**
114
+ * Login with Google.
115
+ *
116
+ * Overload 1 (legacy): `googleLogin("token", "idToken" | "accessToken")`
117
+ * Overload 2 (code flow): `googleLogin({ code, redirectUri })`
118
+ */
119
+ export async function googleLogin(payload: GoogleLoginPayload): Promise<AuthResponse>;
120
+ export async function googleLogin(token: string, tokenType?: "idToken" | "accessToken"): Promise<AuthResponse>;
121
+ export async function googleLogin(
122
+ tokenOrPayload: string | GoogleLoginPayload,
123
+ tokenType: "idToken" | "accessToken" = "idToken"
124
+ ): Promise<AuthResponse> {
125
+ const body = typeof tokenOrPayload === "string"
126
+ ? { [tokenType]: tokenOrPayload }
127
+ : tokenOrPayload;
128
+
109
129
  const response = await fetchWithHandling(`${baseApiUrl}/api/auth/google`, {
110
130
  method: "POST",
111
131
  headers: { "Content-Type": "application/json" },
112
- body: JSON.stringify({ [tokenType]: token })
132
+ body: JSON.stringify(body)
113
133
  });
114
134
 
115
135
  return handleResponse<AuthResponse>(response);
@@ -349,17 +369,37 @@ export interface AuthConfigResponse {
349
369
  enabledProviders?: string[];
350
370
  }
351
371
 
372
+ /**
373
+ * Inflight promise for `fetchAuthConfig` — ensures concurrent callers
374
+ * (e.g. React StrictMode double-mount) reuse the same network request.
375
+ */
376
+ let authConfigInflight: Promise<AuthConfigResponse> | null = null;
377
+
352
378
  /**
353
379
  * Fetch auth configuration / status from the backend
354
- * This is an unauthenticated endpoint used to detect bootstrap mode
380
+ * This is an unauthenticated endpoint used to detect bootstrap mode.
381
+ *
382
+ * Concurrent calls are deduplicated: only one network request is made
383
+ * and all callers share the same promise.
355
384
  */
356
385
  export async function fetchAuthConfig(): Promise<AuthConfigResponse> {
357
- const response = await fetchWithHandling(`${baseApiUrl}/api/auth/config`, {
358
- method: "GET",
359
- headers: { "Content-Type": "application/json" }
360
- });
386
+ if (authConfigInflight) {
387
+ return authConfigInflight;
388
+ }
361
389
 
362
- return handleResponse<AuthConfigResponse>(response);
390
+ authConfigInflight = (async () => {
391
+ const response = await fetchWithHandling(`${baseApiUrl}/api/auth/config`, {
392
+ method: "GET",
393
+ headers: { "Content-Type": "application/json" }
394
+ });
395
+ return handleResponse<AuthConfigResponse>(response);
396
+ })();
397
+
398
+ try {
399
+ return await authConfigInflight;
400
+ } finally {
401
+ authConfigInflight = null;
402
+ }
363
403
  }
364
404
 
365
405
  export { AuthApiError };
@@ -26,14 +26,12 @@ export function createUserManagementAdminViews({ userManagement, apiUrl, getAuth
26
26
  {
27
27
  slug: "dev/users",
28
28
  name: "CMS Users",
29
- group: "Admin",
30
29
  icon: "face",
31
30
  view: <UsersView userManagement={userManagement} apiUrl={apiUrl} getAuthToken={getAuthToken}/>
32
31
  },
33
32
  {
34
33
  slug: "dev/roles",
35
34
  name: "Roles",
36
- group: "Admin",
37
35
  icon: "gpp_good",
38
36
  view: <RolesView userManagement={userManagement} collections={collections}/>
39
37
  }
@@ -153,7 +151,7 @@ message: error instanceof Error ? error.message : "Error deleting user" });
153
151
  return (
154
152
  <Container className="w-full flex flex-col py-4 gap-4" maxWidth={"6xl"}>
155
153
  {/* Bootstrap warning when no admins */}
156
- {!hasAdmin && !usersError && loggedInUser && (
154
+ {!hasAdmin && loggedInUser && (
157
155
  <div className="bg-yellow-100 dark:bg-yellow-900 border border-yellow-400 dark:border-yellow-700 rounded p-4 flex items-center justify-between">
158
156
  <div>
159
157
  <Typography variant="label" className="text-yellow-800 dark:text-yellow-200">
@@ -213,15 +213,21 @@ export function RebaseLoginView({
213
213
  />
214
214
  )}
215
215
  {showRegistration && (
216
- <Button
217
- className="w-full"
218
- variant="filled"
219
- color="primary"
220
- size="large"
221
- onClick={() => switchMode("register")}
222
- >
223
- Create an account
224
- </Button>
216
+ <div className="mt-2 text-center">
217
+ <Typography variant="body2" color="secondary">
218
+ Don&apos;t have an account?{" "}
219
+ <button
220
+ type="button"
221
+ className={cls(
222
+ "font-semibold hover:underline cursor-pointer",
223
+ "text-primary-600 dark:text-primary-400"
224
+ )}
225
+ onClick={() => switchMode("register")}
226
+ >
227
+ Create one
228
+ </button>
229
+ </Typography>
230
+ </div>
225
231
  )}
226
232
  </div>
227
233
  )}
@@ -303,6 +309,24 @@ const GoogleIcon = () => (
303
309
  </svg>
304
310
  );
305
311
 
312
+ /** Google Identity Services SDK — injected by the GIS <script> tag. */
313
+ declare global {
314
+ interface Window {
315
+ google?: {
316
+ accounts: {
317
+ oauth2: {
318
+ initCodeClient(config: {
319
+ client_id: string;
320
+ scope: string;
321
+ ux_mode: "popup" | "redirect";
322
+ callback: (response: { code?: string; error?: string }) => void;
323
+ }): { requestCode(): void };
324
+ };
325
+ };
326
+ };
327
+ }
328
+ }
329
+
306
330
  function GoogleLoginButton({
307
331
  disabled,
308
332
  googleClientId,
@@ -312,22 +336,28 @@ function GoogleLoginButton({
312
336
  googleClientId: string,
313
337
  authController: RebaseAuthController
314
338
  }) {
315
- const tokenClientRef = useRef<any>(null);
339
+ const codeClientRef = useRef<{ requestCode(): void } | null>(null);
316
340
 
317
341
  useEffect(() => {
318
- const google = (window as any).google;
319
- if (!google || tokenClientRef.current) return;
342
+ const google = window.google;
343
+ if (!google || codeClientRef.current) return;
320
344
 
321
- tokenClientRef.current = google.accounts.oauth2.initTokenClient({
345
+ codeClientRef.current = google.accounts.oauth2.initCodeClient({
322
346
  client_id: googleClientId,
323
347
  scope: "openid email profile",
324
- callback: async (response: { access_token?: string; error?: string }) => {
325
- if (response.error || !response.access_token) {
348
+ ux_mode: "popup",
349
+ callback: async (response: { code?: string; error?: string }) => {
350
+ if (response.error || !response.code) {
326
351
  console.error("Google login error:", response.error);
327
352
  return;
328
353
  }
329
354
  try {
330
- await authController.googleLogin(response.access_token, "accessToken");
355
+ // Send the authorization code to the backend.
356
+ // redirectUri "postmessage" is required when using popup ux_mode.
357
+ await authController.googleLogin({
358
+ code: response.code,
359
+ redirectUri: "postmessage"
360
+ });
331
361
  } catch (err: unknown) {
332
362
  console.error("Google login error:", err);
333
363
  }
@@ -336,11 +366,11 @@ function GoogleLoginButton({
336
366
  }, [googleClientId, authController]);
337
367
 
338
368
  const handleClick = () => {
339
- if (!tokenClientRef.current) {
369
+ if (!codeClientRef.current) {
340
370
  console.error("Google Sign-In not loaded");
341
371
  return;
342
372
  }
343
- tokenClientRef.current.requestAccessToken();
373
+ codeClientRef.current.requestCode();
344
374
  };
345
375
 
346
376
  return (
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { Role, User } from "@rebasepro/types";
3
3
 
4
4
  /**
@@ -135,6 +135,14 @@ export function useBackendUserManagement(config: BackendUserManagementConfig): U
135
135
  const [usersError, setUsersError] = useState<Error | undefined>();
136
136
  const [rolesError, setRolesError] = useState<Error | undefined>();
137
137
 
138
+ // Tracks the UID for which roles+users were last successfully loaded.
139
+ // Prevents redundant refetches on React StrictMode double-mounts.
140
+ const lastLoadedUidRef = useRef<string | null>(null);
141
+
142
+ // Ref to hold the latest apiRequest so the initial-load effect doesn't
143
+ // re-trigger every time the callback identity changes.
144
+ const apiRequestRef = useRef<typeof apiRequest | null>(null);
145
+
138
146
  /**
139
147
  * Make authenticated API request
140
148
  */
@@ -226,6 +234,9 @@ export function useBackendUserManagement(config: BackendUserManagementConfig): U
226
234
  throw lastError;
227
235
  }, [apiUrl, getAuthToken]);
228
236
 
237
+ // Keep the ref in sync after every render.
238
+ apiRequestRef.current = apiRequest;
239
+
229
240
  /**
230
241
  * Load roles from API
231
242
  */
@@ -260,7 +271,11 @@ export function useBackendUserManagement(config: BackendUserManagementConfig): U
260
271
 
261
272
  /**
262
273
  * Initial data load - only when user is logged in
263
- * Load roles first, then admin users
274
+ * Load roles first, then admin users.
275
+ *
276
+ * Dependencies are intentionally limited to `currentUser?.uid` so the
277
+ * effect does NOT re-run when callback identities change. The latest
278
+ * `apiRequest` is read via `apiRequestRef`.
264
279
  */
265
280
  useEffect(() => {
266
281
  // Don't load if no user is logged in
@@ -269,27 +284,56 @@ export function useBackendUserManagement(config: BackendUserManagementConfig): U
269
284
  return;
270
285
  }
271
286
 
287
+ // Skip refetch if we already loaded data for this same UID
288
+ // (e.g. React StrictMode unmounts and re-mounts with the same user).
289
+ if (lastLoadedUidRef.current === currentUser.uid) {
290
+ setLoading(false);
291
+ return;
292
+ }
293
+
272
294
  const abortController = new AbortController();
273
295
 
274
296
  const load = async () => {
275
297
  setLoading(true);
298
+ const request = apiRequestRef.current!;
299
+
276
300
  // Load roles first
277
301
  try {
278
- const data = await apiRequest("/roles", "GET", undefined, 6, abortController.signal);
302
+ const data = await request("/roles", "GET", undefined, 6, abortController.signal);
279
303
  setRoles(data.roles.map(convertRole));
280
304
  setRolesError(undefined);
281
305
  } catch (error: unknown) {
282
- if (error instanceof Error && error.name !== "AbortError") {
283
- console.error("Failed to load roles:", error);
284
- setRolesError(error);
306
+ if (error instanceof Error && error.name === "AbortError") return;
307
+ console.error("Failed to load roles:", error);
308
+ setRolesError(error instanceof Error ? error : new Error(String(error)));
309
+
310
+ // If the error is a permission issue (e.g. 403), skip loading
311
+ // users — they will fail with the same error and we'd show a
312
+ // duplicate snackbar / error message.
313
+ const status = (error as { status?: number }).status;
314
+ if (status === 403 || status === 401) {
315
+ setUsersError(error instanceof Error ? error : new Error(String(error)));
316
+ setLoading(false);
317
+ return;
285
318
  }
286
319
  }
320
+
287
321
  // Then load all users if not aborted
288
322
  if (!abortController.signal.aborted) {
289
- await loadUsers(abortController.signal);
323
+ try {
324
+ const data = await request("/users", "GET", undefined, 6, abortController.signal);
325
+ const allUsers: User[] = data.users.map((u: ApiUser) => convertUser(u));
326
+ setUsers(allUsers);
327
+ setUsersError(undefined);
328
+ } catch (error: unknown) {
329
+ if (error instanceof Error && error.name === "AbortError") return;
330
+ console.error("Failed to load users:", error);
331
+ setUsersError(error instanceof Error ? error : new Error(String(error)));
332
+ }
290
333
  }
291
334
 
292
335
  if (!abortController.signal.aborted) {
336
+ lastLoadedUidRef.current = currentUser.uid;
293
337
  setLoading(false);
294
338
  }
295
339
  };
@@ -298,7 +342,8 @@ export function useBackendUserManagement(config: BackendUserManagementConfig): U
298
342
  return () => {
299
343
  abortController.abort();
300
344
  };
301
- }, [currentUser, apiRequest, loadUsers]);
345
+ // eslint-disable-next-line react-hooks/exhaustive-deps
346
+ }, [currentUser?.uid]);
302
347
 
303
348
  /**
304
349
  * Search users with server-side pagination.
@@ -306,6 +306,7 @@ export function useRebaseAuthController(
306
306
 
307
307
  // Handle successful authentication
308
308
  const handleAuthSuccess = useCallback(async (userInfo: UserInfo, tokens: AuthTokens) => {
309
+ console.log("[Auth] handleAuthSuccess called, user:", userInfo.email, "uid:", userInfo.uid);
309
310
  tokensRef.current = tokens;
310
311
  let convertedUser = convertToUser(userInfo);
311
312
 
@@ -321,11 +322,13 @@ roles: customRoles.map(r => r.id) };
321
322
  // Save to localStorage for persistence
322
323
  saveAuthToStorage(tokens, userInfo);
323
324
 
325
+ console.log("[Auth] Calling setUser, roles:", convertedUser.roles);
324
326
  setUser(convertedUser);
325
327
  setAuthError(null);
326
328
  setAuthProviderError(null);
327
329
  setLoginSkipped(false);
328
330
  scheduleTokenRefresh(tokens);
331
+ console.log("[Auth] handleAuthSuccess completed");
329
332
  }, [scheduleTokenRefresh, defineRolesFor]);
330
333
 
331
334
  // Email/password login
@@ -360,13 +363,18 @@ roles: customRoles.map(r => r.id) };
360
363
  }
361
364
  }, [handleAuthSuccess]);
362
365
 
363
- // Google login with ID token or access token
364
- const googleLogin = useCallback(async (token: string, tokenType: "idToken" | "accessToken" = "idToken") => {
366
+ // Google login supports legacy (token, tokenType) and code-flow payload
367
+ const googleLogin = useCallback(async (
368
+ tokenOrPayload: string | { code: string; redirectUri: string },
369
+ tokenType?: "idToken" | "accessToken"
370
+ ) => {
365
371
  setAuthLoading(true);
366
372
  setAuthProviderError(null);
367
373
 
368
374
  try {
369
- const response = await authApi.googleLogin(token, tokenType);
375
+ const response = typeof tokenOrPayload === "string"
376
+ ? await authApi.googleLogin(tokenOrPayload, tokenType ?? "idToken")
377
+ : await authApi.googleLogin(tokenOrPayload);
370
378
  await handleAuthSuccess(response.user, response.tokens);
371
379
  } catch (error: unknown) {
372
380
  setAuthProviderError(error as Error);
package/src/types.ts CHANGED
@@ -5,8 +5,11 @@ import { AuthController, Role, User } from "@rebasepro/types";
5
5
  * with additional methods for email/password and Google login
6
6
  */
7
7
  export type RebaseAuthController = AuthController & {
8
- /** Login with Google token (ID token from One Tap, or access token from popup) */
9
- googleLogin: (token: string, tokenType?: "idToken" | "accessToken") => Promise<void>;
8
+ /** Login with Google accepts legacy (token, tokenType) or code-flow payload */
9
+ googleLogin: {
10
+ (token: string, tokenType?: "idToken" | "accessToken"): Promise<void>;
11
+ (payload: { code: string; redirectUri: string }): Promise<void>;
12
+ };
10
13
  /** Generic OAuth login — works with any provider. Posts payload to /auth/{providerId}. */
11
14
  oauthLogin: (providerId: string, payload: Record<string, unknown>) => Promise<void>;
12
15
  /** Login with email and password */