@geniee-inc/sfa-kit 1.14.0 → 1.14.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geniee-inc/sfa-kit",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "React hooks and components for building pages that connect to GENIEE SFA/CRM data.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -1,22 +1,16 @@
1
1
  import { __awaiter } from "tslib";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useMemo, useState } from 'react';
4
4
  import { SfaProvider } from '../provider/SfaProvider';
5
5
  import { color, font, radius, space, baseContainerStyle, baseButtonStyle, } from '../theme';
6
6
  import { SFA_KIT_VERSION } from '../version';
7
7
  import { DevToolbar } from './DevToolbar';
8
- import { parseInboundMessage, sendToHost } from './embed-protocol';
9
- import { exchangeCode } from './exchange-code';
10
8
  import { authenticate } from './keystone-auth';
11
9
  import { KEYSTONE_PRODUCTION_API_BASE, resolveApiBase, } from './resolve-api-base';
12
10
  import { loadSession, saveSession, clearSession, loadLastOrgId, } from './session-store';
13
11
  import { StandalonePreviewCtx } from './StandalonePreviewContext';
14
12
  import { EmbedContextValue } from './useEmbedContext';
15
- const REFRESH_BUFFER_SECONDS = 120;
16
- /** After this many consecutive exchange failures, stop retrying and show session_expired. */
17
- const MAX_CONSECUTIVE_FAILURES = 3;
18
- /** Seconds to wait for the host to send a code before showing a connection error. */
19
- const CODE_TIMEOUT_SECONDS = 15;
13
+ import { useEmbedSession } from './useEmbedSession';
20
14
  /**
21
15
  * @description Returns true when the page is running inside an iframe.
22
16
  */
@@ -93,103 +87,35 @@ function resolvePageOrigin() {
93
87
  export function SfaLoginGate({ apiBase, trustedOrigin, defaultOrganizationId, objectKey, level, children, }) {
94
88
  const shouldEmbed = isEmbedded() && (trustedOrigin != null || hasContentToken());
95
89
  const resolvedOrigin = resolveTrustedOrigin(trustedOrigin);
96
- if (shouldEmbed && resolvedOrigin != null) {
90
+ if (shouldEmbed) {
91
+ // ホストオリジンを 1 つに決められない状態。postMessage の宛先も
92
+ // event.origin の照合相手も決まらないため、埋め込みの手順自体が成立
93
+ // しない。ここでログインフォームに落とすと、どの frame の中にいるか
94
+ // 分からないまま資格情報の入力欄を出すことになるので、描画せずに
95
+ // 理由だけ表示して止める。
96
+ //
97
+ // NOTE: resolveTrustedOrigin は trustedOrigin 未指定時に document.referrer
98
+ // を使う。referrer は埋め込む側が決める値なので、下の EmbedFlow の
99
+ // 経路は「埋め込み元の自己申告を信じる」ことになる。ホストを実際に
100
+ // 限定できるのは trustedOrigin を明示した場合だけ。
101
+ if (resolvedOrigin == null) {
102
+ return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u3067\u304D\u307E\u305B\u3093", message: "SFA/CRM \u306E\u30DB\u30B9\u30C8\u30AA\u30EA\u30B8\u30F3\u3092\u78BA\u8A8D\u3067\u304D\u306A\u3044\u305F\u3081\u3001\u3053\u306E\u30DA\u30FC\u30B8\u3092\u8868\u793A\u3067\u304D\u307E\u305B\u3093\u3002\u57CB\u3081\u8FBC\u307F\u8A2D\u5B9A\u3092\u7BA1\u7406\u8005\u306B\u3054\u78BA\u8A8D\u304F\u3060\u3055\u3044\u3002" }));
103
+ }
97
104
  // Embedded: the SFA/CRM host that framed us decides the environment.
98
105
  return (_jsx(EmbedFlow, { trustedOrigin: resolvedOrigin, apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvedOrigin), children: children }));
99
106
  }
100
107
  // Standalone: there is no host, so the page's own origin decides.
101
108
  return (_jsx(StandaloneFlow, { apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvePageOrigin()), defaultOrganizationId: defaultOrganizationId, objectKey: objectKey, level: level, children: children }));
102
109
  }
110
+ // ---------------------------------------------------------------------------
111
+ // Embed flow (iframe + postMessage)
112
+ // ---------------------------------------------------------------------------
103
113
  function EmbedFlow({ trustedOrigin, apiBase, children, }) {
104
- const [phase, setPhase] = useState({ step: 'waiting_for_code' });
105
- const refreshTimerRef = useRef(null);
106
- const exchangingRef = useRef(false);
107
- const failureCountRef = useRef(0);
108
114
  const normalizedBase = useMemo(() => apiBase.replace(/\/+$/, ''), [apiBase]);
109
- const clearRefreshTimer = useCallback(() => {
110
- if (refreshTimerRef.current != null) {
111
- clearTimeout(refreshTimerRef.current);
112
- refreshTimerRef.current = null;
113
- }
114
- }, []);
115
- const scheduleRefresh = useCallback((expiresInSeconds) => {
116
- clearRefreshTimer();
117
- const refreshAfterMs = (expiresInSeconds - REFRESH_BUFFER_SECONDS) * 1000;
118
- if (refreshAfterMs <= 0) {
119
- sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
120
- return;
121
- }
122
- refreshTimerRef.current = setTimeout(() => {
123
- sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
124
- }, refreshAfterMs);
125
- }, [trustedOrigin, clearRefreshTimer]);
126
- const handleCodeReceived = useCallback((code, context) => __awaiter(this, void 0, void 0, function* () {
127
- if (exchangingRef.current)
128
- return;
129
- exchangingRef.current = true;
130
- setPhase({ step: 'exchanging' });
131
- try {
132
- const session = yield exchangeCode(normalizedBase, code);
133
- failureCountRef.current = 0;
134
- setPhase({ step: 'authenticated', session, context });
135
- const expiresInSeconds = Math.max(0, Math.floor((session.expiresAt - Date.now()) / 1000));
136
- scheduleRefresh(expiresInSeconds);
137
- }
138
- catch (thrown) {
139
- failureCountRef.current += 1;
140
- if (failureCountRef.current >= MAX_CONSECUTIVE_FAILURES) {
141
- setPhase({ step: 'session_expired' });
142
- }
143
- else {
144
- const message = thrown instanceof Error ? thrown.message : 'Code exchange failed';
145
- setPhase({ step: 'error', message });
146
- }
147
- }
148
- finally {
149
- exchangingRef.current = false;
150
- }
151
- }), [normalizedBase, scheduleRefresh]);
152
- const handleAuthError = useCallback(() => {
153
- clearRefreshTimer();
154
- failureCountRef.current += 1;
155
- if (failureCountRef.current >= MAX_CONSECUTIVE_FAILURES) {
156
- setPhase({ step: 'session_expired' });
157
- return;
158
- }
159
- sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
160
- setPhase({ step: 'waiting_for_code' });
161
- }, [trustedOrigin, clearRefreshTimer]);
162
- useEffect(() => {
163
- const handleMessage = (event) => {
164
- var _a;
165
- const parsed = parseInboundMessage(event, trustedOrigin);
166
- if (!parsed)
167
- return;
168
- if (parsed.type === 'sfa-kit:code') {
169
- void handleCodeReceived(parsed.code, (_a = parsed.context) !== null && _a !== void 0 ? _a : null);
170
- }
171
- };
172
- window.addEventListener('message', handleMessage);
173
- sendToHost({ type: 'sfa-kit:ready' }, trustedOrigin);
174
- // If the host never sends a code (e.g. mintSfaKitExchangeCode is not
175
- // implemented or the host page doesn't support the embed protocol),
176
- // show an error instead of hanging on "Connecting..." forever.
177
- const codeTimeout = setTimeout(() => {
178
- setPhase(current => {
179
- if (current.step !== 'waiting_for_code')
180
- return current;
181
- return {
182
- step: 'error',
183
- message: 'SFA/CRM との接続がタイムアウトしました。ページを再読み込みしてください。',
184
- };
185
- });
186
- }, CODE_TIMEOUT_SECONDS * 1000);
187
- return () => {
188
- window.removeEventListener('message', handleMessage);
189
- clearTimeout(codeTimeout);
190
- clearRefreshTimer();
191
- };
192
- }, [trustedOrigin, handleCodeReceived, clearRefreshTimer]);
115
+ const { phase, handleAuthError } = useEmbedSession({
116
+ apiBase: normalizedBase,
117
+ trustedOrigin,
118
+ });
193
119
  const auth = useMemo(() => {
194
120
  if (phase.step !== 'authenticated')
195
121
  return null;
@@ -199,7 +125,10 @@ function EmbedFlow({ trustedOrigin, apiBase, children, }) {
199
125
  return (_jsx(EmbedStatus, { title: "\u30BB\u30C3\u30B7\u30E7\u30F3\u671F\u9650\u5207\u308C", message: "SFA/CRM \u306E\u30BB\u30C3\u30B7\u30E7\u30F3\u304C\u7D42\u4E86\u3057\u307E\u3057\u305F\u3002\u30DA\u30FC\u30B8\u3092\u518D\u8AAD\u307F\u8FBC\u307F\u3057\u3066\u304F\u3060\u3055\u3044\u3002", showReload: true }));
200
126
  }
201
127
  if (phase.step === 'error') {
202
- return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u30A8\u30E9\u30FC", message: phase.message, showReload: true }));
128
+ return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u30A8\u30E9\u30FC", message: phase.message, showReload: phase.canRecoverByReload }));
129
+ }
130
+ if (phase.step === 'reconnecting') {
131
+ return (_jsx(EmbedStatus, { title: "\u518D\u63A5\u7D9A\u4E2D...", message: "SFA/CRM \u3078\u306E\u63A5\u7D9A\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u81EA\u52D5\u7684\u306B\u518D\u8A66\u884C\u3057\u3066\u3044\u307E\u3059\u3002" }));
203
132
  }
204
133
  if (phase.step === 'waiting_for_code' || phase.step === 'exchanging') {
205
134
  return (_jsx(EmbedStatus, { title: phase.step === 'exchanging' ? '認証中...' : 'SFA/CRM に接続中...', message: "\u30DB\u30B9\u30C8\u30A2\u30D7\u30EA\u30B1\u30FC\u30B7\u30E7\u30F3\u304B\u3089\u306E\u5FDC\u7B54\u3092\u5F85\u3063\u3066\u3044\u307E\u3059\u3002" }));
@@ -212,7 +141,7 @@ function EmbedStatus({ title, message, showReload, }) {
212
141
  const handleReload = useCallback(() => {
213
142
  window.location.reload();
214
143
  }, []);
215
- return (_jsx("div", { style: styles.backdrop, children: _jsxs("div", { style: Object.assign(Object.assign({}, styles.card), { textAlign: 'center' }), children: [_jsxs("div", { style: loginStyles.logoGroup, children: [_jsx(GenieeLogoMark, {}), _jsxs("div", { style: loginStyles.logoText, children: [_jsx("span", { style: loginStyles.logoGeniee, children: "GENIEE" }), _jsx("span", { style: loginStyles.logoProduct, children: "SFA/CRM" })] })] }), _jsx("h2", { style: styles.title, children: title }), _jsx("p", { style: styles.subtitle, children: message }), showReload && (_jsx("button", { type: "button", style: styles.reloadButton, onClick: handleReload, children: "\u30DA\u30FC\u30B8\u3092\u518D\u8AAD\u307F\u8FBC\u307F" })), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }) }));
144
+ return (_jsx("div", { style: styles.backdrop, children: _jsxs("div", { role: "status", style: Object.assign(Object.assign({}, styles.card), { textAlign: 'center' }), children: [_jsxs("div", { style: loginStyles.logoGroup, children: [_jsx(GenieeLogoMark, {}), _jsxs("div", { style: loginStyles.logoText, children: [_jsx("span", { style: loginStyles.logoGeniee, children: "GENIEE" }), _jsx("span", { style: loginStyles.logoProduct, children: "SFA/CRM" })] })] }), _jsx("h2", { style: styles.title, children: title }), _jsx("p", { style: styles.subtitle, children: message }), showReload && (_jsx("button", { type: "button", style: styles.reloadButton, onClick: handleReload, children: "\u30DA\u30FC\u30B8\u3092\u518D\u8AAD\u307F\u8FBC\u307F" })), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }) }));
216
145
  }
217
146
  // ---------------------------------------------------------------------------
218
147
  // Standalone flow (login form + DevToolbar)
@@ -0,0 +1,49 @@
1
+ import type { EmbedContext } from './embed-protocol';
2
+ import type { SfaSession } from './types';
3
+ export type EmbedPhase = {
4
+ step: 'waiting_for_code';
5
+ } | {
6
+ step: 'exchanging';
7
+ } | {
8
+ step: 'authenticated';
9
+ session: SfaSession;
10
+ context: EmbedContext | null;
11
+ }
12
+ /** 交換に失敗したが、まだ試行回数が残っている状態。自力で復帰しうる。 */
13
+ | {
14
+ step: 'reconnecting';
15
+ } | {
16
+ step: 'session_expired';
17
+ }
18
+ /**
19
+ * 打ち切って画面を差し替えた状態。canRecoverByReload は再読み込みで
20
+ * 直りうるかどうか。設定の誤りのように直らない失敗で再読み込みを勧めると、
21
+ * 利用者は同じ操作を繰り返すことになる。
22
+ */
23
+ | {
24
+ step: 'error';
25
+ message: string;
26
+ canRecoverByReload: boolean;
27
+ };
28
+ export interface EmbedSessionState {
29
+ readonly phase: EmbedPhase;
30
+ /** Passed to SfaProvider so a 401 triggers the code-request fallback. */
31
+ readonly handleAuthError: () => void;
32
+ }
33
+ /**
34
+ * @description Keeps an embedded page authenticated over the postMessage
35
+ * code-exchange protocol: requests a code on mount, swaps the token before it
36
+ * expires, and falls back to a fresh code on 401.
37
+ *
38
+ * トークンを差し替えている間も phase は `authenticated` のまま据え置く。
39
+ * 状態画面に切り替えると子ツリーがアンマウントされ、埋め込みページの入力内容と
40
+ * React の状態が失われるため(authentication.md 条件 1)。
41
+ *
42
+ * 再要求は毎回 backoff 付きの見張りを伴う。手元のトークンが有効なうちは
43
+ * 諦めず、失効して規定回数を試し切った時点でセッション切れとして扱う
44
+ * (同 条件 3)。打ち切った後は isSettledRef で以降の遷移を止める。
45
+ */
46
+ export declare function useEmbedSession({ apiBase, trustedOrigin, }: {
47
+ readonly apiBase: string;
48
+ readonly trustedOrigin: string;
49
+ }): EmbedSessionState;
@@ -0,0 +1,404 @@
1
+ import { __awaiter } from "tslib";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { parseInboundMessage, sendToHost } from './embed-protocol';
4
+ import { exchangeCode, ExchangeError } from './exchange-code';
5
+ /** How many seconds before expiry a fresh code is requested from the host. */
6
+ const REFRESH_BUFFER_SECONDS = 120;
7
+ /**
8
+ * Fraction of the remaining lifetime used for the refresh timer when a token
9
+ * lives for less than REFRESH_BUFFER_SECONDS. A flat floor would only set the
10
+ * period of a permanent re-exchange loop, not stop it.
11
+ */
12
+ const SHORT_TOKEN_REFRESH_RATIO = 0.5;
13
+ /** Lower bound for the refresh timer. */
14
+ const MIN_REFRESH_DELAY_SECONDS = 5;
15
+ /** First wait before asking the host for a code again. */
16
+ const CODE_RETRY_BASE_SECONDS = 5;
17
+ /** Upper bound for the backoff between code requests. */
18
+ const CODE_RETRY_MAX_SECONDS = 20;
19
+ /** Seconds to wait for the host's first code before showing a connection error. */
20
+ const CODE_TIMEOUT_SECONDS = 15;
21
+ /** Consecutive failed attempts to obtain a usable session before giving up. */
22
+ const MAX_RECOVERY_ATTEMPTS = 4;
23
+ /**
24
+ * A 401 landing within this window of the previous one belongs to the same
25
+ * recovery run. Without it, a token Keystone happily mints but the API keeps
26
+ * rejecting loops forever, because every exchange succeeds and resets counters.
27
+ */
28
+ const AUTH_ERROR_WINDOW_SECONDS = 30;
29
+ /** 401-triggered recovery cycles allowed inside AUTH_ERROR_WINDOW_SECONDS. */
30
+ const MAX_AUTH_ERROR_CYCLES = 3;
31
+ /** An exchange still unsettled after this long is treated as abandoned. */
32
+ const STALE_EXCHANGE_SECONDS = 20;
33
+ /**
34
+ * Statuses below 500 that are still worth retrying. Every attempt asks the host
35
+ * for a fresh code, so a rejected code (401) can succeed on the next one.
36
+ * Everything else in the 4xx range (wrong apiBase, malformed request) fails
37
+ * identically however many times it is tried.
38
+ */
39
+ const RETRYABLE_CLIENT_STATUSES = new Set([401, 408, 425, 429]);
40
+ /**
41
+ * @description Whether asking for another code could plausibly succeed.
42
+ * Non-ExchangeError throws are network faults and aborts, which are transient.
43
+ */
44
+ function isTransientExchangeFailure(thrown) {
45
+ if (!(thrown instanceof ExchangeError))
46
+ return true;
47
+ // 200 なのに本文が欠けていた場合。ゲートウェイが一度だけ返すことがあるので、
48
+ // 本文が JSON として壊れていた場合(SyntaxError)と同じ扱いにする。
49
+ if (thrown.httpStatus === 0)
50
+ return true;
51
+ if (thrown.httpStatus >= 500)
52
+ return true;
53
+ return RETRYABLE_CLIENT_STATUSES.has(thrown.httpStatus);
54
+ }
55
+ const CONNECT_FAILED_MESSAGE = 'SFA/CRM に接続できませんでした。ページを再読み込みしてください。';
56
+ /**
57
+ * 何度試しても同じ結果になる失敗。再読み込みでは解消しないため、
58
+ * CONNECT_FAILED_MESSAGE とは別の案内を出す。
59
+ */
60
+ const PERMANENT_FAILURE_MESSAGE = 'SFA/CRM に接続できませんでした。埋め込み設定を管理者にご確認ください。';
61
+ /**
62
+ * @description 交換の失敗理由をコンソールに残す。画面には出さない。
63
+ * ExchangeError の message は応答本文をそのまま含むため、日本語の案内画面に
64
+ * 流すと利用者に読めない内容が出るうえ、カードの幅を越えて崩れる。
65
+ */
66
+ function logExchangeFailure(thrown) {
67
+ // eslint-disable-next-line no-console
68
+ console.warn('[sfa-kit] code exchange failed:', thrown);
69
+ }
70
+ /**
71
+ * @description Keeps an embedded page authenticated over the postMessage
72
+ * code-exchange protocol: requests a code on mount, swaps the token before it
73
+ * expires, and falls back to a fresh code on 401.
74
+ *
75
+ * トークンを差し替えている間も phase は `authenticated` のまま据え置く。
76
+ * 状態画面に切り替えると子ツリーがアンマウントされ、埋め込みページの入力内容と
77
+ * React の状態が失われるため(authentication.md 条件 1)。
78
+ *
79
+ * 再要求は毎回 backoff 付きの見張りを伴う。手元のトークンが有効なうちは
80
+ * 諦めず、失効して規定回数を試し切った時点でセッション切れとして扱う
81
+ * (同 条件 3)。打ち切った後は isSettledRef で以降の遷移を止める。
82
+ */
83
+ export function useEmbedSession({ apiBase, trustedOrigin, }) {
84
+ const [phase, setPhase] = useState({ step: 'waiting_for_code' });
85
+ const refreshTimerRef = useRef(null);
86
+ const retryTimerRef = useRef(null);
87
+ const exchangingRef = useRef(false);
88
+ const exchangeStartedAtRef = useRef(0);
89
+ /** 追い越された交換の結果で新しいセッションを上書きしないための世代番号。 */
90
+ const exchangeGenerationRef = useRef(0);
91
+ /**
92
+ * 直近に交換できたセッション。トークンがまだ有効かどうかの判定に使う。
93
+ * 401 を受け取った時点で null に戻す(期限内でもそのトークンは使えない)。
94
+ */
95
+ const sessionRef = useRef(null);
96
+ /** 使えるセッションを得られなかった連続回数。交換成功で 0 に戻す。 */
97
+ const recoveryAttemptsRef = useRef(0);
98
+ const authErrorCyclesRef = useRef(0);
99
+ /** 連鎖の起点。最後の 1 件ではなく最初の 1 件から窓を測る。 */
100
+ const authErrorRunStartedAtRef = useRef(0);
101
+ /**
102
+ * 401 を受けてコードを要求済みかどうか。SfaClient は 401 を返した
103
+ * リクエストごとに onAuthError を呼ぶため、並行リクエストの分をまとめる。
104
+ */
105
+ const isAuthErrorPendingRef = useRef(false);
106
+ const hasEverAuthenticatedRef = useRef(false);
107
+ /** 同じ復帰の試行でコンソールに書いたかどうか。交換成功で false に戻す。 */
108
+ const hasLoggedExchangeFailureRef = useRef(false);
109
+ /** 打ち切り済み。以降は再要求も画面遷移もしない。 */
110
+ const isSettledRef = useRef(false);
111
+ const clearRefreshTimer = useCallback(() => {
112
+ if (refreshTimerRef.current != null) {
113
+ clearTimeout(refreshTimerRef.current);
114
+ refreshTimerRef.current = null;
115
+ }
116
+ }, []);
117
+ const clearRetryTimer = useCallback(() => {
118
+ if (retryTimerRef.current != null) {
119
+ clearTimeout(retryTimerRef.current);
120
+ retryTimerRef.current = null;
121
+ }
122
+ }, []);
123
+ const isTokenUsable = useCallback(() => {
124
+ const session = sessionRef.current;
125
+ return session != null && session.expiresAt > Date.now();
126
+ }, []);
127
+ /** 打ち切って画面を差し替える。手元に使えるものが何も残っていないとき。 */
128
+ const settle = useCallback((next) => {
129
+ isSettledRef.current = true;
130
+ // 進行中の交換を無効化する。これが無いと、打ち切った後に解決した交換が
131
+ // authenticated を書き戻し、二度と操作できない画面が残る。
132
+ exchangeGenerationRef.current += 1;
133
+ clearRefreshTimer();
134
+ clearRetryTimer();
135
+ setPhase(next);
136
+ }, [clearRefreshTimer, clearRetryTimer]);
137
+ /**
138
+ * 状態画面は子ツリーがまだ描画されていないときだけ出す。認証済みの表示中は
139
+ * phase を動かさず、ページの状態をそのまま残す。打ち切り後も動かさない。
140
+ */
141
+ const showStatusIfNotMounted = useCallback((next) => {
142
+ if (isSettledRef.current)
143
+ return;
144
+ setPhase(current => (current.step === 'authenticated' ? current : next));
145
+ }, []);
146
+ /**
147
+ * 交換中であることを示す。すでに再接続中の表示なら据え置く。再試行のたびに
148
+ * 「認証中...」と「再接続中...」が入れ替わると、見出しが点滅して見えるため。
149
+ */
150
+ const showExchangingIfNotMounted = useCallback(() => {
151
+ if (isSettledRef.current)
152
+ return;
153
+ setPhase(current => current.step === 'authenticated' || current.step === 'reconnecting'
154
+ ? current
155
+ : { step: 'exchanging' });
156
+ }, []);
157
+ const sendCodeRequest = useCallback(() => {
158
+ sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
159
+ }, [trustedOrigin]);
160
+ /** 打ち切り後にトークンが切れた時点で next に落とすためのタイマー。 */
161
+ const scheduleExpiryCutoff = useCallback((next) => {
162
+ clearRefreshTimer();
163
+ const session = sessionRef.current;
164
+ if (session == null)
165
+ return;
166
+ refreshTimerRef.current = setTimeout(() => {
167
+ refreshTimerRef.current = null;
168
+ setPhase(next);
169
+ }, Math.max(0, session.expiresAt - Date.now()));
170
+ }, [clearRefreshTimer]);
171
+ /**
172
+ * 打ち切る。手元のトークンがまだ使えるなら描画済みの子ツリーを残し、
173
+ * 失効した時点で next に切り替える。使えるものが無ければその場で next を出す。
174
+ *
175
+ * next は打ち切った理由をそのまま表す。設定の誤りで再交換が詰んだ場合に
176
+ * 失効を待って「セッション期限切れ」を出すと、原因の見当がつかなくなる。
177
+ *
178
+ * トークンの有無の判定はここに閉じる。呼び出し側で分岐させると、据え置きの
179
+ * 経路だけを通って失効タイマーの無いまま固まる呼び方ができてしまう。
180
+ */
181
+ const stopRetrying = useCallback((next) => {
182
+ if (!isTokenUsable()) {
183
+ settle(next);
184
+ return;
185
+ }
186
+ isSettledRef.current = true;
187
+ exchangeGenerationRef.current += 1;
188
+ clearRetryTimer();
189
+ setPhase(current => (current.step === 'authenticated' ? current : next));
190
+ scheduleExpiryCutoff(next);
191
+ }, [clearRetryTimer, isTokenUsable, scheduleExpiryCutoff, settle]);
192
+ // 見張りは自分自身を再武装する。useCallback は自己参照できないため ref 経由。
193
+ const armCodeRetryRef = useRef(() => undefined);
194
+ /**
195
+ * コード要求に応答が無かった場合に備えて次の要求を仕込む。間隔は指数的に
196
+ * 広げ、揺らぎを加える。複数のタブが同じ間隔で一斉に再要求すると、弱った
197
+ * ホストと Keystone をそろって叩くことになるため。
198
+ */
199
+ const armCodeRetry = useCallback(() => {
200
+ if (isSettledRef.current)
201
+ return;
202
+ clearRetryTimer();
203
+ const backoffSeconds = Math.min(CODE_RETRY_BASE_SECONDS * Math.pow(2, recoveryAttemptsRef.current), CODE_RETRY_MAX_SECONDS);
204
+ const delayMs = backoffSeconds * 1000 * (0.75 + Math.random() * 0.5);
205
+ retryTimerRef.current = setTimeout(() => {
206
+ retryTimerRef.current = null;
207
+ if (isSettledRef.current)
208
+ return;
209
+ recoveryAttemptsRef.current += 1;
210
+ // トークンが生きているうちは諦めない。間隔だけ広げる。
211
+ if (!isTokenUsable() &&
212
+ recoveryAttemptsRef.current >= MAX_RECOVERY_ATTEMPTS) {
213
+ settle(hasEverAuthenticatedRef.current
214
+ ? { step: 'session_expired' }
215
+ : {
216
+ step: 'error',
217
+ message: CONNECT_FAILED_MESSAGE,
218
+ canRecoverByReload: true,
219
+ });
220
+ return;
221
+ }
222
+ sendCodeRequest();
223
+ armCodeRetryRef.current();
224
+ }, delayMs);
225
+ }, [clearRetryTimer, isTokenUsable, sendCodeRequest, settle]);
226
+ armCodeRetryRef.current = armCodeRetry;
227
+ /** コードを要求し、応答が無かったときのために次の要求を仕込む。 */
228
+ const requestCode = useCallback(() => {
229
+ sendCodeRequest();
230
+ armCodeRetry();
231
+ }, [sendCodeRequest, armCodeRetry]);
232
+ const scheduleRefresh = useCallback((expiresInSeconds) => {
233
+ clearRefreshTimer();
234
+ const lifetime = Number.isFinite(expiresInSeconds)
235
+ ? Math.max(0, expiresInSeconds)
236
+ : 0;
237
+ // 3 つの下限の最大値をとる。分岐で選ぶと 125 秒付近で不連続になり、
238
+ // 1 秒長いトークンの交換頻度が 10 倍になっていた。
239
+ const delaySeconds = Math.max(MIN_REFRESH_DELAY_SECONDS, lifetime - REFRESH_BUFFER_SECONDS, lifetime * SHORT_TOKEN_REFRESH_RATIO);
240
+ refreshTimerRef.current = setTimeout(() => {
241
+ refreshTimerRef.current = null;
242
+ requestCode();
243
+ }, delaySeconds * 1000);
244
+ }, [clearRefreshTimer, requestCode]);
245
+ const handleCodeReceived = useCallback((code, context) => __awaiter(this, void 0, void 0, function* () {
246
+ if (isSettledRef.current)
247
+ return;
248
+ const startedAt = Date.now();
249
+ // 決着しない交換に居座られてコードを取りこぼし続けないよう、古い交換は
250
+ // 見捨てる(authTimeoutSignal は AbortSignal.timeout が無い環境で
251
+ // undefined を返すため、fetch 側で必ず中断されるとは限らない)。
252
+ if (exchangingRef.current &&
253
+ startedAt - exchangeStartedAtRef.current < STALE_EXCHANGE_SECONDS * 1000) {
254
+ return;
255
+ }
256
+ exchangingRef.current = true;
257
+ exchangeStartedAtRef.current = startedAt;
258
+ const generation = (exchangeGenerationRef.current += 1);
259
+ // 交換中も見張りを残す。ここでタイマーを消すと、決着しない交換に
260
+ // 入った時点で armed なタイマーが 1 つも無くなり、復帰できなくなる。
261
+ armCodeRetry();
262
+ showExchangingIfNotMounted();
263
+ try {
264
+ const session = yield exchangeCode(apiBase, code);
265
+ if (exchangeGenerationRef.current !== generation)
266
+ return;
267
+ recoveryAttemptsRef.current = 0;
268
+ isAuthErrorPendingRef.current = false;
269
+ hasLoggedExchangeFailureRef.current = false;
270
+ hasEverAuthenticatedRef.current = true;
271
+ sessionRef.current = session;
272
+ // 交換中に 401 で仕込まれた見張りが生き残ると、成功後も 5 秒ごとに
273
+ // コードを要求し続けることになる。
274
+ clearRetryTimer();
275
+ setPhase(current => ({
276
+ step: 'authenticated',
277
+ session,
278
+ // context を省いた再交換で object_key / record_id を失わない。
279
+ context: context !== null && context !== void 0 ? context : (current.step === 'authenticated' ? current.context : null),
280
+ }));
281
+ scheduleRefresh(Math.max(0, Math.floor((session.expiresAt - Date.now()) / 1000)));
282
+ }
283
+ catch (thrown) {
284
+ if (exchangeGenerationRef.current !== generation)
285
+ return;
286
+ // 同じ復帰の試行が続く間は 1 回だけ出す。トークンが生きている間は
287
+ // 20 秒ごとに再交換を続けるため、毎回書くとホストのコンソールが埋まる。
288
+ if (!hasLoggedExchangeFailureRef.current) {
289
+ hasLoggedExchangeFailureRef.current = true;
290
+ logExchangeFailure(thrown);
291
+ }
292
+ // 何度やっても通らない失敗。使い捨てコードを浪費しない。
293
+ if (!isTransientExchangeFailure(thrown)) {
294
+ stopRetrying({
295
+ step: 'error',
296
+ message: PERMANENT_FAILURE_MESSAGE,
297
+ canRecoverByReload: false,
298
+ });
299
+ return;
300
+ }
301
+ // 試行回数は見張りのタイマー側だけで数える。ここでも足すと 1 周で
302
+ // 2 つ進み、実際にホストへ届く要求が半分になる。
303
+ if (isTokenUsable()) {
304
+ armCodeRetry();
305
+ return;
306
+ }
307
+ // ここはまだ打ち切っていない。error を出すと再読み込みボタン付きの
308
+ // 終了画面になるが、実際には最大 55 秒ほど再試行が続き、そのまま
309
+ // ログインできることがある。
310
+ showStatusIfNotMounted({ step: 'reconnecting' });
311
+ armCodeRetry();
312
+ }
313
+ finally {
314
+ if (exchangeGenerationRef.current === generation) {
315
+ exchangingRef.current = false;
316
+ }
317
+ }
318
+ }), [
319
+ apiBase,
320
+ scheduleRefresh,
321
+ armCodeRetry,
322
+ clearRetryTimer,
323
+ isTokenUsable,
324
+ stopRetrying,
325
+ showStatusIfNotMounted,
326
+ showExchangingIfNotMounted,
327
+ ]);
328
+ const handleAuthError = useCallback(() => {
329
+ if (isSettledRef.current)
330
+ return;
331
+ if (isAuthErrorPendingRef.current)
332
+ return;
333
+ isAuthErrorPendingRef.current = true;
334
+ const now = Date.now();
335
+ // 窓は連鎖の起点から測る。直近の 1 件から測ると、正常に復帰している
336
+ // 25 秒間隔の 401 でも窓が滑り続けて打ち切りに達してしまう。
337
+ if (now - authErrorRunStartedAtRef.current >
338
+ AUTH_ERROR_WINDOW_SECONDS * 1000) {
339
+ authErrorCyclesRef.current = 0;
340
+ authErrorRunStartedAtRef.current = now;
341
+ }
342
+ authErrorCyclesRef.current += 1;
343
+ // ここに達した時点で交換自体は通り続けている。その 401 は差し替え前の
344
+ // トークンで投げられたリクエストの遅れ着信であることが多く、手元では
345
+ // 見分けられない(onAuthError は拒否されたトークンを渡さない)。
346
+ // 動いているセッションを巻き添えにしない側を採る。
347
+ if (authErrorCyclesRef.current > MAX_AUTH_ERROR_CYCLES) {
348
+ // 手元のトークンが期限内なら表示は保ち、コードの取り直しだけをやめて、
349
+ // 失効した時点でセッション切れとして扱う。
350
+ //
351
+ // 据え置きにする必要がある。ホストは要求を受けてから非同期にコードを
352
+ // 発行するので、打ち切った後に以前の要求への応答が届く。交換を続けて
353
+ // しまうと更新タイマーが張り直され、打ち切りが無かったことになる。
354
+ // stopRetrying が世代を進めて以降のコードを無視させる。
355
+ stopRetrying({ step: 'session_expired' });
356
+ return;
357
+ }
358
+ clearRefreshTimer();
359
+ // 401 を返されたトークンは期限内でも使えない。保持判定から外す。
360
+ //
361
+ // NOTE: phase.session のトークンは差し替えるまで残る。SfaProvider に渡す
362
+ // auth は phase.session から作るため、ここで消すと auth が null になり
363
+ // 子ツリーが落ちる(それを避けるのがこの修正の目的)。復帰までの数秒は
364
+ // 無効なトークンでリクエストが失敗するが、トークンを外しても同じく
365
+ // 失敗するため、表示を保つ側を採る。
366
+ sessionRef.current = null;
367
+ requestCode();
368
+ showStatusIfNotMounted({ step: 'waiting_for_code' });
369
+ }, [requestCode, clearRefreshTimer, stopRetrying, showStatusIfNotMounted]);
370
+ useEffect(() => {
371
+ const handleMessage = (event) => {
372
+ var _a;
373
+ const parsed = parseInboundMessage(event, trustedOrigin);
374
+ if (!parsed)
375
+ return;
376
+ if (parsed.type === 'sfa-kit:code') {
377
+ void handleCodeReceived(parsed.code, (_a = parsed.context) !== null && _a !== void 0 ? _a : null);
378
+ }
379
+ };
380
+ window.addEventListener('message', handleMessage);
381
+ sendToHost({ type: 'sfa-kit:ready' }, trustedOrigin);
382
+ // If the host never sends a code (e.g. mintSfaKitExchangeCode is not
383
+ // implemented or the host page doesn't support the embed protocol),
384
+ // show an error instead of hanging on "Connecting..." forever.
385
+ const codeTimeout = setTimeout(() => {
386
+ setPhase(current => {
387
+ if (current.step !== 'waiting_for_code')
388
+ return current;
389
+ return {
390
+ step: 'error',
391
+ message: 'SFA/CRM との接続がタイムアウトしました。ページを再読み込みしてください。',
392
+ canRecoverByReload: true,
393
+ };
394
+ });
395
+ }, CODE_TIMEOUT_SECONDS * 1000);
396
+ return () => {
397
+ window.removeEventListener('message', handleMessage);
398
+ clearTimeout(codeTimeout);
399
+ clearRefreshTimer();
400
+ clearRetryTimer();
401
+ };
402
+ }, [trustedOrigin, handleCodeReceived, clearRefreshTimer, clearRetryTimer]);
403
+ return { phase, handleAuthError };
404
+ }
@@ -1 +1 @@
1
- export declare const SFA_KIT_VERSION = "1.14.0";
1
+ export declare const SFA_KIT_VERSION = "1.14.1";
@@ -1,3 +1,3 @@
1
1
  // このファイルは libs/sfa-kit/scripts/sync-version.cjs が生成する。
2
2
  // 手で編集しないこと。バージョンを上げるときは libs/sfa-kit/package.json を変更する。
3
- export const SFA_KIT_VERSION = '1.14.0';
3
+ export const SFA_KIT_VERSION = '1.14.1';