@rebasepro/auth 0.0.1-canary.f81da60 → 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/api.d.ts +20 -2
- package/dist/components/RebaseLoginView.d.ts +22 -0
- package/dist/index.es.js +82 -37
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +81 -36
- package/dist/index.umd.js.map +1 -1
- package/dist/types.d.ts +8 -2
- package/package.json +4 -4
- package/src/api.ts +49 -9
- package/src/components/AdminViews.tsx +1 -3
- package/src/components/RebaseLoginView.tsx +48 -18
- package/src/hooks/useBackendUserManagement.ts +53 -8
- package/src/hooks/useRebaseAuthController.ts +11 -3
- package/src/types.ts +5 -2
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
|
|
8
|
-
googleLogin:
|
|
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.
|
|
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/core": "0.
|
|
15
|
-
"@rebasepro/
|
|
16
|
-
"@rebasepro/
|
|
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
|
-
*
|
|
106
|
+
* Google login payload — one of the three supported flows.
|
|
107
107
|
*/
|
|
108
|
-
export
|
|
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(
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
});
|
|
386
|
+
if (authConfigInflight) {
|
|
387
|
+
return authConfigInflight;
|
|
388
|
+
}
|
|
361
389
|
|
|
362
|
-
|
|
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 &&
|
|
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
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
216
|
+
<div className="mt-2 text-center">
|
|
217
|
+
<Typography variant="body2" color="secondary">
|
|
218
|
+
Don'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
|
|
339
|
+
const codeClientRef = useRef<{ requestCode(): void } | null>(null);
|
|
316
340
|
|
|
317
341
|
useEffect(() => {
|
|
318
|
-
const google =
|
|
319
|
-
if (!google ||
|
|
342
|
+
const google = window.google;
|
|
343
|
+
if (!google || codeClientRef.current) return;
|
|
320
344
|
|
|
321
|
-
|
|
345
|
+
codeClientRef.current = google.accounts.oauth2.initCodeClient({
|
|
322
346
|
client_id: googleClientId,
|
|
323
347
|
scope: "openid email profile",
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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 (!
|
|
369
|
+
if (!codeClientRef.current) {
|
|
340
370
|
console.error("Google Sign-In not loaded");
|
|
341
371
|
return;
|
|
342
372
|
}
|
|
343
|
-
|
|
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
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
364
|
-
const googleLogin = useCallback(async (
|
|
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 =
|
|
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
|
|
9
|
-
googleLogin:
|
|
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 */
|