@geniee-inc/sfa-kit 1.14.0 → 1.16.0
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 +40 -11
- package/package.json +1 -1
- package/src/lib/auth/ObjectKeyGuard.d.ts +12 -0
- package/src/lib/auth/ObjectKeyGuard.js +61 -0
- package/src/lib/auth/SfaLoginGate.js +33 -103
- package/src/lib/auth/useEmbedSession.d.ts +49 -0
- package/src/lib/auth/useEmbedSession.js +404 -0
- package/src/lib/hooks/splitBulkRows.d.ts +8 -4
- package/src/lib/hooks/splitBulkRows.js +17 -4
- package/src/lib/hooks/useBulkCreateRecords.d.ts +8 -3
- package/src/lib/hooks/useBulkCreateRecords.js +5 -2
- package/src/lib/hooks/useBulkDeleteRecords.d.ts +14 -1
- package/src/lib/hooks/useBulkDeleteRecords.js +4 -0
- package/src/lib/hooks/useBulkUpdateRecords.d.ts +8 -2
- package/src/lib/hooks/useBulkUpdateRecords.js +3 -1
- package/src/lib/provider/SfaClient.d.ts +1 -0
- package/src/lib/provider/SfaClient.js +18 -5
- package/src/lib/provider/types.d.ts +19 -3
- package/src/lib/version.d.ts +1 -1
- package/src/lib/version.js +1 -1
package/GUIDE.md
CHANGED
|
@@ -286,9 +286,11 @@ that did land, and those are not recoverable anywhere else.
|
|
|
286
286
|
whole picture leaves it looking neither written nor retryable. Re-read the
|
|
287
287
|
records to find out what landed
|
|
288
288
|
- `failed` — the rows that did not land **and can be sent again**: `conflict`,
|
|
289
|
-
`invalid` and `
|
|
290
|
-
that they did not apply; there is no
|
|
291
|
-
on
|
|
289
|
+
`invalid`, `not_attempted`, `not_found` and `not_permitted`. Retry exactly
|
|
290
|
+
these. What makes that safe is that they did not apply; there is no
|
|
291
|
+
server-side de-duplication to fall back on. Read each row's `status` before
|
|
292
|
+
offering a retry: `not_found` and `not_permitted` are the server's settled
|
|
293
|
+
answer, and resending alone will not change it
|
|
292
294
|
- `unresolved` — the rows that may already be written but cannot be reported
|
|
293
295
|
either way. They keep whatever `id` arrived. **Re-read these; never retry
|
|
294
296
|
them**: a create that already landed would be written twice
|
|
@@ -375,7 +377,9 @@ return (
|
|
|
375
377
|
<ErrorSnackbar
|
|
376
378
|
error={null}
|
|
377
379
|
message={`${results.length} 件中 ${updated.length} 件を保存しました。${failed.length} 件は保存されていません。`}
|
|
378
|
-
onRetry={() =>
|
|
380
|
+
onRetry={() =>
|
|
381
|
+
void bulkUpdate(rowsFor(resendable(failed))).catch(() => undefined)
|
|
382
|
+
}
|
|
379
383
|
onClose={reset}
|
|
380
384
|
/>
|
|
381
385
|
)}
|
|
@@ -385,17 +389,42 @@ return (
|
|
|
385
389
|
|
|
386
390
|
Counts only: which row failed and why belongs in the grid, where the reader
|
|
387
391
|
acts on it — `results` carries each row's `index` and its per-field errors.
|
|
388
|
-
|
|
389
|
-
|
|
392
|
+
`unresolved` rows are re-read instead, so label the action accordingly
|
|
393
|
+
(`retryLabel`).
|
|
394
|
+
|
|
395
|
+
Offer `onRetry` for the `failed` rows a resend can still change — that is what
|
|
396
|
+
`resendable` stands for above:
|
|
397
|
+
|
|
398
|
+
```tsx
|
|
399
|
+
const SETTLED = new Set(['not_found', 'not_permitted']);
|
|
400
|
+
|
|
401
|
+
const resendable = (rows: readonly SfaBulkRowResult[]) =>
|
|
402
|
+
rows.filter(row => !SETTLED.has(row.status));
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Every row in `failed` is safe to send again, but a `not_found` or
|
|
406
|
+
`not_permitted` one comes back refused however often it is sent. Showing those
|
|
407
|
+
under a retry button gives the reader an action that cannot succeed; report
|
|
408
|
+
what they say instead.
|
|
390
409
|
|
|
391
410
|
A row counts as written only when it carries what a write produces: the record
|
|
392
411
|
for a create or an update, the id for a delete. Anything short of that is
|
|
393
412
|
`failed` or `unresolved`, and which one is decided by what the answer says,
|
|
394
|
-
not by elimination.
|
|
395
|
-
may be sent again:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
413
|
+
not by elimination. Five statuses mean the row did not apply, and only those
|
|
414
|
+
may be sent again:
|
|
415
|
+
|
|
416
|
+
| Status | What it means | What to do |
|
|
417
|
+
| --------------- | -------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
418
|
+
| `conflict` | Another write got there first | Retry with the `version` the row reports, or re-read first if none arrived |
|
|
419
|
+
| `invalid` | The row failed validation | Correct the row from its per-field errors, then resend |
|
|
420
|
+
| `not_attempted` | The call ran out of budget before starting the row | Resend exactly as it was |
|
|
421
|
+
| `not_found` | The record the row named does not exist | Tell the reader; a resend answers the same |
|
|
422
|
+
| `not_permitted` | The caller may not write that record | Tell the reader; a resend answers the same |
|
|
423
|
+
|
|
424
|
+
`not_found` and `not_permitted` are refusals of a row the call did reach, so no
|
|
425
|
+
write happened and resending is safe — it is simply futile until the record or
|
|
426
|
+
the permission changes. Their `error.code` may be an empty string: keystone has
|
|
427
|
+
not minted one for `not_permitted`, so branch on `status` rather than `code`.
|
|
399
428
|
|
|
400
429
|
Everything else is `unresolved`: `indeterminate`, the server saying it cannot
|
|
401
430
|
tell whether the row applied; a claimed success missing what it should carry —
|
package/package.json
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
interface ObjectKeyGuardProps {
|
|
3
|
+
readonly objectKey: string;
|
|
4
|
+
readonly children: ReactNode;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* objectKey が組織のオブジェクトカタログに存在することを検証する。
|
|
8
|
+
* 不一致の場合はページ本体の代わりにエラーバナーを表示し、
|
|
9
|
+
* singular / typo のキーがリフレッシュ時に 404 で壊れる前に検出する。
|
|
10
|
+
*/
|
|
11
|
+
export declare function ObjectKeyGuard({ objectKey, children }: ObjectKeyGuardProps): JSX.Element;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useObjectCatalog } from '../hooks/useObjectCatalog';
|
|
3
|
+
import { font, space } from '../theme';
|
|
4
|
+
/**
|
|
5
|
+
* objectKey が組織のオブジェクトカタログに存在することを検証する。
|
|
6
|
+
* 不一致の場合はページ本体の代わりにエラーバナーを表示し、
|
|
7
|
+
* singular / typo のキーがリフレッシュ時に 404 で壊れる前に検出する。
|
|
8
|
+
*/
|
|
9
|
+
export function ObjectKeyGuard({ objectKey, children }) {
|
|
10
|
+
const { objects, isLoading, error } = useObjectCatalog();
|
|
11
|
+
// カタログ取得中やエラー時はブロックせずそのまま表示する。
|
|
12
|
+
// ガードが止めるのは「カタログに無い」と確定した場合のみ。
|
|
13
|
+
if (isLoading || error || objects.length === 0)
|
|
14
|
+
return _jsx(_Fragment, { children: children });
|
|
15
|
+
const knownKeys = objects.map(o => o.key);
|
|
16
|
+
if (knownKeys.includes(objectKey))
|
|
17
|
+
return _jsx(_Fragment, { children: children });
|
|
18
|
+
// 提案のみ大文字小文字を無視する。一致判定は API がキーを厳密比較するため厳密のまま
|
|
19
|
+
const target = objectKey.toLowerCase();
|
|
20
|
+
const suggestion = knownKeys.find(knownKey => {
|
|
21
|
+
const key = knownKey.toLowerCase();
|
|
22
|
+
return key.startsWith(target) || target.startsWith(key);
|
|
23
|
+
});
|
|
24
|
+
return (_jsx("div", { style: {
|
|
25
|
+
padding: space.lg,
|
|
26
|
+
fontFamily: font.family,
|
|
27
|
+
maxWidth: 560,
|
|
28
|
+
margin: '80px auto',
|
|
29
|
+
}, children: _jsxs("div", { style: {
|
|
30
|
+
background: '#FEF2F2',
|
|
31
|
+
border: '1px solid #FECACA',
|
|
32
|
+
borderRadius: 8,
|
|
33
|
+
padding: space.lg,
|
|
34
|
+
}, children: [_jsx("div", { style: {
|
|
35
|
+
fontWeight: 700,
|
|
36
|
+
fontSize: 15,
|
|
37
|
+
color: '#991B1B',
|
|
38
|
+
marginBottom: 8,
|
|
39
|
+
}, children: "Invalid object key" }), _jsxs("p", { style: {
|
|
40
|
+
margin: 0,
|
|
41
|
+
fontSize: 14,
|
|
42
|
+
color: '#7F1D1D',
|
|
43
|
+
lineHeight: 1.5,
|
|
44
|
+
}, children: [_jsx("code", { style: {
|
|
45
|
+
background: '#FEE2E2',
|
|
46
|
+
padding: '2px 6px',
|
|
47
|
+
borderRadius: 4,
|
|
48
|
+
fontSize: 13,
|
|
49
|
+
}, children: objectKey }), ' ', "does not match any object in this organization.", suggestion && (_jsxs(_Fragment, { children: [' ', "Did you mean", ' ', _jsx("code", { style: {
|
|
50
|
+
background: '#DCFCE7',
|
|
51
|
+
padding: '2px 6px',
|
|
52
|
+
borderRadius: 4,
|
|
53
|
+
fontSize: 13,
|
|
54
|
+
color: '#166534',
|
|
55
|
+
}, children: suggestion }), "?"] }))] }), _jsx("p", { style: {
|
|
56
|
+
margin: '12px 0 0',
|
|
57
|
+
fontSize: 12,
|
|
58
|
+
color: '#9CA3AF',
|
|
59
|
+
lineHeight: 1.4,
|
|
60
|
+
}, children: "Object keys are the plural collection name from the object catalogue (e.g. \"business_discussions\", not \"business_discussion\"). Check the objectKey prop on SfaLoginGate and the manifest." })] }) }));
|
|
61
|
+
}
|
|
@@ -1,22 +1,17 @@
|
|
|
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 { ObjectKeyGuard } from './ObjectKeyGuard';
|
|
11
10
|
import { KEYSTONE_PRODUCTION_API_BASE, resolveApiBase, } from './resolve-api-base';
|
|
12
11
|
import { loadSession, saveSession, clearSession, loadLastOrgId, } from './session-store';
|
|
13
12
|
import { StandalonePreviewCtx } from './StandalonePreviewContext';
|
|
14
13
|
import { EmbedContextValue } from './useEmbedContext';
|
|
15
|
-
|
|
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;
|
|
14
|
+
import { useEmbedSession } from './useEmbedSession';
|
|
20
15
|
/**
|
|
21
16
|
* @description Returns true when the page is running inside an iframe.
|
|
22
17
|
*/
|
|
@@ -93,103 +88,35 @@ function resolvePageOrigin() {
|
|
|
93
88
|
export function SfaLoginGate({ apiBase, trustedOrigin, defaultOrganizationId, objectKey, level, children, }) {
|
|
94
89
|
const shouldEmbed = isEmbedded() && (trustedOrigin != null || hasContentToken());
|
|
95
90
|
const resolvedOrigin = resolveTrustedOrigin(trustedOrigin);
|
|
96
|
-
if (shouldEmbed
|
|
91
|
+
if (shouldEmbed) {
|
|
92
|
+
// ホストオリジンを 1 つに決められない状態。postMessage の宛先も
|
|
93
|
+
// event.origin の照合相手も決まらないため、埋め込みの手順自体が成立
|
|
94
|
+
// しない。ここでログインフォームに落とすと、どの frame の中にいるか
|
|
95
|
+
// 分からないまま資格情報の入力欄を出すことになるので、描画せずに
|
|
96
|
+
// 理由だけ表示して止める。
|
|
97
|
+
//
|
|
98
|
+
// NOTE: resolveTrustedOrigin は trustedOrigin 未指定時に document.referrer
|
|
99
|
+
// を使う。referrer は埋め込む側が決める値なので、下の EmbedFlow の
|
|
100
|
+
// 経路は「埋め込み元の自己申告を信じる」ことになる。ホストを実際に
|
|
101
|
+
// 限定できるのは trustedOrigin を明示した場合だけ。
|
|
102
|
+
if (resolvedOrigin == null) {
|
|
103
|
+
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" }));
|
|
104
|
+
}
|
|
97
105
|
// Embedded: the SFA/CRM host that framed us decides the environment.
|
|
98
|
-
return (_jsx(EmbedFlow, { trustedOrigin: resolvedOrigin, apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvedOrigin), children: children }));
|
|
106
|
+
return (_jsx(EmbedFlow, { objectKey: objectKey, trustedOrigin: resolvedOrigin, apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvedOrigin), children: children }));
|
|
99
107
|
}
|
|
100
108
|
// Standalone: there is no host, so the page's own origin decides.
|
|
101
109
|
return (_jsx(StandaloneFlow, { apiBase: apiBase !== null && apiBase !== void 0 ? apiBase : resolveApiBase(resolvePageOrigin()), defaultOrganizationId: defaultOrganizationId, objectKey: objectKey, level: level, children: children }));
|
|
102
110
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const failureCountRef = useRef(0);
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Embed flow (iframe + postMessage)
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
function EmbedFlow({ objectKey, trustedOrigin, apiBase, children, }) {
|
|
108
115
|
const normalizedBase = useMemo(() => apiBase.replace(/\/+$/, ''), [apiBase]);
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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]);
|
|
116
|
+
const { phase, handleAuthError } = useEmbedSession({
|
|
117
|
+
apiBase: normalizedBase,
|
|
118
|
+
trustedOrigin,
|
|
119
|
+
});
|
|
193
120
|
const auth = useMemo(() => {
|
|
194
121
|
if (phase.step !== 'authenticated')
|
|
195
122
|
return null;
|
|
@@ -199,20 +126,23 @@ function EmbedFlow({ trustedOrigin, apiBase, children, }) {
|
|
|
199
126
|
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
127
|
}
|
|
201
128
|
if (phase.step === 'error') {
|
|
202
|
-
return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u30A8\u30E9\u30FC", message: phase.message, showReload:
|
|
129
|
+
return (_jsx(EmbedStatus, { title: "\u63A5\u7D9A\u30A8\u30E9\u30FC", message: phase.message, showReload: phase.canRecoverByReload }));
|
|
130
|
+
}
|
|
131
|
+
if (phase.step === 'reconnecting') {
|
|
132
|
+
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
133
|
}
|
|
204
134
|
if (phase.step === 'waiting_for_code' || phase.step === 'exchanging') {
|
|
205
135
|
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" }));
|
|
206
136
|
}
|
|
207
137
|
if (!auth)
|
|
208
138
|
return null;
|
|
209
|
-
return (_jsx(EmbedContextValue.Provider, { value: phase.context, children: _jsx(SfaProvider, { apiBase: normalizedBase, auth: auth, mode: "direct", organizationId: phase.session.organizationId, onAuthError: handleAuthError, children: children }) }));
|
|
139
|
+
return (_jsx(EmbedContextValue.Provider, { value: phase.context, children: _jsx(SfaProvider, { apiBase: normalizedBase, auth: auth, mode: "direct", organizationId: phase.session.organizationId, onAuthError: handleAuthError, children: objectKey ? (_jsx(ObjectKeyGuard, { objectKey: objectKey, children: children })) : (children) }) }));
|
|
210
140
|
}
|
|
211
141
|
function EmbedStatus({ title, message, showReload, }) {
|
|
212
142
|
const handleReload = useCallback(() => {
|
|
213
143
|
window.location.reload();
|
|
214
144
|
}, []);
|
|
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] })] }) }));
|
|
145
|
+
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
146
|
}
|
|
217
147
|
// ---------------------------------------------------------------------------
|
|
218
148
|
// Standalone flow (login form + DevToolbar)
|
|
@@ -312,7 +242,7 @@ function StandaloneFlow({ apiBase, defaultOrganizationId, objectKey, level, chil
|
|
|
312
242
|
if (!session || !auth) {
|
|
313
243
|
return (_jsx(LoginForm, { defaultApiBase: apiBase, defaultOrganizationId: defaultOrganizationId, onSubmit: handleLogin, status: status, error: error }));
|
|
314
244
|
}
|
|
315
|
-
return (_jsx(SfaProvider, { apiBase: activeBase, auth: auth, mode: "direct", organizationId: session.organizationId, onAuthError: handleLogout, children: _jsxs(StandalonePreviewCtx.Provider, { value: previewControls, children: [_jsx(DevToolbar, { organizationId: session.organizationId, preview: previewControls, objectKey: objectKey, level: resolvedLevel, onLogout: handleLogout }), children] }) }));
|
|
245
|
+
return (_jsx(SfaProvider, { apiBase: activeBase, auth: auth, mode: "direct", organizationId: session.organizationId, onAuthError: handleLogout, children: _jsxs(StandalonePreviewCtx.Provider, { value: previewControls, children: [_jsx(DevToolbar, { organizationId: session.organizationId, preview: previewControls, objectKey: objectKey, level: resolvedLevel, onLogout: handleLogout }), objectKey ? (_jsx(ObjectKeyGuard, { objectKey: objectKey, children: children })) : (children)] }) }));
|
|
316
246
|
}
|
|
317
247
|
// ---------------------------------------------------------------------------
|
|
318
248
|
// GENIEE SFA/CRM-branded login form
|
|
@@ -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
|
+
}
|
|
@@ -8,7 +8,9 @@ export interface BulkRowSplit<T> {
|
|
|
8
8
|
readonly conflicts: readonly SfaBulkRowResult[];
|
|
9
9
|
/**
|
|
10
10
|
* The rows that did not land and can be sent again. A `conflict` is one of
|
|
11
|
-
* them, and also appears in `conflicts`.
|
|
11
|
+
* them, and also appears in `conflicts`. Read each row's `status` to say why
|
|
12
|
+
* it failed: `not_found` and `not_permitted` are settled refusals that a
|
|
13
|
+
* resend alone will not clear.
|
|
12
14
|
*/
|
|
13
15
|
readonly failed: readonly SfaBulkRowResult[];
|
|
14
16
|
}
|
|
@@ -16,9 +18,11 @@ export interface BulkRowSplit<T> {
|
|
|
16
18
|
* @description Splits per-row outcomes by what the caller may do with them.
|
|
17
19
|
*
|
|
18
20
|
* NOTE: `failed` is decided by what the answer says, never by exclusion.
|
|
19
|
-
* `conflict`, `invalid` and `
|
|
20
|
-
* those are the ones safe to send again — a row
|
|
21
|
-
* unwritten as one the server
|
|
21
|
+
* `conflict`, `invalid`, `not_attempted`, `not_found` and `not_permitted` all
|
|
22
|
+
* mean the row did not apply, so those are the ones safe to send again — a row
|
|
23
|
+
* the call never started is as unwritten as one the server refused. Each keeps
|
|
24
|
+
* its own `status`, so a caller can tell "no permission" from "failed
|
|
25
|
+
* validation" without a bucket per reason. Anything else the kit cannot place —
|
|
22
26
|
* `indeterminate`, no record, no version, a status outside the contract or
|
|
23
27
|
* belonging to another operation — may already be written, and a retry would
|
|
24
28
|
* write it twice.
|
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* The outcomes that mean the row did not apply, so sending it again is safe.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: safe to send again, not sure to succeed. `invalid` needs the row
|
|
5
|
+
* corrected first, `conflict` a fresh `version`, and `not_found` /
|
|
6
|
+
* `not_permitted` answer the same until the record or the permission changes.
|
|
7
|
+
* What the set decides is that no write happened, so the caller may resend
|
|
8
|
+
* without writing a second record — which is the question `unresolved` exists
|
|
9
|
+
* to answer, and the reason these belong in `failed` instead.
|
|
10
|
+
*/
|
|
2
11
|
const RETRYABLE_STATUSES = new Set([
|
|
3
12
|
'conflict',
|
|
4
13
|
'invalid',
|
|
5
14
|
'not_attempted',
|
|
15
|
+
'not_found',
|
|
16
|
+
'not_permitted',
|
|
6
17
|
]);
|
|
7
18
|
/**
|
|
8
19
|
* @description Splits per-row outcomes by what the caller may do with them.
|
|
9
20
|
*
|
|
10
21
|
* NOTE: `failed` is decided by what the answer says, never by exclusion.
|
|
11
|
-
* `conflict`, `invalid` and `
|
|
12
|
-
* those are the ones safe to send again — a row
|
|
13
|
-
* unwritten as one the server
|
|
22
|
+
* `conflict`, `invalid`, `not_attempted`, `not_found` and `not_permitted` all
|
|
23
|
+
* mean the row did not apply, so those are the ones safe to send again — a row
|
|
24
|
+
* the call never started is as unwritten as one the server refused. Each keeps
|
|
25
|
+
* its own `status`, so a caller can tell "no permission" from "failed
|
|
26
|
+
* validation" without a bucket per reason. Anything else the kit cannot place —
|
|
14
27
|
* `indeterminate`, no record, no version, a status outside the contract or
|
|
15
28
|
* belonging to another operation — may already be written, and a retry would
|
|
16
29
|
* write it twice.
|
|
@@ -18,7 +18,9 @@ export interface UseBulkCreateRecordsResult {
|
|
|
18
18
|
/**
|
|
19
19
|
* The rows that did not land and can be sent again, with their `index` and
|
|
20
20
|
* `error`. A batch can be partly successful, so this being non-empty does
|
|
21
|
-
* not mean nothing was written
|
|
21
|
+
* not mean nothing was written. Read each row's `status` before retrying:
|
|
22
|
+
* `not_found` and `not_permitted` are settled refusals that come back the
|
|
23
|
+
* same however often they are resent.
|
|
22
24
|
*/
|
|
23
25
|
readonly failed: readonly SfaBulkRowResult[];
|
|
24
26
|
/**
|
|
@@ -50,7 +52,10 @@ export interface UseBulkCreateRecordsResult {
|
|
|
50
52
|
* the call as a whole.
|
|
51
53
|
*
|
|
52
54
|
* Retry only the rows in `failed`: those did not apply, which is what makes
|
|
53
|
-
* resending them safe
|
|
54
|
-
*
|
|
55
|
+
* resending them safe — safe, not sure to succeed. A `not_found` or
|
|
56
|
+
* `not_permitted` row is refused again until the record or the permission
|
|
57
|
+
* changes, so branch on `status` rather than offering one retry for the whole
|
|
58
|
+
* bucket. Re-read the rows in `unresolved` instead — nothing protects against
|
|
59
|
+
* writing them twice.
|
|
55
60
|
*/
|
|
56
61
|
export declare function useBulkCreateRecords(collection: string): UseBulkCreateRecordsResult;
|
|
@@ -14,8 +14,11 @@ const sendBulkCreate = (client, collection, rows) => client.bulkCreate(collectio
|
|
|
14
14
|
* the call as a whole.
|
|
15
15
|
*
|
|
16
16
|
* Retry only the rows in `failed`: those did not apply, which is what makes
|
|
17
|
-
* resending them safe
|
|
18
|
-
*
|
|
17
|
+
* resending them safe — safe, not sure to succeed. A `not_found` or
|
|
18
|
+
* `not_permitted` row is refused again until the record or the permission
|
|
19
|
+
* changes, so branch on `status` rather than offering one retry for the whole
|
|
20
|
+
* bucket. Re-read the rows in `unresolved` instead — nothing protects against
|
|
21
|
+
* writing them twice.
|
|
19
22
|
*/
|
|
20
23
|
export function useBulkCreateRecords(collection) {
|
|
21
24
|
const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkCreate);
|
|
@@ -20,7 +20,16 @@ export interface UseBulkDeleteRecordsResult {
|
|
|
20
20
|
* whether it still wants the record gone.
|
|
21
21
|
*/
|
|
22
22
|
readonly conflicts: readonly SfaBulkRowResult[];
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The rows that were not removed by this call, conflicts included.
|
|
25
|
+
*
|
|
26
|
+
* NOTE: not all of them are still in place. A `not_found` row says the
|
|
27
|
+
* record was not there to delete — already gone, or never existing. It is
|
|
28
|
+
* reported here rather than in `deletedIds` because this call removed
|
|
29
|
+
* nothing, and the kit will not claim a deletion the server did not make.
|
|
30
|
+
* A caller whose grid wants the row cleared checks for that `status`;
|
|
31
|
+
* `not_permitted` is the one that leaves the record in place.
|
|
32
|
+
*/
|
|
24
33
|
readonly failed: readonly SfaBulkRowResult[];
|
|
25
34
|
/**
|
|
26
35
|
* The rows that may already be removed but cannot be reported either way.
|
|
@@ -46,5 +55,9 @@ export interface UseBulkDeleteRecordsResult {
|
|
|
46
55
|
* place while the rest of the batch applies. There is no rollback, so a
|
|
47
56
|
* partly failed batch has already removed records — which is why per-row
|
|
48
57
|
* outcomes are returned as data rather than thrown.
|
|
58
|
+
*
|
|
59
|
+
* Retry only the rows in `failed`, and read each row's `status` first: a
|
|
60
|
+
* `not_found` row was not there to delete and a `not_permitted` one may not be
|
|
61
|
+
* deleted, so neither clears on a resend. Rows in `unresolved` are re-read.
|
|
49
62
|
*/
|
|
50
63
|
export declare function useBulkDeleteRecords(collection: string): UseBulkDeleteRecordsResult;
|
|
@@ -11,6 +11,10 @@ const sendBulkDelete = (client, collection, rows) => client.bulkDelete(collectio
|
|
|
11
11
|
* place while the rest of the batch applies. There is no rollback, so a
|
|
12
12
|
* partly failed batch has already removed records — which is why per-row
|
|
13
13
|
* outcomes are returned as data rather than thrown.
|
|
14
|
+
*
|
|
15
|
+
* Retry only the rows in `failed`, and read each row's `status` first: a
|
|
16
|
+
* `not_found` row was not there to delete and a `not_permitted` one may not be
|
|
17
|
+
* deleted, so neither clears on a resend. Rows in `unresolved` are re-read.
|
|
14
18
|
*/
|
|
15
19
|
export function useBulkDeleteRecords(collection) {
|
|
16
20
|
const { run, results, isLoading, error, reset } = useBulkWrite(collection, sendBulkDelete);
|
|
@@ -21,7 +21,11 @@ export interface UseBulkUpdateRecordsResult {
|
|
|
21
21
|
* without a `version`, re-read it first — there is nothing to lock against.
|
|
22
22
|
*/
|
|
23
23
|
readonly conflicts: readonly SfaBulkRowResult[];
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The rows that did not land and can be sent again, conflicts included.
|
|
26
|
+
* Read each row's `status`: `not_found` and `not_permitted` are settled
|
|
27
|
+
* refusals, and a resend alone will not clear them.
|
|
28
|
+
*/
|
|
25
29
|
readonly failed: readonly SfaBulkRowResult[];
|
|
26
30
|
/**
|
|
27
31
|
* The rows that may already be applied but cannot be reported either way.
|
|
@@ -49,7 +53,9 @@ export interface UseBulkUpdateRecordsResult {
|
|
|
49
53
|
* as data rather than thrown errors.
|
|
50
54
|
*
|
|
51
55
|
* Retry only the rows in `failed`. A conflict is retried with the version from
|
|
52
|
-
* its `record`, never by re-sending the stale one.
|
|
56
|
+
* its `record`, never by re-sending the stale one. A `not_found` or
|
|
57
|
+
* `not_permitted` row is refused again until the record or the permission
|
|
58
|
+
* changes — report it rather than offering a retry. Rows in `unresolved` are
|
|
53
59
|
* re-read, not retried.
|
|
54
60
|
*/
|
|
55
61
|
export declare function useBulkUpdateRecords(collection: string): UseBulkUpdateRecordsResult;
|
|
@@ -13,7 +13,9 @@ const sendBulkUpdate = (client, collection, rows) => client.bulkUpdate(collectio
|
|
|
13
13
|
* as data rather than thrown errors.
|
|
14
14
|
*
|
|
15
15
|
* Retry only the rows in `failed`. A conflict is retried with the version from
|
|
16
|
-
* its `record`, never by re-sending the stale one.
|
|
16
|
+
* its `record`, never by re-sending the stale one. A `not_found` or
|
|
17
|
+
* `not_permitted` row is refused again until the record or the permission
|
|
18
|
+
* changes — report it rather than offering a retry. Rows in `unresolved` are
|
|
17
19
|
* re-read, not retried.
|
|
18
20
|
*/
|
|
19
21
|
export function useBulkUpdateRecords(collection) {
|
|
@@ -35,6 +35,7 @@ export declare class SfaClient implements SfaClientInterface {
|
|
|
35
35
|
/** In-memory cache for describe results, keyed by objectKey. Stores Promises
|
|
36
36
|
* so concurrent callers share a single in-flight request. */
|
|
37
37
|
private readonly describeCache;
|
|
38
|
+
private listObjectsCache;
|
|
38
39
|
constructor(config: SfaClientConfig);
|
|
39
40
|
private resolveBase;
|
|
40
41
|
get(collection: string, recordId: string): Promise<SfaRecord>;
|
|
@@ -59,6 +59,7 @@ export class SfaClient {
|
|
|
59
59
|
/** In-memory cache for describe results, keyed by objectKey. Stores Promises
|
|
60
60
|
* so concurrent callers share a single in-flight request. */
|
|
61
61
|
this.describeCache = new Map();
|
|
62
|
+
this.listObjectsCache = null;
|
|
62
63
|
this.apiBase = this.resolveBase(config.apiBase.replace(/\/+$/, ''), config.mode);
|
|
63
64
|
this.auth = config.auth;
|
|
64
65
|
this.mode = config.mode;
|
|
@@ -174,11 +175,21 @@ export class SfaClient {
|
|
|
174
175
|
}
|
|
175
176
|
listObjects() {
|
|
176
177
|
return __awaiter(this, void 0, void 0, function* () {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
if (this.listObjectsCache)
|
|
179
|
+
return this.listObjectsCache;
|
|
180
|
+
const promise = this.fetchGet(this.objectsPath())
|
|
181
|
+
.then(raw => {
|
|
182
|
+
var _a;
|
|
183
|
+
const data = raw;
|
|
184
|
+
const objects = ((_a = data.objects) !== null && _a !== void 0 ? _a : []).map(o => normalizeObjectSummary(o));
|
|
185
|
+
return { objects };
|
|
186
|
+
})
|
|
187
|
+
.catch((err) => {
|
|
188
|
+
this.listObjectsCache = null;
|
|
189
|
+
throw err;
|
|
190
|
+
});
|
|
191
|
+
this.listObjectsCache = promise;
|
|
192
|
+
return promise;
|
|
182
193
|
});
|
|
183
194
|
}
|
|
184
195
|
listFieldTypes() {
|
|
@@ -1040,6 +1051,8 @@ function readBulkRowStatus(raw) {
|
|
|
1040
1051
|
conflict: 'conflict',
|
|
1041
1052
|
invalid: 'invalid',
|
|
1042
1053
|
not_attempted: 'not_attempted',
|
|
1054
|
+
not_found: 'not_found',
|
|
1055
|
+
not_permitted: 'not_permitted',
|
|
1043
1056
|
indeterminate: 'indeterminate',
|
|
1044
1057
|
};
|
|
1045
1058
|
return typeof raw === 'string' ? (_a = known[raw]) !== null && _a !== void 0 ? _a : 'unknown' : 'unknown';
|
|
@@ -103,13 +103,19 @@ export interface SfaFieldError {
|
|
|
103
103
|
* All are reported per row with HTTP 200 — a bulk call is best-effort and has
|
|
104
104
|
* no all-or-nothing mode.
|
|
105
105
|
*
|
|
106
|
+
* `not_found` and `not_permitted` are refusals of a row the call did reach:
|
|
107
|
+
* the record it named does not exist, or the caller may not write it. No write
|
|
108
|
+
* happened either way, so both are settled failures and reach the caller in
|
|
109
|
+
* `failed`. Sending them again is safe, but returns the same answer until the
|
|
110
|
+
* record or the permission changes — the decision belongs to the server.
|
|
111
|
+
*
|
|
106
112
|
* `unknown` is not sent by the API: it is what the kit reads an unrecognised
|
|
107
113
|
* status as, and what a claimed success it cannot make sense of becomes — no
|
|
108
114
|
* record, or no `version` for the next write to lock against. The row keeps
|
|
109
115
|
* its `id`, and reaches the caller in `unresolved`, never in `failed`:
|
|
110
116
|
* retrying a create that already landed writes a second record.
|
|
111
117
|
*/
|
|
112
|
-
export type SfaBulkRowStatus = 'created' | 'updated' | 'deleted' | 'conflict' | 'invalid' | 'not_attempted' | 'indeterminate' | 'unknown';
|
|
118
|
+
export type SfaBulkRowStatus = 'created' | 'updated' | 'deleted' | 'conflict' | 'invalid' | 'not_attempted' | 'not_found' | 'not_permitted' | 'indeterminate' | 'unknown';
|
|
113
119
|
/** @description One row of a bulk update: what to change and from which version. */
|
|
114
120
|
export interface SfaBulkUpdateRow {
|
|
115
121
|
readonly id: string;
|
|
@@ -146,12 +152,22 @@ export interface SfaBulkRowResult {
|
|
|
146
152
|
* against.
|
|
147
153
|
*/
|
|
148
154
|
readonly record: SfaRecord | null;
|
|
149
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* The problem detail for this row. Set on the statuses that report one:
|
|
157
|
+
* `conflict`, `invalid`, `not_found` and `not_permitted`.
|
|
158
|
+
*/
|
|
150
159
|
readonly error: SfaBulkRowError | null;
|
|
151
160
|
}
|
|
152
161
|
/** @description Why one row failed, in the same shape single writes report. */
|
|
153
162
|
export interface SfaBulkRowError {
|
|
154
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Machine-readable problem code, e.g. `version_mismatch`.
|
|
165
|
+
*
|
|
166
|
+
* NOTE: empty when the response carried none. Keystone has not minted a code
|
|
167
|
+
* for `not_permitted` and widened that field of its contract to any string,
|
|
168
|
+
* so a caller that branches on `code` has to treat `''` as "not stated" and
|
|
169
|
+
* fall back to `status`, rather than assume every failure names itself.
|
|
170
|
+
*/
|
|
155
171
|
readonly code: string;
|
|
156
172
|
readonly message: string;
|
|
157
173
|
/** Per-field errors when the row failed validation. */
|
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.16.0";
|
package/src/lib/version.js
CHANGED