antaeus.keycloak.react 1.0.6

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/index.mjs ADDED
@@ -0,0 +1,937 @@
1
+ import { useCallback, useMemo, useEffect, useState, useRef } from 'react';
2
+ import { useAuth as useAuth$1, AuthProvider } from 'react-oidc-context';
3
+ import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
4
+ import { useLocation } from 'react-router-dom';
5
+ import { QRCodeSVG } from 'qrcode.react';
6
+ import { User } from 'oidc-client-ts';
7
+
8
+ // src/components/KeycloakProvider.tsx
9
+
10
+ // src/config/defaults.ts
11
+ var defaultConfig = {
12
+ redirectUri: typeof window !== "undefined" ? window.location.origin + "/" : "/",
13
+ postLogoutRedirectUri: typeof window !== "undefined" ? window.location.origin + "/" : "/",
14
+ scope: "openid profile email offline_access",
15
+ features: {
16
+ deviceFlow: false,
17
+ sessionMonitor: false,
18
+ autoRefresh: true
19
+ },
20
+ silentRenew: {
21
+ enabled: true,
22
+ silentRedirectUri: typeof window !== "undefined" ? window.location.origin + "/silent-renew.html" : "/silent-renew.html",
23
+ thresholdSeconds: 300
24
+ // 5 minutes
25
+ },
26
+ advanced: {
27
+ responseType: "code",
28
+ codeChallengeMethod: "S256",
29
+ loadUserInfo: true,
30
+ automaticSilentSignIn: false
31
+ },
32
+ events: {}
33
+ };
34
+
35
+ // src/config/KeycloakConfig.ts
36
+ var KeycloakConfigBuilder = class {
37
+ /**
38
+ * Builds OIDC client configuration from Keycloak config
39
+ *
40
+ * @param userConfig - User-provided Keycloak configuration
41
+ * @returns OIDC client settings ready for react-oidc-context
42
+ * @throws Error if required fields are missing or invalid
43
+ */
44
+ static build(userConfig) {
45
+ this.validate(userConfig);
46
+ const config = this.mergeWithDefaults(userConfig);
47
+ const oidcSettings = {
48
+ authority: config.authority,
49
+ client_id: config.clientId,
50
+ redirect_uri: config.redirectUri,
51
+ post_logout_redirect_uri: config.postLogoutRedirectUri,
52
+ response_type: config.advanced.responseType,
53
+ scope: config.scope,
54
+ automaticSilentRenew: config.silentRenew.enabled,
55
+ loadUserInfo: config.advanced.loadUserInfo,
56
+ silent_redirect_uri: config.silentRenew.silentRedirectUri
57
+ };
58
+ return oidcSettings;
59
+ }
60
+ /**
61
+ * Validates required configuration fields
62
+ *
63
+ * @param config - Configuration to validate
64
+ * @throws Error if validation fails
65
+ */
66
+ static validate(config) {
67
+ if (!config.authority) {
68
+ throw new Error("Keycloak authority is required");
69
+ }
70
+ if (!config.clientId) {
71
+ throw new Error("Keycloak clientId is required");
72
+ }
73
+ try {
74
+ new URL(config.authority);
75
+ } catch {
76
+ throw new Error("Keycloak authority must be a valid URL");
77
+ }
78
+ }
79
+ /**
80
+ * Merges user configuration with defaults
81
+ *
82
+ * @param config - User configuration
83
+ * @returns Merged configuration with all defaults applied
84
+ */
85
+ static mergeWithDefaults(config) {
86
+ return {
87
+ authority: config.authority,
88
+ clientId: config.clientId,
89
+ redirectUri: config.redirectUri ?? defaultConfig.redirectUri,
90
+ postLogoutRedirectUri: config.postLogoutRedirectUri ?? defaultConfig.postLogoutRedirectUri,
91
+ scope: config.scope ?? defaultConfig.scope,
92
+ features: {
93
+ ...defaultConfig.features,
94
+ ...config.features
95
+ },
96
+ silentRenew: {
97
+ ...defaultConfig.silentRenew,
98
+ ...config.silentRenew
99
+ },
100
+ advanced: {
101
+ ...defaultConfig.advanced,
102
+ ...config.advanced
103
+ },
104
+ events: {
105
+ ...defaultConfig.events,
106
+ ...config.events
107
+ }
108
+ };
109
+ }
110
+ };
111
+ function getConfigFromEnv() {
112
+ if (typeof import.meta === "undefined") {
113
+ return {};
114
+ }
115
+ const env = import.meta.env;
116
+ if (!env) {
117
+ return {};
118
+ }
119
+ return {
120
+ authority: env.VITE_KEYCLOAK_AUTHORITY,
121
+ clientId: env.VITE_KEYCLOAK_CLIENT_ID,
122
+ features: {
123
+ deviceFlow: env.VITE_ENABLE_DEVICE_FLOW === "true",
124
+ sessionMonitor: env.VITE_ENABLE_SESSION_MONITOR === "true"
125
+ }
126
+ };
127
+ }
128
+ var KeycloakProvider = ({
129
+ config,
130
+ children,
131
+ debug = false
132
+ }) => {
133
+ const oidcConfig = KeycloakConfigBuilder.build(config);
134
+ const log = useCallback(
135
+ (level, message, ...args) => {
136
+ if (debug) {
137
+ const prefix = "[Keycloak]";
138
+ switch (level) {
139
+ case "info":
140
+ console.log(prefix, message, ...args);
141
+ break;
142
+ case "warn":
143
+ console.warn(prefix, message, ...args);
144
+ break;
145
+ case "error":
146
+ console.error(prefix, message, ...args);
147
+ break;
148
+ }
149
+ }
150
+ },
151
+ [debug]
152
+ );
153
+ const onSigninCallback = useCallback(
154
+ (_user) => {
155
+ log("info", "Sign in successful");
156
+ window.history.replaceState({}, document.title, window.location.pathname);
157
+ if (_user) {
158
+ config.events?.onLogin?.(_user);
159
+ }
160
+ },
161
+ [config.events, log]
162
+ );
163
+ const onSignoutCallback = useCallback(() => {
164
+ log("info", "Sign out successful");
165
+ config.events?.onLogout?.();
166
+ }, [config.events, log]);
167
+ const onRemoveUser = useCallback(() => {
168
+ log("info", "User removed");
169
+ }, [log]);
170
+ return /* @__PURE__ */ jsx(
171
+ AuthProvider,
172
+ {
173
+ ...oidcConfig,
174
+ onSigninCallback,
175
+ onSignoutCallback,
176
+ onRemoveUser,
177
+ children
178
+ }
179
+ );
180
+ };
181
+ var useAuth = useAuth$1;
182
+ function useRoles() {
183
+ const auth = useAuth();
184
+ const roles = useMemo(() => {
185
+ if (!auth.user?.profile) {
186
+ return [];
187
+ }
188
+ const realmAccess = auth.user.profile.realm_access;
189
+ return realmAccess?.roles || [];
190
+ }, [auth.user]);
191
+ const hasRole = useMemo(
192
+ () => (role) => {
193
+ return roles.includes(role);
194
+ },
195
+ [roles]
196
+ );
197
+ const hasAnyRole = useMemo(
198
+ () => (requiredRoles) => {
199
+ return requiredRoles.some((role) => roles.includes(role));
200
+ },
201
+ [roles]
202
+ );
203
+ const hasAllRoles = useMemo(
204
+ () => (requiredRoles) => {
205
+ return requiredRoles.every((role) => roles.includes(role));
206
+ },
207
+ [roles]
208
+ );
209
+ const getClientRoles = useMemo(
210
+ () => (clientId) => {
211
+ if (!auth.user?.profile) {
212
+ return [];
213
+ }
214
+ const resourceAccess = auth.user.profile.resource_access;
215
+ return resourceAccess?.[clientId]?.roles || [];
216
+ },
217
+ [auth.user]
218
+ );
219
+ return {
220
+ roles,
221
+ hasRole,
222
+ hasAnyRole,
223
+ hasAllRoles,
224
+ getClientRoles
225
+ };
226
+ }
227
+ function useScopes() {
228
+ const auth = useAuth();
229
+ const scopes = useMemo(() => {
230
+ if (!auth.user?.scope) return [];
231
+ return auth.user.scope.split(" ").filter((s) => s.length > 0);
232
+ }, [auth.user]);
233
+ const hasScope = useMemo(
234
+ () => (scope) => {
235
+ return scopes.includes(scope);
236
+ },
237
+ [scopes]
238
+ );
239
+ const hasAnyScope = useMemo(
240
+ () => (requiredScopes) => {
241
+ return requiredScopes.some((scope) => scopes.includes(scope));
242
+ },
243
+ [scopes]
244
+ );
245
+ const hasAllScopes = useMemo(
246
+ () => (requiredScopes) => {
247
+ return requiredScopes.every((scope) => scopes.includes(scope));
248
+ },
249
+ [scopes]
250
+ );
251
+ return {
252
+ scopes,
253
+ hasScope,
254
+ hasAnyScope,
255
+ hasAllScopes
256
+ };
257
+ }
258
+ var ProtectedRoute = ({
259
+ children,
260
+ requiredRoles = [],
261
+ requireAllRoles = false,
262
+ requiredScopes = [],
263
+ requireAllScopes = false,
264
+ policy,
265
+ loadingComponent,
266
+ unauthorizedComponent,
267
+ onUnauthorized
268
+ }) => {
269
+ const auth = useAuth();
270
+ const { hasAnyRole, hasAllRoles } = useRoles();
271
+ const { hasAnyScope, hasAllScopes } = useScopes();
272
+ const location = useLocation();
273
+ const isAuthenticated = !auth.isLoading && auth.isAuthenticated;
274
+ const policyAccess = policy && auth.user ? policy(auth.user, { location }) : null;
275
+ const roleAccess = requiredRoles.length > 0 ? requireAllRoles ? hasAllRoles(requiredRoles) : hasAnyRole(requiredRoles) : null;
276
+ const scopeAccess = requiredScopes.length > 0 ? requireAllScopes ? hasAllScopes(requiredScopes) : hasAnyScope(requiredScopes) : null;
277
+ const unauthorizedReason = !isAuthenticated ? "not-authenticated" : policyAccess === false ? "policy-denied" : roleAccess === false ? "insufficient-roles" : scopeAccess === false ? "insufficient-scopes" : null;
278
+ useEffect(() => {
279
+ if (unauthorizedReason) {
280
+ onUnauthorized?.(unauthorizedReason);
281
+ }
282
+ }, [unauthorizedReason, onUnauthorized]);
283
+ useEffect(() => {
284
+ if (!auth.isLoading && !auth.isAuthenticated) {
285
+ auth.signinRedirect({
286
+ redirect_uri: window.location.origin + location.pathname + location.search
287
+ });
288
+ }
289
+ }, [auth.isLoading, auth.isAuthenticated, auth, location.pathname, location.search]);
290
+ if (auth.isLoading) {
291
+ return /* @__PURE__ */ jsx(Fragment, { children: loadingComponent || /* @__PURE__ */ jsx(DefaultLoadingComponent, {}) });
292
+ }
293
+ if (!isAuthenticated) {
294
+ return /* @__PURE__ */ jsx(Fragment, { children: loadingComponent || /* @__PURE__ */ jsx(DefaultLoadingComponent, {}) });
295
+ }
296
+ if (unauthorizedReason) {
297
+ return /* @__PURE__ */ jsx(Fragment, { children: unauthorizedComponent || /* @__PURE__ */ jsx(DefaultUnauthorizedComponent, {}) });
298
+ }
299
+ return /* @__PURE__ */ jsx(Fragment, { children });
300
+ };
301
+ var DefaultLoadingComponent = () => /* @__PURE__ */ jsxs(
302
+ "div",
303
+ {
304
+ style: {
305
+ display: "flex",
306
+ justifyContent: "center",
307
+ alignItems: "center",
308
+ minHeight: "200px",
309
+ flexDirection: "column",
310
+ gap: "1rem"
311
+ },
312
+ children: [
313
+ /* @__PURE__ */ jsx(
314
+ "div",
315
+ {
316
+ style: {
317
+ border: "3px solid #f3f3f3",
318
+ borderTop: "3px solid #3498db",
319
+ borderRadius: "50%",
320
+ width: "40px",
321
+ height: "40px",
322
+ animation: "spin 1s linear infinite"
323
+ }
324
+ }
325
+ ),
326
+ /* @__PURE__ */ jsx("p", { style: { color: "#666", margin: 0 }, children: "Loading..." }),
327
+ /* @__PURE__ */ jsx("style", { children: `
328
+ @keyframes spin {
329
+ 0% { transform: rotate(0deg); }
330
+ 100% { transform: rotate(360deg); }
331
+ }
332
+ ` })
333
+ ]
334
+ }
335
+ );
336
+ var DefaultUnauthorizedComponent = () => /* @__PURE__ */ jsxs(
337
+ "div",
338
+ {
339
+ style: {
340
+ display: "flex",
341
+ justifyContent: "center",
342
+ alignItems: "center",
343
+ minHeight: "200px",
344
+ flexDirection: "column",
345
+ gap: "1rem",
346
+ padding: "2rem",
347
+ textAlign: "center"
348
+ },
349
+ children: [
350
+ /* @__PURE__ */ jsx(
351
+ "div",
352
+ {
353
+ style: {
354
+ fontSize: "4rem",
355
+ color: "#e74c3c"
356
+ },
357
+ children: "\u{1F512}"
358
+ }
359
+ ),
360
+ /* @__PURE__ */ jsx("h1", { style: { margin: 0, color: "#333" }, children: "Access Denied" }),
361
+ /* @__PURE__ */ jsx("p", { style: { color: "#666", margin: 0 }, children: "You do not have permission to access this page." })
362
+ ]
363
+ }
364
+ );
365
+ var LoginButton = ({
366
+ children = "Login",
367
+ className = "",
368
+ variant = "primary",
369
+ size = "medium",
370
+ showIcon = true,
371
+ onLoginStart,
372
+ style,
373
+ disabled = false
374
+ }) => {
375
+ const auth = useAuth();
376
+ if (auth.isAuthenticated) {
377
+ return null;
378
+ }
379
+ const handleClick = () => {
380
+ onLoginStart?.();
381
+ auth.signinRedirect();
382
+ };
383
+ const baseClass = "kc-button kc-login-button";
384
+ const variantClass = `kc-button--${variant}`;
385
+ const sizeClass = `kc-button--${size}`;
386
+ const classes = `${baseClass} ${variantClass} ${sizeClass} ${className}`.trim();
387
+ return /* @__PURE__ */ jsxs(
388
+ "button",
389
+ {
390
+ type: "button",
391
+ className: classes,
392
+ onClick: handleClick,
393
+ disabled: disabled || auth.isLoading,
394
+ style,
395
+ children: [
396
+ showIcon && /* @__PURE__ */ jsx("span", { className: "kc-button__icon", children: "\u{1F510}" }),
397
+ /* @__PURE__ */ jsx("span", { className: "kc-button__text", children })
398
+ ]
399
+ }
400
+ );
401
+ };
402
+ var LogoutButton = ({
403
+ children = "Logout",
404
+ className = "",
405
+ variant = "secondary",
406
+ size = "medium",
407
+ showIcon = true,
408
+ onLogoutStart,
409
+ style,
410
+ disabled = false
411
+ }) => {
412
+ const auth = useAuth();
413
+ if (!auth.isAuthenticated) {
414
+ return null;
415
+ }
416
+ const handleClick = () => {
417
+ onLogoutStart?.();
418
+ auth.signoutRedirect();
419
+ };
420
+ const baseClass = "kc-button kc-logout-button";
421
+ const variantClass = `kc-button--${variant}`;
422
+ const sizeClass = `kc-button--${size}`;
423
+ const classes = `${baseClass} ${variantClass} ${sizeClass} ${className}`.trim();
424
+ return /* @__PURE__ */ jsxs(
425
+ "button",
426
+ {
427
+ type: "button",
428
+ className: classes,
429
+ onClick: handleClick,
430
+ disabled: disabled || auth.isLoading,
431
+ style,
432
+ children: [
433
+ showIcon && /* @__PURE__ */ jsx("span", { className: "kc-button__icon", children: "\u{1F6AA}" }),
434
+ /* @__PURE__ */ jsx("span", { className: "kc-button__text", children })
435
+ ]
436
+ }
437
+ );
438
+ };
439
+ var UserProfile = ({
440
+ className = "",
441
+ showAvatar = true,
442
+ showEmail = true,
443
+ showRoles = false,
444
+ avatarSize = 40,
445
+ style,
446
+ onClick
447
+ }) => {
448
+ const auth = useAuth();
449
+ const { roles } = useRoles();
450
+ if (!auth.isAuthenticated || !auth.user) {
451
+ return null;
452
+ }
453
+ const profile = auth.user.profile;
454
+ const name = profile.name || profile.preferred_username || "User";
455
+ const email = profile.email;
456
+ const initials = getInitials(name);
457
+ const baseClass = "kc-user-profile";
458
+ const clickableClass = onClick ? "kc-user-profile--clickable" : "";
459
+ const classes = `${baseClass} ${clickableClass} ${className}`.trim();
460
+ const handleClick = () => {
461
+ if (onClick) {
462
+ onClick();
463
+ }
464
+ };
465
+ return /* @__PURE__ */ jsxs(
466
+ "div",
467
+ {
468
+ className: classes,
469
+ style,
470
+ onClick: handleClick,
471
+ role: onClick ? "button" : void 0,
472
+ tabIndex: onClick ? 0 : void 0,
473
+ onKeyPress: onClick ? (e) => e.key === "Enter" && handleClick() : void 0,
474
+ children: [
475
+ showAvatar && /* @__PURE__ */ jsx(
476
+ "div",
477
+ {
478
+ className: "kc-user-profile__avatar",
479
+ style: {
480
+ width: avatarSize,
481
+ height: avatarSize,
482
+ fontSize: avatarSize * 0.4
483
+ },
484
+ children: initials
485
+ }
486
+ ),
487
+ /* @__PURE__ */ jsxs("div", { className: "kc-user-profile__info", children: [
488
+ /* @__PURE__ */ jsx("div", { className: "kc-user-profile__name", children: name }),
489
+ showEmail && email && /* @__PURE__ */ jsx("div", { className: "kc-user-profile__email", children: email }),
490
+ showRoles && roles.length > 0 && /* @__PURE__ */ jsxs("div", { className: "kc-user-profile__roles", children: [
491
+ roles.slice(0, 3).map((role) => /* @__PURE__ */ jsx("span", { className: "kc-user-profile__role-badge", children: role }, role)),
492
+ roles.length > 3 && /* @__PURE__ */ jsxs("span", { className: "kc-user-profile__role-badge", children: [
493
+ "+",
494
+ roles.length - 3
495
+ ] })
496
+ ] })
497
+ ] })
498
+ ]
499
+ }
500
+ );
501
+ };
502
+ function getInitials(name) {
503
+ const parts = name.trim().split(" ");
504
+ if (parts.length === 1) {
505
+ return parts[0].substring(0, 2).toUpperCase();
506
+ }
507
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
508
+ }
509
+ var SessionMonitor = ({
510
+ position = "top-right",
511
+ warningThreshold = 300,
512
+ // 5 minutes
513
+ criticalThreshold = 60,
514
+ // 1 minute
515
+ showProgressBar = true,
516
+ minimizable = true,
517
+ startMinimized = false,
518
+ className = "",
519
+ onWarning,
520
+ onCritical,
521
+ onExpired,
522
+ style
523
+ }) => {
524
+ const auth = useAuth();
525
+ const [timeRemaining, setTimeRemaining] = useState(null);
526
+ const [minimized, setMinimized] = useState(startMinimized);
527
+ const [warningTriggered, setWarningTriggered] = useState(false);
528
+ const [criticalTriggered, setCriticalTriggered] = useState(false);
529
+ useEffect(() => {
530
+ if (!auth.isAuthenticated || !auth.user?.expires_at) {
531
+ setTimeRemaining(null);
532
+ return;
533
+ }
534
+ const updateTimeRemaining = () => {
535
+ const expiresAt = auth.user.expires_at;
536
+ const now = Math.floor(Date.now() / 1e3);
537
+ const remaining = expiresAt - now;
538
+ if (remaining <= 0) {
539
+ setTimeRemaining(0);
540
+ onExpired?.();
541
+ setTimeout(() => {
542
+ auth.signoutRedirect();
543
+ }, 2e3);
544
+ } else {
545
+ setTimeRemaining(remaining);
546
+ if (remaining <= criticalThreshold && !criticalTriggered) {
547
+ setCriticalTriggered(true);
548
+ onCritical?.(remaining);
549
+ } else if (remaining <= warningThreshold && !warningTriggered) {
550
+ setWarningTriggered(true);
551
+ onWarning?.(remaining);
552
+ }
553
+ }
554
+ };
555
+ updateTimeRemaining();
556
+ const interval = setInterval(updateTimeRemaining, 1e3);
557
+ return () => clearInterval(interval);
558
+ }, [
559
+ auth,
560
+ warningThreshold,
561
+ criticalThreshold,
562
+ warningTriggered,
563
+ criticalTriggered,
564
+ onWarning,
565
+ onCritical,
566
+ onExpired
567
+ ]);
568
+ useEffect(() => {
569
+ if (timeRemaining && timeRemaining > warningThreshold) {
570
+ setWarningTriggered(false);
571
+ setCriticalTriggered(false);
572
+ }
573
+ }, [timeRemaining, warningThreshold]);
574
+ const formatTime = useCallback((seconds) => {
575
+ const mins = Math.floor(seconds / 60);
576
+ const secs = seconds % 60;
577
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
578
+ }, []);
579
+ const getStatusClass = useCallback(() => {
580
+ if (!timeRemaining) return "normal";
581
+ if (timeRemaining <= criticalThreshold) return "critical";
582
+ if (timeRemaining <= warningThreshold) return "warning";
583
+ return "normal";
584
+ }, [timeRemaining, warningThreshold, criticalThreshold]);
585
+ const getProgress = useCallback(() => {
586
+ if (!timeRemaining || !auth.user?.expires_in) return 100;
587
+ return timeRemaining / auth.user.expires_in * 100;
588
+ }, [timeRemaining, auth.user]);
589
+ if (!auth.isAuthenticated || timeRemaining === null) {
590
+ return null;
591
+ }
592
+ const baseClass = "kc-session-monitor";
593
+ const positionClass = `kc-session-monitor--${position}`;
594
+ const statusClass = `kc-session-monitor--${getStatusClass()}`;
595
+ const minimizedClass = minimized ? "kc-session-monitor--minimized" : "";
596
+ const classes = `${baseClass} ${positionClass} ${statusClass} ${minimizedClass} ${className}`.trim();
597
+ const progress = getProgress();
598
+ return /* @__PURE__ */ jsxs("div", { className: classes, style, children: [
599
+ minimizable && /* @__PURE__ */ jsx(
600
+ "button",
601
+ {
602
+ className: "kc-session-monitor__toggle",
603
+ onClick: () => setMinimized(!minimized),
604
+ "aria-label": minimized ? "Expand session monitor" : "Minimize session monitor",
605
+ title: minimized ? "Expand" : "Minimize",
606
+ children: minimized ? "\u25B2" : "\u25BC"
607
+ }
608
+ ),
609
+ !minimized && /* @__PURE__ */ jsxs(Fragment, { children: [
610
+ /* @__PURE__ */ jsxs("div", { className: "kc-session-monitor__content", children: [
611
+ /* @__PURE__ */ jsx("span", { className: "kc-session-monitor__label", children: timeRemaining === 0 ? "Session expired" : "Session expires in:" }),
612
+ /* @__PURE__ */ jsx("span", { className: "kc-session-monitor__time", children: formatTime(timeRemaining) })
613
+ ] }),
614
+ showProgressBar && /* @__PURE__ */ jsx("div", { className: "kc-session-monitor__progress-bar", children: /* @__PURE__ */ jsx(
615
+ "div",
616
+ {
617
+ className: "kc-session-monitor__progress-fill",
618
+ style: { width: `${progress}%` },
619
+ role: "progressbar",
620
+ "aria-valuenow": progress,
621
+ "aria-valuemin": 0,
622
+ "aria-valuemax": 100
623
+ }
624
+ ) })
625
+ ] })
626
+ ] });
627
+ };
628
+
629
+ // src/utils/deviceFlow.ts
630
+ async function requestDeviceCode(apiBaseUrl) {
631
+ const url = `${apiBaseUrl}/api/keycloak/device/authorize`;
632
+ try {
633
+ const response = await fetch(url, {
634
+ method: "POST",
635
+ headers: {
636
+ "Content-Type": "application/json"
637
+ }
638
+ });
639
+ if (!response.ok) {
640
+ const errorText = await response.text();
641
+ throw new Error(
642
+ `Device authorization failed: ${response.status} ${response.statusText}. ${errorText}`
643
+ );
644
+ }
645
+ const data = await response.json();
646
+ return data;
647
+ } catch (error) {
648
+ if (error instanceof Error) {
649
+ throw new Error(`Failed to request device code: ${error.message}`);
650
+ }
651
+ throw error;
652
+ }
653
+ }
654
+ async function pollForToken(apiBaseUrl, deviceCode) {
655
+ const url = `${apiBaseUrl}/api/keycloak/device/token`;
656
+ try {
657
+ const response = await fetch(url, {
658
+ method: "POST",
659
+ headers: {
660
+ "Content-Type": "application/json"
661
+ },
662
+ body: JSON.stringify({ device_code: deviceCode })
663
+ });
664
+ const data = await response.json();
665
+ if ("error" in data) {
666
+ const errorData = data;
667
+ if (errorData.error === "authorization_pending" || errorData.error === "slow_down") {
668
+ return null;
669
+ }
670
+ throw new Error(
671
+ errorData.error_description || errorData.error || "Token polling failed"
672
+ );
673
+ }
674
+ if (!response.ok) {
675
+ throw new Error(`Token polling failed with status ${response.status}`);
676
+ }
677
+ return data;
678
+ } catch (error) {
679
+ if (error instanceof Error) {
680
+ throw error;
681
+ }
682
+ throw new Error("Token polling failed");
683
+ }
684
+ }
685
+ async function fetchUserInfo(apiBaseUrl, accessToken) {
686
+ const url = `${apiBaseUrl}/api/keycloak/device/userinfo`;
687
+ try {
688
+ const response = await fetch(url, {
689
+ method: "POST",
690
+ headers: {
691
+ "Content-Type": "application/json"
692
+ },
693
+ body: JSON.stringify({ access_token: accessToken })
694
+ });
695
+ if (!response.ok) {
696
+ throw new Error(`Failed to fetch user info: ${response.statusText}`);
697
+ }
698
+ return await response.json();
699
+ } catch (error) {
700
+ if (error instanceof Error) {
701
+ throw new Error(`Failed to fetch user info: ${error.message}`);
702
+ }
703
+ throw error;
704
+ }
705
+ }
706
+ var DeviceLogin = ({
707
+ apiBaseUrl,
708
+ qrCodeSize = 256,
709
+ pollingInterval = 5,
710
+ className = "",
711
+ onSuccess,
712
+ onError,
713
+ style
714
+ }) => {
715
+ const auth = useAuth();
716
+ const [deviceData, setDeviceData] = useState(null);
717
+ const [error, setError] = useState(null);
718
+ const [loading, setLoading] = useState(true);
719
+ const [timeRemaining, setTimeRemaining] = useState(null);
720
+ const pollTimerRef = useRef(null);
721
+ const userManagerRef = useRef(null);
722
+ useEffect(() => {
723
+ const initDeviceFlow = async () => {
724
+ setLoading(true);
725
+ setError(null);
726
+ try {
727
+ const response = await requestDeviceCode(apiBaseUrl);
728
+ setDeviceData(response);
729
+ setTimeRemaining(response.expires_in);
730
+ } catch (err) {
731
+ const error2 = err;
732
+ setError(error2.message);
733
+ onError?.(error2);
734
+ } finally {
735
+ setLoading(false);
736
+ }
737
+ };
738
+ initDeviceFlow();
739
+ }, [apiBaseUrl, onError]);
740
+ useEffect(() => {
741
+ if (!deviceData || timeRemaining === null) return;
742
+ const interval = setInterval(() => {
743
+ setTimeRemaining((prev) => {
744
+ if (prev === null || prev <= 1) {
745
+ clearInterval(interval);
746
+ setError("Device code expired. Please refresh to try again.");
747
+ return 0;
748
+ }
749
+ return prev - 1;
750
+ });
751
+ }, 1e3);
752
+ return () => clearInterval(interval);
753
+ }, [deviceData, timeRemaining]);
754
+ useEffect(() => {
755
+ if (!deviceData) return;
756
+ userManagerRef.current = auth.userManager;
757
+ const poll = async () => {
758
+ try {
759
+ const tokenResponse = await pollForToken(apiBaseUrl, deviceData.device_code);
760
+ if (tokenResponse) {
761
+ if (pollTimerRef.current) {
762
+ clearInterval(pollTimerRef.current);
763
+ }
764
+ const userInfo = await fetchUserInfo(apiBaseUrl, tokenResponse.access_token);
765
+ const user = new User({
766
+ id_token: tokenResponse.id_token || "",
767
+ access_token: tokenResponse.access_token,
768
+ token_type: tokenResponse.token_type,
769
+ profile: userInfo,
770
+ expires_at: Math.floor(Date.now() / 1e3) + tokenResponse.expires_in,
771
+ scope: tokenResponse.scope,
772
+ refresh_token: tokenResponse.refresh_token
773
+ });
774
+ if (userManagerRef.current) {
775
+ await userManagerRef.current.storeUser(user);
776
+ }
777
+ onSuccess?.(user);
778
+ }
779
+ } catch (err) {
780
+ if (!err.message.includes("authorization_pending")) {
781
+ if (pollTimerRef.current) {
782
+ clearInterval(pollTimerRef.current);
783
+ }
784
+ const error2 = err;
785
+ setError(error2.message);
786
+ onError?.(error2);
787
+ }
788
+ }
789
+ };
790
+ pollTimerRef.current = setInterval(poll, pollingInterval * 1e3);
791
+ return () => {
792
+ if (pollTimerRef.current) {
793
+ clearInterval(pollTimerRef.current);
794
+ }
795
+ };
796
+ }, [deviceData, apiBaseUrl, pollingInterval, onSuccess, onError, auth]);
797
+ if (loading) {
798
+ return /* @__PURE__ */ jsxs("div", { className: `kc-device-login kc-device-login--loading ${className}`, style, children: [
799
+ /* @__PURE__ */ jsx("div", { className: "kc-device-login__spinner" }),
800
+ /* @__PURE__ */ jsx("p", { children: "Initializing device flow..." })
801
+ ] });
802
+ }
803
+ if (error) {
804
+ return /* @__PURE__ */ jsxs("div", { className: `kc-device-login kc-device-login--error ${className}`, style, children: [
805
+ /* @__PURE__ */ jsx("div", { className: "kc-device-login__error-icon", children: "\u26A0\uFE0F" }),
806
+ /* @__PURE__ */ jsx("h2", { children: "Error" }),
807
+ /* @__PURE__ */ jsx("p", { children: error }),
808
+ /* @__PURE__ */ jsx(
809
+ "button",
810
+ {
811
+ className: "kc-button kc-button--primary",
812
+ onClick: () => window.location.reload(),
813
+ children: "Retry"
814
+ }
815
+ )
816
+ ] });
817
+ }
818
+ if (!deviceData) {
819
+ return null;
820
+ }
821
+ const formatTime = (seconds) => {
822
+ const mins = Math.floor(seconds / 60);
823
+ const secs = seconds % 60;
824
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
825
+ };
826
+ return /* @__PURE__ */ jsxs("div", { className: `kc-device-login ${className}`, style, children: [
827
+ /* @__PURE__ */ jsx("h2", { className: "kc-device-login__title", children: "Device Login" }),
828
+ /* @__PURE__ */ jsx("div", { className: "kc-device-login__qr", children: /* @__PURE__ */ jsx(QRCodeSVG, { value: deviceData.verification_uri_complete, size: qrCodeSize }) }),
829
+ /* @__PURE__ */ jsxs("div", { className: "kc-device-login__instructions", children: [
830
+ /* @__PURE__ */ jsx("p", { children: "Scan the QR code with your phone or visit:" }),
831
+ /* @__PURE__ */ jsx("code", { className: "kc-device-login__url", children: deviceData.verification_uri }),
832
+ /* @__PURE__ */ jsx("p", { children: "And enter code:" }),
833
+ /* @__PURE__ */ jsx("strong", { className: "kc-device-login__user-code", children: deviceData.user_code })
834
+ ] }),
835
+ timeRemaining !== null && timeRemaining > 0 && /* @__PURE__ */ jsxs("p", { className: "kc-device-login__expiry", children: [
836
+ "Code expires in ",
837
+ formatTime(timeRemaining)
838
+ ] }),
839
+ /* @__PURE__ */ jsxs("div", { className: "kc-device-login__status", children: [
840
+ /* @__PURE__ */ jsx("div", { className: "kc-device-login__spinner kc-device-login__spinner--small" }),
841
+ /* @__PURE__ */ jsx("p", { children: "Waiting for authorization..." })
842
+ ] })
843
+ ] });
844
+ };
845
+ function useProtectedFetch(options = {}) {
846
+ const auth = useAuth();
847
+ const {
848
+ baseUrl = "",
849
+ onUnauthorized,
850
+ retryOn401 = true,
851
+ headers: customHeaders = {}
852
+ } = options;
853
+ const buildUrl = useCallback(
854
+ (url) => {
855
+ if (url.startsWith("http://") || url.startsWith("https://")) {
856
+ return url;
857
+ }
858
+ const base = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
859
+ const path = url.startsWith("/") ? url : `/${url}`;
860
+ return base + path;
861
+ },
862
+ [baseUrl]
863
+ );
864
+ const getAuthHeader = useCallback(() => {
865
+ if (!auth.user?.access_token) {
866
+ return {};
867
+ }
868
+ return {
869
+ Authorization: `Bearer ${auth.user.access_token}`
870
+ };
871
+ }, [auth.user]);
872
+ const buildHeaders = useCallback(
873
+ (requestHeaders) => {
874
+ const headers = new Headers(requestHeaders);
875
+ const authHeader = getAuthHeader();
876
+ if (authHeader.Authorization) {
877
+ headers.set("Authorization", authHeader.Authorization);
878
+ }
879
+ Object.entries(customHeaders).forEach(([key, value]) => {
880
+ headers.set(key, value);
881
+ });
882
+ return headers;
883
+ },
884
+ [getAuthHeader, customHeaders]
885
+ );
886
+ const protectedFetch = useCallback(
887
+ async (url, init) => {
888
+ const fullUrl = buildUrl(url);
889
+ const headers = buildHeaders(init?.headers);
890
+ const response = await fetch(fullUrl, {
891
+ ...init,
892
+ headers
893
+ });
894
+ if (response.status === 401) {
895
+ onUnauthorized?.();
896
+ if (retryOn401 && auth.user) {
897
+ try {
898
+ await auth.signinSilent();
899
+ const newHeaders = buildHeaders(init?.headers);
900
+ const retryResponse = await fetch(fullUrl, {
901
+ ...init,
902
+ headers: newHeaders
903
+ });
904
+ return retryResponse;
905
+ } catch (error) {
906
+ return response;
907
+ }
908
+ }
909
+ }
910
+ return response;
911
+ },
912
+ [buildUrl, buildHeaders, onUnauthorized, retryOn401, auth]
913
+ );
914
+ const fetchJson = useCallback(
915
+ async (url, init) => {
916
+ const response = await protectedFetch(url, init);
917
+ if (!response.ok) {
918
+ const errorText = await response.text();
919
+ throw new Error(
920
+ `HTTP ${response.status}: ${response.statusText}. ${errorText}`
921
+ );
922
+ }
923
+ const data = await response.json();
924
+ return data;
925
+ },
926
+ [protectedFetch]
927
+ );
928
+ return {
929
+ fetchJson,
930
+ fetch: protectedFetch,
931
+ isLoading: auth.isLoading
932
+ };
933
+ }
934
+
935
+ export { DeviceLogin, KeycloakConfigBuilder, KeycloakProvider, LoginButton, LogoutButton, ProtectedRoute, SessionMonitor, UserProfile, defaultConfig, fetchUserInfo, getConfigFromEnv, pollForToken, requestDeviceCode, useAuth, useProtectedFetch, useRoles, useScopes };
936
+ //# sourceMappingURL=index.mjs.map
937
+ //# sourceMappingURL=index.mjs.map