@orsetra/shared-auth 1.1.0 → 1.1.3
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/ZitadelProvider.tsx +13 -3
- package/components/SessionExpiredModal.tsx +122 -0
- package/components/index.ts +1 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/services/zitadel.auth.service.ts +39 -0
package/ZitadelProvider.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from "react
|
|
|
4
4
|
import { useRouter } from "next/navigation"
|
|
5
5
|
import { ZitadelAuthService, ZitadelConfig } from "./services/zitadel.auth.service"
|
|
6
6
|
import { AuthCard, AuthCardTitle, AuthCardDescription, AuthCardSpinner } from "./components/AuthCard"
|
|
7
|
+
import { SessionExpiredModal } from "./components/SessionExpiredModal"
|
|
7
8
|
import type { User } from "oidc-client-ts"
|
|
8
9
|
|
|
9
10
|
interface ZitadelContextType {
|
|
@@ -20,16 +21,19 @@ interface ZitadelContextType {
|
|
|
20
21
|
|
|
21
22
|
const ZitadelContext = createContext<ZitadelContextType | undefined>(undefined)
|
|
22
23
|
|
|
23
|
-
interface ZitadelProviderProps {
|
|
24
|
+
export interface ZitadelProviderProps {
|
|
24
25
|
children: ReactNode
|
|
25
26
|
config: ZitadelConfig
|
|
27
|
+
/** Render the action button shown inside the session-expired modal. Receives the sign-in handler. */
|
|
28
|
+
renderSessionExpiredAction?: (onReauthenticate: () => void) => ReactNode
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
export function ZitadelProvider({ children, config }: ZitadelProviderProps) {
|
|
31
|
+
export function ZitadelProvider({ children, config, renderSessionExpiredAction }: ZitadelProviderProps) {
|
|
29
32
|
const router = useRouter()
|
|
30
33
|
const [isConfigured, setIsConfigured] = useState(false)
|
|
31
34
|
const [isLoading, setIsLoading] = useState(true)
|
|
32
35
|
const [user, setUser] = useState<User | null>(null)
|
|
36
|
+
const [sessionExpired, setSessionExpired] = useState(false)
|
|
33
37
|
|
|
34
38
|
useEffect(() => {
|
|
35
39
|
// Skip on server-side
|
|
@@ -38,8 +42,9 @@ export function ZitadelProvider({ children, config }: ZitadelProviderProps) {
|
|
|
38
42
|
const configure = async () => {
|
|
39
43
|
try {
|
|
40
44
|
ZitadelAuthService.configureAuth(config)
|
|
45
|
+
ZitadelAuthService.onSessionExpired = () => setSessionExpired(true)
|
|
41
46
|
setIsConfigured(true)
|
|
42
|
-
|
|
47
|
+
|
|
43
48
|
const currentUser = await ZitadelAuthService.getUser()
|
|
44
49
|
setUser(currentUser)
|
|
45
50
|
} catch (error) {
|
|
@@ -130,6 +135,11 @@ export function ZitadelProvider({ children, config }: ZitadelProviderProps) {
|
|
|
130
135
|
return (
|
|
131
136
|
<ZitadelContext.Provider value={value}>
|
|
132
137
|
{children}
|
|
138
|
+
{sessionExpired && (
|
|
139
|
+
<SessionExpiredModal
|
|
140
|
+
action={renderSessionExpiredAction?.(signIn)}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
133
143
|
</ZitadelContext.Provider>
|
|
134
144
|
)
|
|
135
145
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { ReactNode, useEffect, useState } from "react"
|
|
4
|
+
|
|
5
|
+
interface SessionExpiredModalProps {
|
|
6
|
+
/** The action element rendered at the bottom of the modal (e.g. a sign-in button). */
|
|
7
|
+
action: ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SessionExpiredModal({ action }: SessionExpiredModalProps) {
|
|
11
|
+
const [isDark, setIsDark] = useState(false)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const checkDarkMode = () => setIsDark(document.documentElement.classList.contains('dark'))
|
|
15
|
+
checkDarkMode()
|
|
16
|
+
const observer = new MutationObserver(checkDarkMode)
|
|
17
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
18
|
+
return () => observer.disconnect()
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
const cardBg = isDark ? 'var(--theme-dark-background-500)' : 'var(--theme-light-background-100)'
|
|
22
|
+
const borderColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
|
|
23
|
+
const textColor = isDark ? 'var(--theme-dark-text)' : 'var(--theme-light-text)'
|
|
24
|
+
const secondaryText = isDark ? 'var(--theme-dark-secondary-text)' : 'var(--theme-light-secondary-text)'
|
|
25
|
+
const warningBg = isDark ? 'rgba(250,163,0,0.1)' : 'rgba(250,163,0,0.08)'
|
|
26
|
+
const warningBorder = 'rgba(250,163,0,0.4)'
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
role="dialog"
|
|
31
|
+
aria-modal="true"
|
|
32
|
+
aria-labelledby="session-expired-title"
|
|
33
|
+
style={{
|
|
34
|
+
position: 'fixed',
|
|
35
|
+
inset: 0,
|
|
36
|
+
zIndex: 9999,
|
|
37
|
+
display: 'flex',
|
|
38
|
+
alignItems: 'center',
|
|
39
|
+
justifyContent: 'center',
|
|
40
|
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
41
|
+
backdropFilter: 'blur(2px)',
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<div
|
|
45
|
+
style={{
|
|
46
|
+
width: '440px',
|
|
47
|
+
backgroundColor: cardBg,
|
|
48
|
+
border: '1px solid',
|
|
49
|
+
borderColor,
|
|
50
|
+
padding: '2.5rem',
|
|
51
|
+
display: 'flex',
|
|
52
|
+
flexDirection: 'column',
|
|
53
|
+
alignItems: 'center',
|
|
54
|
+
gap: '1.25rem',
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{/* Icon */}
|
|
58
|
+
<div
|
|
59
|
+
style={{
|
|
60
|
+
width: '56px',
|
|
61
|
+
height: '56px',
|
|
62
|
+
display: 'flex',
|
|
63
|
+
alignItems: 'center',
|
|
64
|
+
justifyContent: 'center',
|
|
65
|
+
backgroundColor: warningBg,
|
|
66
|
+
border: '1px solid',
|
|
67
|
+
borderColor: warningBorder,
|
|
68
|
+
flexShrink: 0,
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<svg
|
|
72
|
+
width="28"
|
|
73
|
+
height="28"
|
|
74
|
+
viewBox="0 0 24 24"
|
|
75
|
+
fill="none"
|
|
76
|
+
stroke="#FAA300"
|
|
77
|
+
strokeWidth="1.5"
|
|
78
|
+
strokeLinecap="round"
|
|
79
|
+
strokeLinejoin="round"
|
|
80
|
+
>
|
|
81
|
+
<rect x="3" y="11" width="18" height="11" />
|
|
82
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
83
|
+
<circle cx="12" cy="16" r="1" fill="#FAA300" stroke="none" />
|
|
84
|
+
</svg>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{/* Title */}
|
|
88
|
+
<h2
|
|
89
|
+
id="session-expired-title"
|
|
90
|
+
style={{
|
|
91
|
+
margin: 0,
|
|
92
|
+
fontSize: '1.125rem',
|
|
93
|
+
fontWeight: 600,
|
|
94
|
+
color: textColor,
|
|
95
|
+
textAlign: 'center',
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
Session Expired
|
|
99
|
+
</h2>
|
|
100
|
+
|
|
101
|
+
{/* Description */}
|
|
102
|
+
<p
|
|
103
|
+
style={{
|
|
104
|
+
margin: 0,
|
|
105
|
+
fontSize: '0.875rem',
|
|
106
|
+
lineHeight: '1.6',
|
|
107
|
+
color: secondaryText,
|
|
108
|
+
textAlign: 'center',
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
Your session has expired and could not be renewed automatically.
|
|
112
|
+
Please sign in again to continue.
|
|
113
|
+
</p>
|
|
114
|
+
|
|
115
|
+
{/* Action slot — provided by the consumer */}
|
|
116
|
+
<div style={{ width: '100%' }}>
|
|
117
|
+
{action}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
package/components/index.ts
CHANGED
package/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Auth exports - Main app
|
|
2
2
|
export { ZitadelProvider, useZitadel } from './ZitadelProvider'
|
|
3
|
+
export type { ZitadelProviderProps } from './ZitadelProvider'
|
|
3
4
|
export { ProtectedRoute } from './ProtectedRoute'
|
|
4
5
|
export { ZitadelAuthService } from './services/zitadel.auth.service'
|
|
5
6
|
export { createAuthConfig } from './config/zitadel.config'
|
|
@@ -11,5 +12,6 @@ export { SessionProvider, useSession } from './SessionProvider'
|
|
|
11
12
|
// Components
|
|
12
13
|
export { AuthCard, AuthCardTitle, AuthCardDescription, AuthCardSpinner } from './components/AuthCard'
|
|
13
14
|
export { AuthCallbackError } from './components/AuthCallbackError'
|
|
15
|
+
export { SessionExpiredModal } from './components/SessionExpiredModal'
|
|
14
16
|
|
|
15
17
|
export * from './utils'
|
package/package.json
CHANGED
|
@@ -48,6 +48,22 @@ function createZitadelAuth(zitadelConfig: ZitadelConfig): ZitadelAuth {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
userManager.events.addAccessTokenExpired(() => {
|
|
52
|
+
console.warn(' Access token expired - triggering silent renew');
|
|
53
|
+
// Le renouvellement silencieux est géré automatiquement par automaticSilentRenew
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
userManager.events.addAccessTokenExpiring(() => {
|
|
57
|
+
console.log(' Access token expiring soon - preparing silent renew');
|
|
58
|
+
// Se déclenche environ 60 secondes avant l'expiration
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
userManager.events.addSilentRenewError((error) => {
|
|
62
|
+
console.error(' Silent renew failed:', error);
|
|
63
|
+
document.cookie = 'auth_session=; path=/; max-age=0';
|
|
64
|
+
ZitadelAuthService.onSessionExpired?.();
|
|
65
|
+
});
|
|
66
|
+
|
|
51
67
|
return {
|
|
52
68
|
authorize: () => userManager.signinRedirect(),
|
|
53
69
|
signout: () => userManager.signoutRedirect(),
|
|
@@ -57,6 +73,7 @@ function createZitadelAuth(zitadelConfig: ZitadelConfig): ZitadelAuth {
|
|
|
57
73
|
|
|
58
74
|
export class ZitadelAuthService {
|
|
59
75
|
private static zitadelAuth: ZitadelAuth | null = null;
|
|
76
|
+
static onSessionExpired: (() => void) | null = null;
|
|
60
77
|
|
|
61
78
|
static configureAuth(config: ZitadelConfig) {
|
|
62
79
|
this.zitadelAuth = createZitadelAuth(config);
|
|
@@ -134,6 +151,28 @@ export class ZitadelAuthService {
|
|
|
134
151
|
return user !== null && !user.expired;
|
|
135
152
|
}
|
|
136
153
|
|
|
154
|
+
static async getTokenRemainingTime(): Promise<number> {
|
|
155
|
+
const user = await this.getUser();
|
|
156
|
+
if (!user || !user.expires_at) return 0;
|
|
157
|
+
return Math.max(0, user.expires_at - Math.floor(Date.now() / 1000));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
static async isTokenExpiringSoon(secondsThreshold: number = 60): Promise<boolean> {
|
|
161
|
+
const remainingTime = await this.getTokenRemainingTime();
|
|
162
|
+
return remainingTime > 0 && remainingTime <= secondsThreshold;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
static async forceTokenRenew(): Promise<User | null> {
|
|
166
|
+
try {
|
|
167
|
+
const userManager = this.getUserManager();
|
|
168
|
+
// Force le renouvellement silencieux manuellement
|
|
169
|
+
return await userManager.signinSilent();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error('Manual token renew failed:', error);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
137
176
|
static getUserManager(): UserManager {
|
|
138
177
|
return this.getAuth().userManager;
|
|
139
178
|
}
|