@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.
@@ -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
+ }
@@ -1,2 +1,3 @@
1
1
  export { AuthCard, AuthCardTitle, AuthCardDescription, AuthCardSpinner } from './AuthCard'
2
2
  export { AuthCallbackError } from './AuthCallbackError'
3
+ export { SessionExpiredModal } from './SessionExpiredModal'
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-auth",
3
- "version": "1.1.0",
3
+ "version": "1.1.3",
4
4
  "description": "Shared authentication utilities for Orsetra platform using Zitadel",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",
@@ -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
  }