@rebasepro/auth 0.0.1-canary.eae7889 → 0.1.0

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 ID token from frontend Google Sign-In */
8
- googleLogin: (idToken: string) => 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.eae7889",
4
+ "version": "0.1.0",
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/ui": "0.0.1-canary.eae7889",
15
- "@rebasepro/core": "0.0.1-canary.eae7889",
16
- "@rebasepro/types": "0.0.1-canary.eae7889"
14
+ "@rebasepro/core": "0.1.0",
15
+ "@rebasepro/types": "0.1.0",
16
+ "@rebasepro/ui": "0.1.0"
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 ID token
106
+ * Google login payload one of the three supported flows.
107
107
  */
108
- export async function googleLogin(idToken: string): 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({ idToken })
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
  }
@@ -71,6 +69,7 @@ export function UsersView({ userManagement, apiUrl, getAuthToken }: {
71
69
  getAuthToken: () => Promise<string>;
72
70
  }) {
73
71
  const { users, roles, saveUser, deleteUser, loading } = userManagement;
72
+ const usersError = 'usersError' in userManagement ? (userManagement as { usersError?: Error }).usersError : undefined;
74
73
  const snackbarController = useSnackbarController();
75
74
  const { user: loggedInUser } = useAuthController();
76
75
 
@@ -219,8 +218,15 @@ message: error instanceof Error ? error.message : "Error deleting user" });
219
218
  <TableCell colspan={4}>
220
219
  <CenteredView className="flex flex-col gap-4 my-8 items-center">
221
220
  <Typography variant="label">
222
- There are no users yet
221
+ {usersError
222
+ ? "You don't have permission to view users"
223
+ : "There are no users yet"}
223
224
  </Typography>
225
+ {usersError && (
226
+ <Typography variant="caption" className="text-surface-500">
227
+ Contact an administrator if you need access to this section.
228
+ </Typography>
229
+ )}
224
230
  </CenteredView>
225
231
  </TableCell>
226
232
  </TableRow>
@@ -405,6 +411,7 @@ height: "100%" }}>
405
411
  // ============================================
406
412
  export function RolesView({ userManagement, collections = [] }: { userManagement: UserManagement, collections?: EntityCollection[] }) {
407
413
  const { roles, saveRole, deleteRole, loading, allowDefaultRolesCreation } = userManagement;
414
+ const rolesError = 'rolesError' in userManagement ? (userManagement as { rolesError?: Error }).rolesError : undefined;
408
415
  const snackbarController = useSnackbarController();
409
416
 
410
417
  const [dialogOpen, setDialogOpen] = useState(false);
@@ -516,9 +523,16 @@ isAdmin: false }
516
523
  <TableCell colspan={4}>
517
524
  <CenteredView className="flex flex-col gap-4 my-8 items-center">
518
525
  <Typography variant="label">
519
- You don&apos;t have any roles yet.
526
+ {rolesError
527
+ ? "You don't have permission to view roles"
528
+ : "You don\u0026apos;t have any roles yet."}
520
529
  </Typography>
521
- {allowDefaultRolesCreation && (
530
+ {rolesError && (
531
+ <Typography variant="caption" className="text-surface-500">
532
+ Contact an administrator if you need access to this section.
533
+ </Typography>
534
+ )}
535
+ {!rolesError && allowDefaultRolesCreation && (
522
536
  <Button onClick={createDefaultRoles}>
523
537
  Create default roles
524
538
  </Button>
@@ -141,7 +141,7 @@ export function RebaseLoginView({
141
141
  return (
142
142
  <div
143
143
  className={cls(
144
- "relative flex items-center justify-center h-screen w-screen p-4 transition-opacity duration-500 bg-white dark:bg-surface-950",
144
+ "relative flex items-center justify-center h-screen w-screen p-4 transition-opacity duration-500 bg-white dark:bg-surface-900",
145
145
  fadeIn ? "opacity-100" : "opacity-0"
146
146
  )}>
147
147
 
@@ -296,6 +296,37 @@ function LoginButton({
296
296
  );
297
297
  }
298
298
 
299
+ const GoogleIcon = () => (
300
+ <svg viewBox="0 0 24 24" width="20" height="20">
301
+ <path fill="#4285F4"
302
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
303
+ <path fill="#34A853"
304
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
305
+ <path fill="#FBBC05"
306
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
307
+ <path fill="#EA4335"
308
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
309
+ </svg>
310
+ );
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
+
299
330
  function GoogleLoginButton({
300
331
  disabled,
301
332
  googleClientId,
@@ -305,52 +336,50 @@ function GoogleLoginButton({
305
336
  googleClientId: string,
306
337
  authController: RebaseAuthController
307
338
  }) {
308
- const handleGoogleLogin = async () => {
309
- try {
310
- const google = (window as unknown as { google?: { accounts: { id: { initialize: (config: { client_id: string; callback: (response: { credential: string }) => void }) => void; prompt: () => void } } } }).google;
311
- if (!google) {
312
- console.error("Google Sign-In not loaded");
313
- return;
314
- }
339
+ const codeClientRef = useRef<{ requestCode(): void } | null>(null);
315
340
 
316
- google.accounts.id.initialize({
317
- client_id: googleClientId,
318
- callback: async (response: { credential: string }) => {
319
- try {
320
- await authController.googleLogin(response.credential);
321
- } catch (err: unknown) {
322
- console.error("Google login error:", err);
323
- }
341
+ useEffect(() => {
342
+ const google = window.google;
343
+ if (!google || codeClientRef.current) return;
344
+
345
+ codeClientRef.current = google.accounts.oauth2.initCodeClient({
346
+ client_id: googleClientId,
347
+ scope: "openid email profile",
348
+ ux_mode: "popup",
349
+ callback: async (response: { code?: string; error?: string }) => {
350
+ if (response.error || !response.code) {
351
+ console.error("Google login error:", response.error);
352
+ return;
324
353
  }
325
- });
354
+ try {
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
+ });
361
+ } catch (err: unknown) {
362
+ console.error("Google login error:", err);
363
+ }
364
+ }
365
+ });
366
+ }, [googleClientId, authController]);
326
367
 
327
- google.accounts.id.prompt();
328
- } catch (err: unknown) {
329
- console.error("Google login error:", err);
368
+ const handleClick = () => {
369
+ if (!codeClientRef.current) {
370
+ console.error("Google Sign-In not loaded");
371
+ return;
330
372
  }
373
+ codeClientRef.current.requestCode();
331
374
  };
332
375
 
333
376
  return (
334
- <Button
377
+ <LoginButton
335
378
  disabled={disabled}
336
- className="w-full"
337
- variant="outlined"
338
- size="large"
339
- onClick={handleGoogleLogin}>
340
- <div className="flex items-center justify-center w-full gap-3 py-1">
341
- <svg viewBox="0 0 24 24" width="20" height="20">
342
- <path fill="#4285F4"
343
- d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
344
- <path fill="#34A853"
345
- d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
346
- <path fill="#FBBC05"
347
- d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
348
- <path fill="#EA4335"
349
- d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
350
- </svg>
351
- <Typography variant="button">Continue with Google</Typography>
352
- </div>
353
- </Button>
379
+ text="Sign in with Google"
380
+ icon={<GoogleIcon/>}
381
+ onClick={handleClick}
382
+ />
354
383
  );
355
384
  }
356
385
 
@@ -518,6 +547,7 @@ function LoginForm({
518
547
  <LoadingButton
519
548
  type="submit"
520
549
  variant="filled"
550
+ color="primary"
521
551
  className="w-full mt-1"
522
552
  size="large"
523
553
  loading={authController.authLoading}
@@ -681,6 +711,7 @@ function ForgotPasswordForm({
681
711
  <LoadingButton
682
712
  type="submit"
683
713
  variant="filled"
714
+ color="primary"
684
715
  className="w-full"
685
716
  size="large"
686
717
  loading={authController.authLoading}
@@ -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
364
- const googleLogin = useCallback(async (idToken: string) => {
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(idToken);
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 ID token from frontend Google Sign-In */
9
- googleLogin: (idToken: string) => 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 */