@@ -16,15 +16,40 @@ const SECTIONS = [
1616 { key : 'bug-reports' , label : 'バグ報告' }
1717] ;
1818
19+ // The current section lives in the URL hash (#/classrooms) so a full reload
20+ // — including the one we prompt for on session expiry — lands the operator
21+ // back on the same section after re-login. Deliberately not localStorage.
22+ const sectionFromHash = ( ) => {
23+ const key = window . location . hash . replace ( / ^ # \/ ? / , '' ) ;
24+ return SECTIONS . some ( s => s . key === key ) ? key : 'shared' ;
25+ } ;
26+
1927// signed-out → checking → authorized | forbidden | error
2028const App = ( ) => {
2129 const [ phase , setPhase ] = useState ( 'signed-out' ) ;
2230 const [ me , setMe ] = useState ( null ) ;
2331 const [ errorMessage , setErrorMessage ] = useState ( '' ) ;
24- const [ section , setSection ] = useState ( 'shared' ) ;
32+ const [ section , setSection ] = useState ( sectionFromHash ) ;
33+ const [ sessionExpired , setSessionExpired ] = useState ( false ) ;
2534 const buttonRef = useRef ( null ) ;
2635
27- const handleSection = useCallback ( e => setSection ( e . currentTarget . dataset . section ) , [ ] ) ;
36+ const handleSection = useCallback ( e => {
37+ const key = e . currentTarget . dataset . section ;
38+ setSection ( key ) ;
39+ window . history . replaceState ( null , '' , `#/${ key } ` ) ;
40+ } , [ ] ) ;
41+
42+ const handleReload = useCallback ( ( ) => window . location . reload ( ) , [ ] ) ;
43+
44+ // Google ID tokens live ~1 hour and the SPA keeps them in memory only, so
45+ // an expired session surfaces as API 401s. The clients broadcast those;
46+ // show one clear prompt instead of per-view raw errors.
47+ useEffect ( ( ) => {
48+ if ( phase !== 'authorized' ) return ( ) => { } ;
49+ const handleUnauthorized = ( ) => setSessionExpired ( true ) ;
50+ window . addEventListener ( 'smalruby-admin:unauthorized' , handleUnauthorized ) ;
51+ return ( ) => window . removeEventListener ( 'smalruby-admin:unauthorized' , handleUnauthorized ) ;
52+ } , [ phase ] ) ;
2853
2954 const handleCredential = useCallback ( async token => {
3055 setIdToken ( token ) ;
@@ -89,6 +114,24 @@ const App = () => {
89114 { section === 'classrooms' && < ClassroomsView /> }
90115 { section === 'bug-reports' && < BugReportsView /> }
91116 </ main >
117+ { sessionExpired && (
118+ < div
119+ className = "admin-session-expired"
120+ data-testid = "admin-session-expired"
121+ >
122+ < div className = "admin-session-expired-card" >
123+ < p >
124+ { 'セッションの有効期限が切れました(ログインから約 1 時間)。' }
125+ { '再読み込みして、もう一度ログインしてください。いまのページに戻ります。' }
126+ </ p >
127+ < button
128+ data-testid = "admin-session-reload"
129+ type = "button"
130+ onClick = { handleReload }
131+ > { '再読み込みしてログイン' } </ button >
132+ </ div >
133+ </ div >
134+ ) }
92135 </ div >
93136 ) ;
94137 }
0 commit comments