@geniee-inc/sfa-kit 1.13.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/GUIDE.md +2 -2
- package/package.json +1 -1
- package/src/lib/auth/SfaLoginGate.js +63 -106
- package/src/lib/auth/resolve-api-base.d.ts +19 -0
- package/src/lib/auth/resolve-api-base.js +65 -0
- package/src/lib/auth/useEmbedSession.d.ts +49 -0
- package/src/lib/auth/useEmbedSession.js +404 -0
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
package/GUIDE.md
CHANGED
|
@@ -18,7 +18,7 @@ The package ships built JavaScript and TypeScript declarations only. It contains
|
|
|
18
18
|
|
|
19
19
|
```ts
|
|
20
20
|
import pkg from '@geniee-inc/sfa-kit/package.json';
|
|
21
|
-
// pkg.version -> '1.
|
|
21
|
+
// pkg.version -> '1.14.0'
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Two additional subpaths are available: `@geniee-inc/sfa-kit/signing` exposes bundle-signing utilities (`computeContentHash`, `computeSignature`, `createManifest`, `createManifestFromDirectory`) for Node.js environments, and `./package.json` exposes the manifest. Internal modules cannot be imported directly.
|
|
@@ -38,7 +38,7 @@ Every page uses `SfaLoginGate` as the outermost wrapper. It auto-detects the hos
|
|
|
38
38
|
|
|
39
39
|
### Props
|
|
40
40
|
|
|
41
|
-
- **`apiBase`** (optional) — Overrides the
|
|
41
|
+
- **`apiBase`** (optional) — Overrides the auto-detected Keystone API endpoint. When omitted, the endpoint is resolved from a hostname: the embedding SFA/CRM host's when the page runs inside the iframe, the page's own when it runs standalone. Non-production environments (`stg.chikyu.mobi`, `dev01`-`dev09`, `devdc`, `deva0`, `dev-docker`, `dev.chikyu.local`, `localhost`, `127.0.0.1`, `[::1]`, and every Amplify host of the new frontend under `*.amplify.axiom.chikyu.mobi`: PR previews plus the `devNN.waf.` / `stg.waf.` record-detail hosts) use `https://keystone-api-staging.chikyu.mobi`; everything else (`app.chikyu.net`, any hostname not in that list) uses `https://keystone-api-production.chikyu.mobi`. Production is the fallback on purpose so a page that reaches a customer never silently talks to staging.
|
|
42
42
|
- **`trustedOrigin`** (optional) — The origin of the SFA/CRM host page (e.g. `"https://app.chikyu.net"`). When provided and the page runs inside an iframe, `SfaLoginGate` uses the postMessage code-exchange flow (`POST /v1/auth/exchange`) instead of a login form. Injected automatically by Keystone's content endpoint at serve time.
|
|
43
43
|
- **`defaultOrganizationId`** (optional) — The org ID to auto-select when the user's account belongs to multiple organizations. **Set this to the org that generated the page's metadata** (the org used in Step 0 schema discovery). Without this prop, multi-org accounts auto-select the first available org, which may not be the intended one. Use an env var, not a literal: `defaultOrganizationId={Number(process.env.NEXT_PUBLIC_DEFAULT_ORG_ID)}`.
|
|
44
44
|
- **`objectKey`** (optional) — The object this page is built for (e.g. `"companies"`). The standalone DevToolbar shows it in the page-info tooltip and in the record ID placeholder. Pass it at **both** levels; omit it only if the page genuinely has no object.
|
package/package.json
CHANGED
|
@@ -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,
|
|
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';
|
|
9
|
+
import { KEYSTONE_PRODUCTION_API_BASE, resolveApiBase, } from './resolve-api-base';
|
|
11
10
|
import { loadSession, saveSession, clearSession, loadLastOrgId, } from './session-store';
|
|
12
11
|
import { StandalonePreviewCtx } from './StandalonePreviewContext';
|
|
13
12
|
import { EmbedContextValue } from './useEmbedContext';
|
|
14
|
-
|
|
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
|
*/
|
|
@@ -62,6 +56,21 @@ function resolveTrustedOrigin(explicitOrigin) {
|
|
|
62
56
|
}
|
|
63
57
|
return undefined;
|
|
64
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* @description The origin this page is being served from.
|
|
61
|
+
*
|
|
62
|
+
* Used to pick the Keystone API when the page runs standalone: a preview
|
|
63
|
+
* opened directly, or a Studio page on a developer's machine, has no
|
|
64
|
+
* embedding host to judge by, so its own hostname is the only signal.
|
|
65
|
+
*/
|
|
66
|
+
function resolvePageOrigin() {
|
|
67
|
+
try {
|
|
68
|
+
return window.location.origin;
|
|
69
|
+
}
|
|
70
|
+
catch (_a) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
65
74
|
/**
|
|
66
75
|
* @description Universal auth wrapper for sfa-kit pages.
|
|
67
76
|
*
|
|
@@ -75,104 +84,38 @@ function resolveTrustedOrigin(explicitOrigin) {
|
|
|
75
84
|
* automatically from the API (auto-selected for multi-org accounts when
|
|
76
85
|
* defaultOrganizationId is set, or the first org is used by default).
|
|
77
86
|
*/
|
|
78
|
-
export function SfaLoginGate({ apiBase
|
|
87
|
+
export function SfaLoginGate({ apiBase, trustedOrigin, defaultOrganizationId, objectKey, level, children, }) {
|
|
79
88
|
const shouldEmbed = isEmbedded() && (trustedOrigin != null || hasContentToken());
|
|
80
89
|
const resolvedOrigin = resolveTrustedOrigin(trustedOrigin);
|
|
81
|
-
if (shouldEmbed
|
|
82
|
-
|
|
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
|
+
}
|
|
104
|
+
// Embedded: the SFA/CRM host that framed us decides the environment.
|
|
105
|
+
return (_jsx(EmbedFlow, { trustedOrigin: resolvedOrigin, apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvedOrigin), children: children }));
|
|
83
106
|
}
|
|
84
|
-
|
|
107
|
+
// Standalone: there is no host, so the page's own origin decides.
|
|
108
|
+
return (_jsx(StandaloneFlow, { apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvePageOrigin()), defaultOrganizationId: defaultOrganizationId, objectKey: objectKey, level: level, children: children }));
|
|
85
109
|
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Embed flow (iframe + postMessage)
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
86
113
|
function EmbedFlow({ trustedOrigin, apiBase, children, }) {
|
|
87
|
-
const [phase, setPhase] = useState({ step: 'waiting_for_code' });
|
|
88
|
-
const refreshTimerRef = useRef(null);
|
|
89
|
-
const exchangingRef = useRef(false);
|
|
90
|
-
const failureCountRef = useRef(0);
|
|
91
114
|
const normalizedBase = useMemo(() => apiBase.replace(/\/+$/, ''), [apiBase]);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
}, []);
|
|
98
|
-
const scheduleRefresh = useCallback((expiresInSeconds) => {
|
|
99
|
-
clearRefreshTimer();
|
|
100
|
-
const refreshAfterMs = (expiresInSeconds - REFRESH_BUFFER_SECONDS) * 1000;
|
|
101
|
-
if (refreshAfterMs <= 0) {
|
|
102
|
-
sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
refreshTimerRef.current = setTimeout(() => {
|
|
106
|
-
sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
|
|
107
|
-
}, refreshAfterMs);
|
|
108
|
-
}, [trustedOrigin, clearRefreshTimer]);
|
|
109
|
-
const handleCodeReceived = useCallback((code, context) => __awaiter(this, void 0, void 0, function* () {
|
|
110
|
-
if (exchangingRef.current)
|
|
111
|
-
return;
|
|
112
|
-
exchangingRef.current = true;
|
|
113
|
-
setPhase({ step: 'exchanging' });
|
|
114
|
-
try {
|
|
115
|
-
const session = yield exchangeCode(normalizedBase, code);
|
|
116
|
-
failureCountRef.current = 0;
|
|
117
|
-
setPhase({ step: 'authenticated', session, context });
|
|
118
|
-
const expiresInSeconds = Math.max(0, Math.floor((session.expiresAt - Date.now()) / 1000));
|
|
119
|
-
scheduleRefresh(expiresInSeconds);
|
|
120
|
-
}
|
|
121
|
-
catch (thrown) {
|
|
122
|
-
failureCountRef.current += 1;
|
|
123
|
-
if (failureCountRef.current >= MAX_CONSECUTIVE_FAILURES) {
|
|
124
|
-
setPhase({ step: 'session_expired' });
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
const message = thrown instanceof Error ? thrown.message : 'Code exchange failed';
|
|
128
|
-
setPhase({ step: 'error', message });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
finally {
|
|
132
|
-
exchangingRef.current = false;
|
|
133
|
-
}
|
|
134
|
-
}), [normalizedBase, scheduleRefresh]);
|
|
135
|
-
const handleAuthError = useCallback(() => {
|
|
136
|
-
clearRefreshTimer();
|
|
137
|
-
failureCountRef.current += 1;
|
|
138
|
-
if (failureCountRef.current >= MAX_CONSECUTIVE_FAILURES) {
|
|
139
|
-
setPhase({ step: 'session_expired' });
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
sendToHost({ type: 'sfa-kit:code-request' }, trustedOrigin);
|
|
143
|
-
setPhase({ step: 'waiting_for_code' });
|
|
144
|
-
}, [trustedOrigin, clearRefreshTimer]);
|
|
145
|
-
useEffect(() => {
|
|
146
|
-
const handleMessage = (event) => {
|
|
147
|
-
var _a;
|
|
148
|
-
const parsed = parseInboundMessage(event, trustedOrigin);
|
|
149
|
-
if (!parsed)
|
|
150
|
-
return;
|
|
151
|
-
if (parsed.type === 'sfa-kit:code') {
|
|
152
|
-
void handleCodeReceived(parsed.code, (_a = parsed.context) !== null && _a !== void 0 ? _a : null);
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
window.addEventListener('message', handleMessage);
|
|
156
|
-
sendToHost({ type: 'sfa-kit:ready' }, trustedOrigin);
|
|
157
|
-
// If the host never sends a code (e.g. mintSfaKitExchangeCode is not
|
|
158
|
-
// implemented or the host page doesn't support the embed protocol),
|
|
159
|
-
// show an error instead of hanging on "Connecting..." forever.
|
|
160
|
-
const codeTimeout = setTimeout(() => {
|
|
161
|
-
setPhase(current => {
|
|
162
|
-
if (current.step !== 'waiting_for_code')
|
|
163
|
-
return current;
|
|
164
|
-
return {
|
|
165
|
-
step: 'error',
|
|
166
|
-
message: 'SFA/CRM との接続がタイムアウトしました。ページを再読み込みしてください。',
|
|
167
|
-
};
|
|
168
|
-
});
|
|
169
|
-
}, CODE_TIMEOUT_SECONDS * 1000);
|
|
170
|
-
return () => {
|
|
171
|
-
window.removeEventListener('message', handleMessage);
|
|
172
|
-
clearTimeout(codeTimeout);
|
|
173
|
-
clearRefreshTimer();
|
|
174
|
-
};
|
|
175
|
-
}, [trustedOrigin, handleCodeReceived, clearRefreshTimer]);
|
|
115
|
+
const { phase, handleAuthError } = useEmbedSession({
|
|
116
|
+
apiBase: normalizedBase,
|
|
117
|
+
trustedOrigin,
|
|
118
|
+
});
|
|
176
119
|
const auth = useMemo(() => {
|
|
177
120
|
if (phase.step !== 'authenticated')
|
|
178
121
|
return null;
|
|
@@ -182,7 +125,10 @@ function EmbedFlow({ trustedOrigin, apiBase, children, }) {
|
|
|
182
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 }));
|
|
183
126
|
}
|
|
184
127
|
if (phase.step === 'error') {
|
|
185
|
-
return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u30A8\u30E9\u30FC", message: phase.message, showReload:
|
|
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" }));
|
|
186
132
|
}
|
|
187
133
|
if (phase.step === 'waiting_for_code' || phase.step === 'exchanging') {
|
|
188
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" }));
|
|
@@ -195,14 +141,25 @@ function EmbedStatus({ title, message, showReload, }) {
|
|
|
195
141
|
const handleReload = useCallback(() => {
|
|
196
142
|
window.location.reload();
|
|
197
143
|
}, []);
|
|
198
|
-
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] })] }) }));
|
|
199
145
|
}
|
|
200
146
|
// ---------------------------------------------------------------------------
|
|
201
147
|
// Standalone flow (login form + DevToolbar)
|
|
202
148
|
// ---------------------------------------------------------------------------
|
|
203
149
|
function StandaloneFlow({ apiBase, defaultOrganizationId, objectKey, level, children, }) {
|
|
204
|
-
const
|
|
205
|
-
const [
|
|
150
|
+
const normalizedBase = useMemo(() => apiBase.replace(/\/+$/, ''), [apiBase]);
|
|
151
|
+
const [session, setSession] = useState(() => {
|
|
152
|
+
const saved = loadSession();
|
|
153
|
+
// Discard a saved session whose apiBase does not match the current
|
|
154
|
+
// environment. Without this check a staging session could be reused
|
|
155
|
+
// when the page is opened from a production host (or vice-versa).
|
|
156
|
+
if ((saved === null || saved === void 0 ? void 0 : saved.apiBase) != null && saved.apiBase !== normalizedBase) {
|
|
157
|
+
clearSession();
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return saved;
|
|
161
|
+
});
|
|
162
|
+
const [activeBase, setActiveBase] = useState(() => { var _a; return (_a = session === null || session === void 0 ? void 0 : session.apiBase) !== null && _a !== void 0 ? _a : normalizedBase; });
|
|
206
163
|
const [status, setStatus] = useState('idle');
|
|
207
164
|
const [error, setError] = useState(null);
|
|
208
165
|
// NOTE: level を渡さない既存ページのために、objectKey の有無から補う。
|
|
@@ -308,7 +265,7 @@ function LoginForm({ defaultApiBase, defaultOrganizationId, onSubmit, status, er
|
|
|
308
265
|
: undefined;
|
|
309
266
|
onSubmit(email.trim(), password, apiBase.trim(), Number.isFinite(orgId) ? orgId : undefined);
|
|
310
267
|
};
|
|
311
|
-
return (_jsx("div", { style: loginStyles.backdrop, children: _jsxs("form", { style: loginStyles.card, onSubmit: handleSubmit, 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" })] })] }), error && (_jsx("div", { role: "alert", style: styles.error, children: error })), _jsxs("label", { style: loginStyles.label, children: [_jsx("span", { style: loginStyles.labelText, children: "\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9*" }), _jsx("input", { style: loginStyles.input, type: "text", value: email, onChange: e => setEmail(e.target.value), autoComplete: "email", required: true, disabled: status === 'loading' })] }), _jsxs("label", { style: loginStyles.label, children: [_jsx("span", { style: loginStyles.labelText, children: "\u30D1\u30B9\u30EF\u30FC\u30C9*" }), _jsx("input", { style: loginStyles.input, type: "password", value: password, onChange: e => setPassword(e.target.value), autoComplete: "current-password", required: true, disabled: status === 'loading' })] }), _jsx("button", { style: Object.assign(Object.assign({}, loginStyles.submitButton), { opacity: status === 'loading' ? 0.7 : 1 }), type: "submit", disabled: status === 'loading', children: status === 'loading' ? 'ログイン中...' : 'ログイン' }), _jsx("button", { type: "button", style: loginStyles.configToggle, onClick: () => setShowConfig(prev => !prev), children: showConfig ? '▾ 接続設定を隠す' : '▸ 接続設定' }), showConfig && (_jsxs("div", { style: loginStyles.configSection, children: [_jsxs("label", { style: loginStyles.label, children: [_jsx("span", { style: loginStyles.configLabel, children: "API Base" }), _jsx("input", { style: loginStyles.input, type: "text", value: apiBase, onChange: e => setApiBase(e.target.value), placeholder:
|
|
268
|
+
return (_jsx("div", { style: loginStyles.backdrop, children: _jsxs("form", { style: loginStyles.card, onSubmit: handleSubmit, 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" })] })] }), error && (_jsx("div", { role: "alert", style: styles.error, children: error })), _jsxs("label", { style: loginStyles.label, children: [_jsx("span", { style: loginStyles.labelText, children: "\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9*" }), _jsx("input", { style: loginStyles.input, type: "text", value: email, onChange: e => setEmail(e.target.value), autoComplete: "email", required: true, disabled: status === 'loading' })] }), _jsxs("label", { style: loginStyles.label, children: [_jsx("span", { style: loginStyles.labelText, children: "\u30D1\u30B9\u30EF\u30FC\u30C9*" }), _jsx("input", { style: loginStyles.input, type: "password", value: password, onChange: e => setPassword(e.target.value), autoComplete: "current-password", required: true, disabled: status === 'loading' })] }), _jsx("button", { style: Object.assign(Object.assign({}, loginStyles.submitButton), { opacity: status === 'loading' ? 0.7 : 1 }), type: "submit", disabled: status === 'loading', children: status === 'loading' ? 'ログイン中...' : 'ログイン' }), _jsx("button", { type: "button", style: loginStyles.configToggle, onClick: () => setShowConfig(prev => !prev), children: showConfig ? '▾ 接続設定を隠す' : '▸ 接続設定' }), showConfig && (_jsxs("div", { style: loginStyles.configSection, children: [_jsxs("label", { style: loginStyles.label, children: [_jsx("span", { style: loginStyles.configLabel, children: "API Base" }), _jsx("input", { style: loginStyles.input, type: "text", value: apiBase, onChange: e => setApiBase(e.target.value), placeholder: KEYSTONE_PRODUCTION_API_BASE, disabled: status === 'loading' })] }), _jsxs("label", { style: loginStyles.label, children: [_jsx("span", { style: loginStyles.configLabel, children: "Organization ID" }), _jsx("input", { style: loginStyles.input, type: "text", value: orgIdInput, onChange: e => setOrgIdInput(e.target.value), placeholder: "\u81EA\u52D5\u9078\u629E (\u7A7A\u6B04)", disabled: status === 'loading' })] })] })), _jsxs("div", { style: styles.versionFooter, children: ["@geniee-inc/sfa-kit v", SFA_KIT_VERSION] })] }) }));
|
|
312
269
|
}
|
|
313
270
|
// ---------------------------------------------------------------------------
|
|
314
271
|
// Geniee logo mark (inline SVG)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const KEYSTONE_PRODUCTION_API_BASE = "https://keystone-api-production.chikyu.mobi";
|
|
2
|
+
export declare const KEYSTONE_STAGING_API_BASE = "https://keystone-api-staging.chikyu.mobi";
|
|
3
|
+
/**
|
|
4
|
+
* @description Resolves the Keystone API base URL from an origin.
|
|
5
|
+
*
|
|
6
|
+
* Every non-production chikyu environment — `stg.chikyu.mobi`, the dev tier,
|
|
7
|
+
* local development, and Amplify PR previews — uses the staging Keystone API.
|
|
8
|
+
* Everything else — `app.chikyu.net`, an origin this table does not know, or
|
|
9
|
+
* no origin at all — uses production.
|
|
10
|
+
*
|
|
11
|
+
* Production is the fallback on purpose: a page that reaches a real customer
|
|
12
|
+
* must never silently talk to staging. A developer on an unrecognised host
|
|
13
|
+
* can still pass `apiBase` explicitly or type it into the standalone login form.
|
|
14
|
+
*
|
|
15
|
+
* Callers decide which origin to pass: the embedding host's origin when the
|
|
16
|
+
* page runs inside the SFA/CRM iframe, and the page's own origin when it runs
|
|
17
|
+
* standalone. A malformed origin is treated the same as no origin.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveApiBase(origin: string | undefined): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const KEYSTONE_PRODUCTION_API_BASE = 'https://keystone-api-production.chikyu.mobi';
|
|
2
|
+
export const KEYSTONE_STAGING_API_BASE = 'https://keystone-api-staging.chikyu.mobi';
|
|
3
|
+
/** Dev-tier chikyu (legacy frontend) hosts: dev01-dev09, devdc, deva0, and the like. */
|
|
4
|
+
const DEV_HOST_PATTERN = /^dev\w+\.chikyu\.mobi$/;
|
|
5
|
+
/**
|
|
6
|
+
* Amplify-hosted new frontend. Covers PR previews
|
|
7
|
+
* (`pr-{id}.amplify.axiom.chikyu.mobi`, see amplify.yml) AND the dev / stg
|
|
8
|
+
* record-detail hosts that frame sfa-kit pages
|
|
9
|
+
* (`dev01.waf.amplify.axiom.chikyu.mobi`, `stg.waf.amplify.axiom.chikyu.mobi`).
|
|
10
|
+
* Keep this a suffix match: narrowing it to `pr-` would send every dev
|
|
11
|
+
* environment to production.
|
|
12
|
+
*/
|
|
13
|
+
const AMPLIFY_PATTERN = /\.amplify\.axiom\.chikyu\.mobi$/;
|
|
14
|
+
/** Hostnames that are always a developer's own machine. */
|
|
15
|
+
const LOCAL_HOSTNAMES = new Set([
|
|
16
|
+
'localhost',
|
|
17
|
+
'127.0.0.1',
|
|
18
|
+
'[::1]',
|
|
19
|
+
'dev.chikyu.local',
|
|
20
|
+
'dev-docker.chikyu.mobi',
|
|
21
|
+
]);
|
|
22
|
+
/** Shared pre-production chikyu environments. */
|
|
23
|
+
const STAGING_HOSTNAMES = new Set(['stg.chikyu.mobi']);
|
|
24
|
+
/**
|
|
25
|
+
* @description Resolves the Keystone API base URL from an origin.
|
|
26
|
+
*
|
|
27
|
+
* Every non-production chikyu environment — `stg.chikyu.mobi`, the dev tier,
|
|
28
|
+
* local development, and Amplify PR previews — uses the staging Keystone API.
|
|
29
|
+
* Everything else — `app.chikyu.net`, an origin this table does not know, or
|
|
30
|
+
* no origin at all — uses production.
|
|
31
|
+
*
|
|
32
|
+
* Production is the fallback on purpose: a page that reaches a real customer
|
|
33
|
+
* must never silently talk to staging. A developer on an unrecognised host
|
|
34
|
+
* can still pass `apiBase` explicitly or type it into the standalone login form.
|
|
35
|
+
*
|
|
36
|
+
* Callers decide which origin to pass: the embedding host's origin when the
|
|
37
|
+
* page runs inside the SFA/CRM iframe, and the page's own origin when it runs
|
|
38
|
+
* standalone. A malformed origin is treated the same as no origin.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveApiBase(origin) {
|
|
41
|
+
const hostname = hostnameOf(origin);
|
|
42
|
+
if (hostname == null)
|
|
43
|
+
return KEYSTONE_PRODUCTION_API_BASE;
|
|
44
|
+
const isNonProduction = STAGING_HOSTNAMES.has(hostname) ||
|
|
45
|
+
LOCAL_HOSTNAMES.has(hostname) ||
|
|
46
|
+
DEV_HOST_PATTERN.test(hostname) ||
|
|
47
|
+
AMPLIFY_PATTERN.test(hostname);
|
|
48
|
+
return isNonProduction
|
|
49
|
+
? KEYSTONE_STAGING_API_BASE
|
|
50
|
+
: KEYSTONE_PRODUCTION_API_BASE;
|
|
51
|
+
}
|
|
52
|
+
/** The hostname of an origin, or undefined when there is no usable origin. */
|
|
53
|
+
function hostnameOf(origin) {
|
|
54
|
+
if (origin == null)
|
|
55
|
+
return undefined;
|
|
56
|
+
try {
|
|
57
|
+
// WHATWG URL preserves a trailing dot on FQDN hostnames (e.g.
|
|
58
|
+
// "stg.chikyu.mobi." stays as-is). Strip it so the allowlist and
|
|
59
|
+
// regex checks match regardless of whether the dot is present.
|
|
60
|
+
return new URL(origin).hostname.replace(/\.$/, '');
|
|
61
|
+
}
|
|
62
|
+
catch (_a) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|
package/src/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SFA_KIT_VERSION = "1.
|
|
1
|
+
export declare const SFA_KIT_VERSION = "1.14.1";
|
package/src/lib/version.js
CHANGED