Skip to content

Commit dd68c19

Browse files
feat(admin): セッション切れの再読み込みプロンプト + セクションの URL ハッシュ保持
Google ID トークンは約 1 時間で失効し、これまでは各ビューに 「Invalid ID token」が生のまま出るだけで不親切だった。 - API クライアント (admin / bug-report) は 401 で smalruby-admin:unauthorized をブロードキャスト - App はログイン済みのときだけ購読し、全画面の「セッションの有効期限が 切れました」オーバーレイ + 再読み込みボタンを表示 (ログイン時の 401 は従来どおり通常エラー) - 表示中セクションは URL ハッシュ (#/classrooms 等) に保持。 再読み込み → 再ログイン後に元のセクションへ復帰する (localStorage は使わない方針) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f9d769d commit dd68c19

7 files changed

Lines changed: 164 additions & 2 deletions

File tree

docs/admin/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
- **deny-by-default 許可リスト**: `SmalrubyAdmins` テーブル(PK: email、RETAIN)への存在照合のみ。登録は AWS コンソール手動操作が唯一の経路(アプリ内に管理者管理 UI は無い・F4)
2727
- **sub 固定**: 初回ログインで Google `sub` を行に固定。以後 email 一致でも sub 不一致は 403(email 再利用防御)
2828
- トークンは**モジュールメモリのみ**(localStorage に保存しない・N1)。リロード時は再ログイン
29+
- **セッション切れ(約 1 時間)**: API が 401 を返すと全画面の再読み込みプロンプトを表示。表示中のセクションは **URL ハッシュ**`#/classrooms` 等)に保持しているため、再読み込み → 再ログイン後に元のセクションへ復帰する(localStorage 不使用)
2930
- すべての管理操作は `audit()` 構造化ログ(prod の CloudWatch 保持は **1 年**
3031
- バグ報告の閲覧は bug-report 側の既存レジストリ `BugReportAdmins`(email)で認可される(両方への登録が必要)
3132

packages/admin/src/components/app.css

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,33 @@ body {
220220
white-space: pre-wrap;
221221
word-break: break-all;
222222
}
223+
224+
.admin-session-expired {
225+
position: fixed;
226+
inset: 0;
227+
display: flex;
228+
align-items: center;
229+
justify-content: center;
230+
background: hsla(220, 30%, 20%, 0.55);
231+
z-index: 100;
232+
}
233+
234+
.admin-session-expired-card {
235+
max-width: 26rem;
236+
padding: 1.5rem;
237+
border-radius: 0.6rem;
238+
background: white;
239+
box-shadow: 0 8px 24px hsla(220, 30%, 20%, 0.25);
240+
text-align: center;
241+
}
242+
243+
.admin-session-expired-card button {
244+
margin-top: 1rem;
245+
padding: 0.5rem 1.2rem;
246+
border: none;
247+
border-radius: 0.4rem;
248+
background: hsl(260, 60%, 55%);
249+
color: white;
250+
font-size: 1rem;
251+
cursor: pointer;
252+
}

packages/admin/src/components/app.jsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
2028
const 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
}

packages/admin/src/lib/admin-api.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ const request = async (method, path, body) => {
4747
if (response.status === 204) return null;
4848
const data = await response.json().catch(() => ({}));
4949
if (!response.ok) {
50+
// Google ID tokens expire after ~1 hour: broadcast 401s so the app
51+
// shell can prompt for a reload (no listener is attached while the
52+
// login probe itself runs, so a bad first login stays a plain error).
53+
if (response.status === 401 && typeof window !== 'undefined') {
54+
window.dispatchEvent(new Event('smalruby-admin:unauthorized'));
55+
}
5056
const error = new Error(data.error || `API error ${response.status}`);
5157
error.status = response.status;
5258
throw error;

packages/admin/src/lib/bug-report-api.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const get = async path => {
2424
});
2525
const data = await response.json().catch(() => ({}));
2626
if (!response.ok) {
27+
// Same session-expiry broadcast as admin-api (the token is shared).
28+
if (response.status === 401 && typeof window !== 'undefined') {
29+
window.dispatchEvent(new Event('smalruby-admin:unauthorized'));
30+
}
2731
const error = new Error(data.error || `API error ${response.status}`);
2832
error.status = response.status;
2933
throw error;

packages/admin/test/unit/admin-api.test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,38 @@ describe('admin-api', () => {
4848
expect(await request('DELETE', '/admin/whatever')).toBeNull();
4949
});
5050
});
51+
52+
describe('session expiry broadcast (401)', () => {
53+
beforeEach(() => {
54+
setIdToken('token-1');
55+
global.fetch = jest.fn();
56+
});
57+
58+
test('a 401 dispatches smalruby-admin:unauthorized before rejecting', async () => {
59+
global.fetch.mockResolvedValue({
60+
ok: false,
61+
status: 401,
62+
json: () => Promise.resolve({error: 'Invalid ID token'})
63+
});
64+
const listener = jest.fn();
65+
window.addEventListener('smalruby-admin:unauthorized', listener);
66+
67+
await expect(request('GET', '/admin/me')).rejects.toMatchObject({status: 401});
68+
expect(listener).toHaveBeenCalledTimes(1);
69+
window.removeEventListener('smalruby-admin:unauthorized', listener);
70+
});
71+
72+
test('non-401 errors do not broadcast', async () => {
73+
global.fetch.mockResolvedValue({
74+
ok: false,
75+
status: 403,
76+
json: () => Promise.resolve({error: 'Not an administrator'})
77+
});
78+
const listener = jest.fn();
79+
window.addEventListener('smalruby-admin:unauthorized', listener);
80+
81+
await expect(request('GET', '/admin/me')).rejects.toMatchObject({status: 403});
82+
expect(listener).not.toHaveBeenCalled();
83+
window.removeEventListener('smalruby-admin:unauthorized', listener);
84+
});
85+
});

packages/admin/test/unit/app.test.jsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,49 @@ describe('App auth flow', () => {
6363
await waitFor(() => expect(screen.getByTestId('admin-error')).toHaveTextContent('boom'));
6464
});
6565

66+
test('the initial section is restored from the URL hash (session-expiry reload)', async () => {
67+
window.location.hash = '#/classrooms';
68+
mockGetDevLoginToken.mockReturnValue('dev-token');
69+
mockFetchMe.mockResolvedValue({email: 'admin@example.com', name: null, stage: 'stg'});
70+
71+
render(<App />);
72+
await waitFor(() => expect(screen.getByTestId('admin-dashboard')).toBeInTheDocument());
73+
expect(screen.getByRole('heading', {level: 2})).toHaveTextContent('クラス・課題');
74+
window.location.hash = '';
75+
});
76+
77+
test('an expired session (API 401) shows the reload prompt instead of raw errors', async () => {
78+
mockGetDevLoginToken.mockReturnValue('dev-token');
79+
mockFetchMe.mockResolvedValue({email: 'admin@example.com', name: null, stage: 'stg'});
80+
81+
render(<App />);
82+
await waitFor(() => expect(screen.getByTestId('admin-dashboard')).toBeInTheDocument());
83+
expect(screen.queryByTestId('admin-session-expired')).not.toBeInTheDocument();
84+
85+
// Any API client broadcasts this on 401 (token lifetime ~1 hour).
86+
fireEvent(window, new Event('smalruby-admin:unauthorized'));
87+
expect(screen.getByTestId('admin-session-expired')).toBeInTheDocument();
88+
expect(screen.getByTestId('admin-session-expired').textContent).toContain('有効期限');
89+
90+
const reloadMock = jest.fn();
91+
const original = window.location;
92+
delete window.location;
93+
window.location = {...original, reload: reloadMock};
94+
fireEvent.click(screen.getByTestId('admin-session-reload'));
95+
expect(reloadMock).toHaveBeenCalled();
96+
window.location = original;
97+
});
98+
99+
test('a 401 before authorization does NOT trigger the session overlay', async () => {
100+
mockGetDevLoginToken.mockReturnValue('dev-token');
101+
mockFetchMe.mockRejectedValue(Object.assign(new Error('Invalid ID token'), {status: 401}));
102+
103+
render(<App />);
104+
await waitFor(() => expect(screen.getByTestId('admin-error')).toBeInTheDocument());
105+
fireEvent(window, new Event('smalruby-admin:unauthorized'));
106+
expect(screen.queryByTestId('admin-session-expired')).not.toBeInTheDocument();
107+
});
108+
66109
test('the section nav switches between management views (issue #1084)', async () => {
67110
mockGetDevLoginToken.mockReturnValue('dev-token');
68111
mockFetchMe.mockResolvedValue({email: 'admin@example.com', name: null, stage: 'stg'});

0 commit comments

Comments
 (0)