@foir/sdk 0.7.0 → 0.8.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/CHANGELOG.md +805 -4
- package/README.md +46 -15
- package/dist/auth/auth.d.ts +104 -1
- package/dist/auth/index.d.ts +12 -6
- package/dist/auth/operations.d.ts +85 -64
- package/dist/auth/ops.d.ts +99 -0
- package/dist/auth.js +775 -106
- package/dist/chunk-IGNLWT3H.js +35 -0
- package/dist/chunk-OHYVKTGB.js +45 -0
- package/dist/chunk-TAL5H6VA.js +39 -0
- package/dist/client.js +25 -31
- package/dist/codegen/block-registry.d.ts +13 -19
- package/dist/codegen/block-registry.js +3 -31
- package/dist/codegen/block-scaffold.d.ts +12 -21
- package/dist/codegen/block-scaffold.js +3 -41
- package/dist/codegen/index.d.ts +9 -4
- package/dist/codegen/index.js +7 -11
- package/dist/codegen/item-registry.d.ts +28 -0
- package/dist/codegen/item-registry.js +7 -0
- package/dist/codegen/item-scaffold.d.ts +29 -0
- package/dist/codegen/item-scaffold.js +7 -0
- package/dist/codegen/preset.d.ts +14 -7
- package/dist/codegen/shared.d.ts +20 -9
- package/dist/content/content-renderer.d.ts +90 -24
- package/dist/content/index.d.ts +13 -7
- package/dist/content/richtext.d.ts +66 -18
- package/dist/content.js +227 -80
- package/dist/data/builtins.d.ts +302 -92
- package/dist/data/client.d.ts +97 -25
- package/dist/data/document.d.ts +251 -0
- package/dist/data/index.d.ts +38 -19
- package/dist/data/manifest.d.ts +266 -3
- package/dist/data/schema.d.ts +78 -8
- package/dist/data/selection.d.ts +231 -37
- package/dist/data/subjects.d.ts +221 -0
- package/dist/data/subscription-document.d.ts +75 -0
- package/dist/data/subscriptions.d.ts +181 -0
- package/dist/data/transport.d.ts +255 -37
- package/dist/data/ws-transport.d.ts +197 -0
- package/dist/data.js +1419 -45
- package/dist/graphql/cdn-route.d.ts +12 -28
- package/dist/media/index.d.ts +6 -2
- package/dist/media/media.d.ts +2 -2
- package/dist/media/screen-capture.d.ts +81 -0
- package/dist/media.js +97 -0
- package/dist/next.js +663 -182
- package/dist/push.js +700 -90
- package/dist/react/FoirFeedback.d.ts +62 -0
- package/dist/react/feedback-notes.d.ts +101 -0
- package/dist/react/index.d.ts +7 -1
- package/dist/react.js +1272 -194
- package/dist/realtime.js +1 -1
- package/dist/server/events-client.d.ts +279 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server.js +298 -0
- package/dist/transport/foir-http.d.ts +23 -6
- package/dist/transport/persisted-query.d.ts +68 -0
- package/dist/transport/retry.d.ts +1 -1
- package/package.json +13 -1
- package/dist/chunk-ADO5FJNM.js +0 -30
package/dist/react.js
CHANGED
|
@@ -74,27 +74,6 @@ var FoirRequestError = class extends Error {
|
|
|
74
74
|
return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
|
-
function readFoirRateLimit(headers) {
|
|
78
|
-
const raw = {
|
|
79
|
-
limit: headers.get("X-RateLimit-Limit"),
|
|
80
|
-
remaining: headers.get("X-RateLimit-Remaining"),
|
|
81
|
-
resetAt: headers.get("X-RateLimit-Reset")
|
|
82
|
-
};
|
|
83
|
-
if (raw.limit === null || raw.remaining === null || raw.resetAt === null) {
|
|
84
|
-
return void 0;
|
|
85
|
-
}
|
|
86
|
-
const limit = Number(raw.limit);
|
|
87
|
-
const remaining = Number(raw.remaining);
|
|
88
|
-
const resetAt = Number(raw.resetAt);
|
|
89
|
-
if (!Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(resetAt)) {
|
|
90
|
-
return void 0;
|
|
91
|
-
}
|
|
92
|
-
return { limit, remaining, resetAt };
|
|
93
|
-
}
|
|
94
|
-
function readFoirRetryAfterMs(headers) {
|
|
95
|
-
const seconds = Number(headers.get("Retry-After"));
|
|
96
|
-
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
97
|
-
}
|
|
98
77
|
function buildFoirHeaders(input) {
|
|
99
78
|
const headers = {
|
|
100
79
|
"Content-Type": "application/json",
|
|
@@ -113,6 +92,13 @@ function buildFoirHeaders(input) {
|
|
|
113
92
|
}
|
|
114
93
|
return headers;
|
|
115
94
|
}
|
|
95
|
+
function foirApiEndpoint(apiUrl, path) {
|
|
96
|
+
const base = apiUrl.trim().replace(/\/+$/, "").replace(/\/graphql$/i, "");
|
|
97
|
+
if (!base) {
|
|
98
|
+
throw new FoirRequestError("[foir] apiUrl is empty \u2014 pass the Foir API origin");
|
|
99
|
+
}
|
|
100
|
+
return base + path;
|
|
101
|
+
}
|
|
116
102
|
function readFoirEnvelope(json) {
|
|
117
103
|
if (json.errors?.length) {
|
|
118
104
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
@@ -130,18 +116,14 @@ function readFoirEnvelope(json) {
|
|
|
130
116
|
return json.data;
|
|
131
117
|
}
|
|
132
118
|
|
|
133
|
-
// src/
|
|
134
|
-
var
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
if (isFoirPreview(env)) return false;
|
|
138
|
-
const apiKey = resolveFoirApiKey(env);
|
|
139
|
-
if (!apiKey?.startsWith("pk_")) return false;
|
|
140
|
-
return !/\bmutation\b/.test(documentText);
|
|
119
|
+
// src/transport/persisted-query.ts
|
|
120
|
+
var FOIR_CDN_GRAPHQL_PATH = "/cdn/graphql";
|
|
121
|
+
function persistedQueryExtension(hash) {
|
|
122
|
+
return { persistedQuery: { version: 1, sha256Hash: hash } };
|
|
141
123
|
}
|
|
142
124
|
function buildCdnUrl(endpoint, apiKey, hash, variables) {
|
|
143
125
|
const url = new URL(endpoint);
|
|
144
|
-
url.pathname = url.pathname.replace(/\/graphql\/?$/, "") +
|
|
126
|
+
url.pathname = url.pathname.replace(/\/graphql\/?$/, "") + FOIR_CDN_GRAPHQL_PATH;
|
|
145
127
|
url.searchParams.set("key", apiKey);
|
|
146
128
|
url.searchParams.set("extensions", JSON.stringify(persistedQueryExtension(hash)));
|
|
147
129
|
if (variables && Object.keys(variables).length > 0) {
|
|
@@ -149,189 +131,688 @@ function buildCdnUrl(endpoint, apiKey, hash, variables) {
|
|
|
149
131
|
}
|
|
150
132
|
return url.toString();
|
|
151
133
|
}
|
|
152
|
-
function persistedQueryExtension(hash) {
|
|
153
|
-
return { persistedQuery: { version: 1, sha256Hash: hash } };
|
|
154
|
-
}
|
|
155
134
|
async function documentHash(documentText) {
|
|
156
|
-
const digest = await crypto.subtle.digest(
|
|
157
|
-
"SHA-256",
|
|
158
|
-
new TextEncoder().encode(documentText)
|
|
159
|
-
);
|
|
135
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(documentText));
|
|
160
136
|
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
161
137
|
}
|
|
162
138
|
var PERSISTED_QUERY_NOT_FOUND = "PERSISTED_QUERY_NOT_FOUND";
|
|
163
139
|
var NOT_SHAREABLE = "NOT_SHAREABLE";
|
|
140
|
+
async function hasFoirErrorCode(res, ...codes) {
|
|
141
|
+
try {
|
|
142
|
+
const body = await res.clone().json();
|
|
143
|
+
return body.errors?.some((e) => e.extensions?.code !== void 0 && codes.includes(e.extensions.code)) ?? false;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
164
148
|
|
|
165
|
-
// src/
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
149
|
+
// src/data/document.ts
|
|
150
|
+
function buildPathTrie(paths) {
|
|
151
|
+
const root = { self: false, children: /* @__PURE__ */ new Map() };
|
|
152
|
+
for (const path of paths) {
|
|
153
|
+
let node = root;
|
|
154
|
+
for (const segment of path.split(".")) {
|
|
155
|
+
if (!segment) continue;
|
|
156
|
+
let next = node.children.get(segment);
|
|
157
|
+
if (!next) {
|
|
158
|
+
next = { self: false, children: /* @__PURE__ */ new Map() };
|
|
159
|
+
node.children.set(segment, next);
|
|
160
|
+
}
|
|
161
|
+
node = next;
|
|
162
|
+
}
|
|
163
|
+
node.self = true;
|
|
164
|
+
}
|
|
165
|
+
return root;
|
|
166
|
+
}
|
|
167
|
+
function childFor(node, field) {
|
|
168
|
+
return node.children.get(field.key) ?? (field.alias ? node.children.get(field.alias) : void 0);
|
|
169
|
+
}
|
|
170
|
+
function segmentFor(node, field) {
|
|
171
|
+
return node.children.has(field.key) ? field.key : field.alias ?? field.key;
|
|
172
|
+
}
|
|
173
|
+
var PROTECTED_ROOT_FIELD = "_id";
|
|
174
|
+
var MAX_QUERY_COMPLEXITY = 1e4;
|
|
175
|
+
var MAX_EMITTED_FIELDS = 4 * MAX_QUERY_COMPLEXITY;
|
|
176
|
+
var newBudget = () => ({ fields: 0, emitted: 0, stopped: false });
|
|
177
|
+
function charge(ctx, n = 1) {
|
|
178
|
+
ctx.budget.fields += n;
|
|
179
|
+
ctx.budget.emitted += n;
|
|
180
|
+
}
|
|
181
|
+
function mayExpand(ctx, depth) {
|
|
182
|
+
if (depth <= 1) return false;
|
|
183
|
+
if (ctx.budget.emitted > MAX_EMITTED_FIELDS) {
|
|
184
|
+
ctx.budget.stopped = true;
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
function printFields(fields, ctx, omit, pick, isRoot, path, depth) {
|
|
190
|
+
const parts = [];
|
|
191
|
+
for (const field of fields) {
|
|
192
|
+
if (field.relation) {
|
|
193
|
+
const named = pick ? childFor(pick, field) : void 0;
|
|
194
|
+
if (named) {
|
|
195
|
+
const segment = segmentFor(pick, field);
|
|
196
|
+
throw new FoirRequestError(
|
|
197
|
+
`[foir] pick "${path}${segment}": "${segment}" is a relation, and relations are not part of the default document \u2014 no pick can select one. ` + (path ? (
|
|
198
|
+
// Nested: there is no `include` form that reaches here at all.
|
|
199
|
+
// `include` addresses the ROOT model's own relations, and (nested)
|
|
200
|
+
// a related model's — never one reached through a composite or a
|
|
201
|
+
// union member. Saying "use include" would send the caller to a
|
|
202
|
+
// call that refuses them a second time.
|
|
203
|
+
`A relation at this position cannot be reached by \`include\` either \u2014 that addresses the model's own relations, not one inside a composite or a union member. Read it through the field that owns it, or drop "${path}${segment}" from the pick`
|
|
204
|
+
) : `Use include: { ${segment}: true } instead`)
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const protectedField = isRoot && field.key === PROTECTED_ROOT_FIELD;
|
|
210
|
+
const omitChild = omit ? childFor(omit, field) : void 0;
|
|
211
|
+
if (omitChild?.self && !protectedField) continue;
|
|
212
|
+
let pickChild;
|
|
213
|
+
if (pick) {
|
|
214
|
+
pickChild = childFor(pick, field);
|
|
215
|
+
if (!pickChild && !protectedField) continue;
|
|
216
|
+
if (pickChild?.self) pickChild = void 0;
|
|
217
|
+
}
|
|
218
|
+
const head = field.alias ? `${field.alias}: ${field.key}` : field.key;
|
|
219
|
+
const childPath = `${path}${pick && childFor(pick, field) ? segmentFor(pick, field) : field.key}.`;
|
|
220
|
+
if (field.truncated) {
|
|
221
|
+
if (field.union && mayExpand(ctx, depth)) {
|
|
222
|
+
const body = printUnion(field.union, ctx, omitChild, pickChild, childPath, depth - 1);
|
|
223
|
+
if (body) {
|
|
224
|
+
charge(ctx);
|
|
225
|
+
parts.push(`${head} ${body}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
charge(ctx, 2);
|
|
230
|
+
parts.push(`${head} { __typename }`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (field.model) {
|
|
234
|
+
const body = printModelTree(field.model, ctx, depth);
|
|
235
|
+
if (body) {
|
|
236
|
+
charge(ctx);
|
|
237
|
+
parts.push(`${head} { ${body} }`);
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (field.union) {
|
|
242
|
+
const body = printUnion(field.union, ctx, omitChild, pickChild, childPath, depth);
|
|
243
|
+
if (body) {
|
|
244
|
+
charge(ctx);
|
|
245
|
+
parts.push(`${head} ${body}`);
|
|
246
|
+
}
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (field.fields?.length) {
|
|
250
|
+
const body = printFields(field.fields, ctx, omitChild, pickChild, false, childPath, depth);
|
|
251
|
+
if (body) {
|
|
252
|
+
charge(ctx);
|
|
253
|
+
parts.push(`${head} { ${body} }`);
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
charge(ctx);
|
|
258
|
+
parts.push(head);
|
|
259
|
+
}
|
|
260
|
+
return parts.join(" ");
|
|
261
|
+
}
|
|
262
|
+
function printModelTree(modelKey, ctx, depth) {
|
|
263
|
+
const model = ctx.source.models[modelKey];
|
|
264
|
+
if (!model?.selection) {
|
|
265
|
+
throw new FoirRequestError(
|
|
266
|
+
`[foir] the result shape references the model "${modelKey}", which publishes no selection \u2014 re-run \`foir types\``
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const narrowing = ctx.record?.modelKey === modelKey ? ctx.record : void 0;
|
|
270
|
+
const body = printFields(
|
|
271
|
+
model.selection.fields,
|
|
272
|
+
ctx,
|
|
273
|
+
narrowing?.omit,
|
|
274
|
+
narrowing?.pick,
|
|
275
|
+
true,
|
|
276
|
+
"",
|
|
277
|
+
depth
|
|
278
|
+
);
|
|
279
|
+
const includes = narrowing?.include ? printIncludes(ctx.source, model, modelKey, narrowing.include, ctx, depth) : "";
|
|
280
|
+
if (!body) {
|
|
281
|
+
return includes;
|
|
282
|
+
}
|
|
283
|
+
return includes ? `${body} ${includes}` : body;
|
|
284
|
+
}
|
|
285
|
+
var NO_FIELDS = { self: false, children: /* @__PURE__ */ new Map() };
|
|
286
|
+
function addressesMembers(node, union) {
|
|
287
|
+
for (const member of union.members) {
|
|
288
|
+
if (node.children.has(member.typeName)) return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
function printUnion(unionName, ctx, omit, pick, path, depth) {
|
|
293
|
+
const union = ctx.unions[unionName];
|
|
294
|
+
if (!union) {
|
|
295
|
+
throw new FoirRequestError(
|
|
296
|
+
`[foir] the selection tree names the union "${unionName}", which is not in selectionUnions \u2014 re-run \`foir types\`, or pass selectionUnions alongside selections`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const perVariantPick = pick !== void 0 && addressesMembers(pick, union);
|
|
300
|
+
const perVariantOmit = omit !== void 0 && addressesMembers(omit, union);
|
|
301
|
+
charge(ctx);
|
|
302
|
+
const parts = ["__typename"];
|
|
303
|
+
const chargeBefore = ctx.budget.fields;
|
|
304
|
+
let widestMember = 0;
|
|
305
|
+
for (const member of union.members) {
|
|
306
|
+
let memberPick = pick;
|
|
307
|
+
if (perVariantPick) {
|
|
308
|
+
const named = pick.children.get(member.typeName);
|
|
309
|
+
memberPick = named === void 0 ? NO_FIELDS : named.self ? void 0 : named;
|
|
310
|
+
}
|
|
311
|
+
let memberOmit = omit;
|
|
312
|
+
if (perVariantOmit) {
|
|
313
|
+
const named = omit.children.get(member.typeName);
|
|
314
|
+
if (named?.self) continue;
|
|
315
|
+
memberOmit = named;
|
|
316
|
+
}
|
|
317
|
+
const memberPath = perVariantPick ? `${path}${member.typeName}.` : path;
|
|
318
|
+
const memberStart = ctx.budget.fields;
|
|
319
|
+
const body = printFields(member.fields, ctx, memberOmit, memberPick, false, memberPath, depth);
|
|
320
|
+
const memberCost = ctx.budget.fields - memberStart;
|
|
321
|
+
ctx.budget.fields = memberStart;
|
|
322
|
+
if (memberCost > widestMember) widestMember = memberCost;
|
|
323
|
+
if (body) parts.push(`... on ${member.typeName} { ${body} }`);
|
|
324
|
+
}
|
|
325
|
+
ctx.budget.fields = chargeBefore + widestMember;
|
|
326
|
+
return `{ ${parts.join(" ")} }`;
|
|
327
|
+
}
|
|
328
|
+
function printIncludes(source, model, modelKey, include, ctx, depth) {
|
|
329
|
+
const parts = [];
|
|
330
|
+
for (const [key, value] of Object.entries(include)) {
|
|
331
|
+
if (value === void 0 || value === false) continue;
|
|
332
|
+
const relation = model.relations?.find((r) => r.key === key);
|
|
333
|
+
if (!relation) {
|
|
334
|
+
throw new FoirRequestError(
|
|
335
|
+
`[foir] include "${key}": ${modelKey} has no such relation \u2014 re-run \`foir types\` if the model has changed`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
const nested = value && typeof value === "object" && "include" in value ? value["include"] : void 0;
|
|
339
|
+
const body = relation.targets?.length ? printPolymorphicRelation(source, relation, ctx, nested, depth) : printRelationTarget(source, relation.target ?? "", ctx, nested, relation.key, depth);
|
|
340
|
+
charge(ctx, relation.connection ? 1 + CONNECTION_WRAPPER_FIELDS : 1);
|
|
341
|
+
parts.push(`${key} ${relation.connection ? wrapRelationConnection(body) : `{ ${body} }`}`);
|
|
342
|
+
}
|
|
343
|
+
return parts.join(" ");
|
|
344
|
+
}
|
|
345
|
+
var CONNECTION_WRAPPER_FIELDS = 9;
|
|
346
|
+
function wrapRelationConnection(body) {
|
|
347
|
+
return `{ edges { cursor node { ${body} } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } totalCount }`;
|
|
348
|
+
}
|
|
349
|
+
function printRelationTarget(source, targetKey, ctx, nested, relationKey, depth) {
|
|
350
|
+
const target = source.models[targetKey];
|
|
351
|
+
if (!target?.selection) {
|
|
352
|
+
throw new FoirRequestError(
|
|
353
|
+
`[foir] include "${relationKey}": no selection published for its target model "${targetKey}" \u2014 re-run \`foir types\``
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const bare = { source, unions: ctx.unions, budget: ctx.budget };
|
|
357
|
+
const body = printFields(target.selection.fields, bare, void 0, void 0, true, "", depth);
|
|
358
|
+
const includes = nested ? printIncludes(source, target, targetKey, nested, bare, depth) : "";
|
|
359
|
+
return includes ? `${body} ${includes}` : body;
|
|
360
|
+
}
|
|
361
|
+
function printPolymorphicRelation(source, relation, ctx, nested, depth) {
|
|
362
|
+
charge(ctx);
|
|
363
|
+
const parts = ["__typename"];
|
|
364
|
+
const chargeBefore = ctx.budget.fields;
|
|
365
|
+
let widestTarget = 0;
|
|
366
|
+
for (const targetKey of relation.targets ?? []) {
|
|
367
|
+
const target = source.models[targetKey];
|
|
368
|
+
if (!target?.selection) {
|
|
369
|
+
throw new FoirRequestError(
|
|
370
|
+
`[foir] include "${relation.key}": no selection published for its target model "${targetKey}" \u2014 re-run \`foir types\``
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
const targetStart = ctx.budget.fields;
|
|
374
|
+
const body = printRelationTarget(source, targetKey, ctx, nested, relation.key, depth);
|
|
375
|
+
const targetCost = ctx.budget.fields - targetStart;
|
|
376
|
+
ctx.budget.fields = targetStart;
|
|
377
|
+
if (targetCost > widestTarget) widestTarget = targetCost;
|
|
378
|
+
if (body) parts.push(`... on ${target.typeName} { ${body} }`);
|
|
379
|
+
}
|
|
380
|
+
ctx.budget.fields = chargeBefore + widestTarget;
|
|
381
|
+
return parts.join(" ");
|
|
382
|
+
}
|
|
383
|
+
var GRAPHQL_TYPE_REF = /^\[?\[?[_A-Za-z][_0-9A-Za-z]*!?\]?!?\]?!?$/;
|
|
384
|
+
function buildArguments(args, op, opLabel) {
|
|
385
|
+
const declared = op.args;
|
|
386
|
+
const definitions = [];
|
|
387
|
+
const call = [];
|
|
388
|
+
const variables = {};
|
|
389
|
+
for (const name of Object.keys(args).sort()) {
|
|
390
|
+
const value = args[name];
|
|
391
|
+
if (value === void 0) continue;
|
|
392
|
+
if (!declared?.includes(name)) {
|
|
393
|
+
throw new FoirRequestError(
|
|
394
|
+
`[foir] ${opLabel} does not take an argument "${name}" \u2014 it takes ${declared?.length ? declared.join(", ") : "no arguments"}`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const type = op.argTypes?.[name];
|
|
398
|
+
if (!type) {
|
|
399
|
+
throw new FoirRequestError(
|
|
400
|
+
`[foir] ${opLabel}: the manifest publishes no type for the argument "${name}", so it cannot be sent as a variable \u2014 and inferring one from the value would guess wrong on every enum. Re-run \`foir types\` against a server that publishes argTypes, or use foir.raw() with your own variable definitions`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (!GRAPHQL_TYPE_REF.test(type)) {
|
|
404
|
+
throw new FoirRequestError(
|
|
405
|
+
`[foir] ${opLabel}: the manifest gives the argument "${name}" the type "${type}", which is not a GraphQL type reference`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
definitions.push(`$${name}: ${type}`);
|
|
409
|
+
call.push(`${name}: $${name}`);
|
|
410
|
+
variables[name] = toVariableValue(value, `${opLabel} argument "${name}"`);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
definitions: definitions.length ? `(${definitions.join(", ")})` : "",
|
|
414
|
+
call: call.length ? `(${call.join(", ")})` : "",
|
|
415
|
+
variables
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function toVariableValue(value, label) {
|
|
419
|
+
if (value === null) return null;
|
|
420
|
+
const kind = typeof value;
|
|
421
|
+
if (kind === "string" || kind === "boolean") return value;
|
|
422
|
+
if (kind === "number") {
|
|
423
|
+
if (!Number.isFinite(value)) {
|
|
424
|
+
throw new FoirRequestError(
|
|
425
|
+
`[foir] ${label} is ${String(value)}, which has no JSON form and cannot be sent`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return value;
|
|
429
|
+
}
|
|
430
|
+
if (kind === "bigint") {
|
|
431
|
+
const asNumber = Number(value);
|
|
432
|
+
if (!Number.isSafeInteger(asNumber)) {
|
|
433
|
+
throw new FoirRequestError(
|
|
434
|
+
`[foir] ${label} is the bigint ${String(value)}, which is outside the range JSON can carry exactly`
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
return asNumber;
|
|
438
|
+
}
|
|
439
|
+
if (value instanceof Date) {
|
|
440
|
+
if (Number.isNaN(value.getTime())) {
|
|
441
|
+
throw new FoirRequestError(`[foir] ${label} is an invalid Date`);
|
|
442
|
+
}
|
|
443
|
+
return value.toISOString();
|
|
444
|
+
}
|
|
445
|
+
if (Array.isArray(value)) {
|
|
446
|
+
return value.filter((item) => item !== void 0).map((item, index) => toVariableValue(item, `${label}[${index}]`));
|
|
447
|
+
}
|
|
448
|
+
if (kind === "object") {
|
|
449
|
+
const out = {};
|
|
450
|
+
for (const [key, child] of Object.entries(value)) {
|
|
451
|
+
if (child === void 0) continue;
|
|
452
|
+
out[key] = toVariableValue(child, `${label}.${key}`);
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
throw new FoirRequestError(
|
|
457
|
+
`[foir] ${label} is a ${kind}, which has no JSON form and cannot be sent`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
function payloadRecordPaths(payload) {
|
|
461
|
+
if (payload.kind === "record") {
|
|
462
|
+
return payload.model ? [{ path: [], modelKey: payload.model }] : [];
|
|
463
|
+
}
|
|
464
|
+
const found = [];
|
|
465
|
+
const visit = (fields, prefix) => {
|
|
466
|
+
for (const field of fields ?? []) {
|
|
467
|
+
const at = [...prefix, field.alias ?? field.key];
|
|
468
|
+
if (field.model) found.push({ path: at, modelKey: field.model });
|
|
469
|
+
visit(field.fields, at);
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
visit(payload.fields, []);
|
|
473
|
+
return found;
|
|
474
|
+
}
|
|
475
|
+
function printPayload(payload, ctx, root, opLabel, depth) {
|
|
476
|
+
switch (payload.kind) {
|
|
477
|
+
case "leaf":
|
|
478
|
+
return void 0;
|
|
479
|
+
case "record": {
|
|
480
|
+
if (!payload.model) {
|
|
481
|
+
throw new FoirRequestError(
|
|
482
|
+
`[foir] ${opLabel}: the result shape says it is a record but names no model \u2014 re-run \`foir types\``
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return printModelTree(payload.model, ctx, depth);
|
|
486
|
+
}
|
|
487
|
+
case "union": {
|
|
488
|
+
if (!payload.union) {
|
|
489
|
+
throw new FoirRequestError(
|
|
490
|
+
`[foir] ${opLabel}: the result shape says it is a union but names none \u2014 re-run \`foir types\``
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return printUnion(payload.union, ctx, root.omit, root.pick, "", depth).slice(2, -2);
|
|
494
|
+
}
|
|
495
|
+
case "object": {
|
|
496
|
+
if (!payload.fields?.length) {
|
|
497
|
+
throw new FoirRequestError(
|
|
498
|
+
`[foir] ${opLabel}: the result shape is an object with no fields \u2014 re-run \`foir types\``
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return printFields(payload.fields, ctx, root.omit, root.pick, false, "", depth);
|
|
502
|
+
}
|
|
503
|
+
default:
|
|
504
|
+
throw new FoirRequestError(
|
|
505
|
+
`[foir] ${opLabel}: the result shape has the kind "${String(payload.kind)}", which this client does not know how to print \u2014 upgrade @foir/sdk`
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
var DEFAULT_DEPTH = 1;
|
|
510
|
+
function planFoirNamespaceCall(source, call) {
|
|
511
|
+
const namespace = source.namespaces?.[call.namespace];
|
|
512
|
+
if (!namespace) {
|
|
513
|
+
throw new FoirRequestError(
|
|
514
|
+
`[foir] no namespace "${call.namespace}" in this credential's surface \u2014 re-run \`foir types\``
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
const opDef = namespace.ops?.[call.op];
|
|
518
|
+
if (!opDef) {
|
|
519
|
+
throw new FoirRequestError(
|
|
520
|
+
`[foir] the namespace "${call.namespace}" has no op "${call.op}" for this credential \u2014 re-run \`foir types\``
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
return planCall(
|
|
524
|
+
source,
|
|
525
|
+
opDef,
|
|
526
|
+
`${call.namespace}.${call.op}`,
|
|
527
|
+
operationName(call.namespace, call.op),
|
|
528
|
+
call
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
function planCall(source, opDef, opLabel, name, call) {
|
|
532
|
+
const payload = opDef.payload;
|
|
533
|
+
if (!payload) {
|
|
534
|
+
throw new FoirRequestError(
|
|
535
|
+
`[foir] ${opLabel}: the manifest publishes no result shape for this op, so there is nothing to print \u2014 and synthesising one client-side is exactly the guess this client refuses to make. Re-run \`foir types\` against a server that publishes op payloads`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
if (payload.error) {
|
|
539
|
+
throw new FoirRequestError(
|
|
540
|
+
`[foir] ${opLabel}: the server could not resolve a result shape for this op: ${payload.error}`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (call.omit && call.pick) {
|
|
544
|
+
throw new FoirRequestError(
|
|
545
|
+
`[foir] ${opLabel}: "omit" and "pick" are mutually exclusive \u2014 pass one or the other`
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
if (call.omit?.includes(PROTECTED_ROOT_FIELD)) {
|
|
549
|
+
throw new FoirRequestError(
|
|
550
|
+
`[foir] omit "${PROTECTED_ROOT_FIELD}": "${PROTECTED_ROOT_FIELD}" is required for correctness and cannot be removed`
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const depth = call.depth ?? DEFAULT_DEPTH;
|
|
554
|
+
if (!Number.isInteger(depth) || depth < DEFAULT_DEPTH) {
|
|
170
555
|
throw new FoirRequestError(
|
|
171
|
-
|
|
556
|
+
`[foir] ${opLabel}: depth must be a whole number of union levels, ${DEFAULT_DEPTH} or more \u2014 got ${String(call.depth)}. ${DEFAULT_DEPTH} is the complete-by-default document; each level above it expands one further union`
|
|
172
557
|
);
|
|
173
558
|
}
|
|
559
|
+
const omit = call.omit?.length ? buildPathTrie(call.omit) : void 0;
|
|
560
|
+
const pick = call.pick?.length ? buildPathTrie(call.pick) : void 0;
|
|
561
|
+
const include = call.include && Object.keys(call.include).length ? call.include : void 0;
|
|
562
|
+
const narrowed = Boolean(omit || pick || include);
|
|
563
|
+
const recordPaths = payloadRecordPaths(payload);
|
|
564
|
+
const records = new Set(recordPaths.map((entry) => entry.modelKey));
|
|
565
|
+
if (records.size > 1 && narrowed) {
|
|
566
|
+
throw new FoirRequestError(
|
|
567
|
+
`[foir] ${opLabel}: the result carries records of more than one model (${[...records].sort().join(", ")}), so "include"/"pick"/"omit" do not say which one they mean \u2014 call it without them and narrow with foir.raw()`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
const recordModel = records.size === 1 ? [...records][0] : void 0;
|
|
571
|
+
if (include && !recordModel) {
|
|
572
|
+
throw new FoirRequestError(
|
|
573
|
+
`[foir] ${opLabel}: "include" names relations of a record, and this op returns none`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
const ctx = {
|
|
577
|
+
source,
|
|
578
|
+
unions: source.selectionUnions ?? {},
|
|
579
|
+
record: recordModel ? { modelKey: recordModel, omit, pick, include } : void 0,
|
|
580
|
+
budget: newBudget()
|
|
581
|
+
};
|
|
582
|
+
const root = recordModel ? {} : { omit, pick };
|
|
583
|
+
const selection = printPayload(payload, ctx, root, opLabel, depth);
|
|
584
|
+
if (selection === "") {
|
|
585
|
+
throw new FoirRequestError(
|
|
586
|
+
`[foir] ${opLabel}: the selection is empty after applying pick/omit \u2014 a GraphQL selection set cannot be empty`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
charge(ctx);
|
|
590
|
+
if (depth > DEFAULT_DEPTH && (ctx.budget.stopped || ctx.budget.fields > MAX_QUERY_COMPLEXITY)) {
|
|
591
|
+
const reason = ctx.budget.stopped ? `expands to more than ${MAX_EMITTED_FIELDS} fields to print, which is past what this client will build in one call` : `makes this document select ${ctx.budget.fields} fields, over the server's limit of ${MAX_QUERY_COMPLEXITY}`;
|
|
592
|
+
throw new FoirRequestError(
|
|
593
|
+
`[foir] ${opLabel}: depth ${depth} ${reason}. Each level re-expands every stopping point inside the expansion, so spend the level on less: narrow with "pick"/"omit" \u2014 including per-member, pick: { <field>: { on: { <MemberType>: [...] } } } \u2014 or ask for depth ${depth - 1}`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const args = buildArguments(call.args, opDef, opLabel);
|
|
597
|
+
const keyword = opDef.mutation ? "mutation" : "query";
|
|
598
|
+
const body = selection === void 0 ? "" : ` { ${selection} }`;
|
|
599
|
+
return {
|
|
600
|
+
document: `${keyword} ${name}${args.definitions} { ${opDef.field}${args.call}${body} }`,
|
|
601
|
+
variables: args.variables,
|
|
602
|
+
mutation: opDef.mutation === true,
|
|
603
|
+
rootField: opDef.field,
|
|
604
|
+
fields: ctx.budget.fields,
|
|
605
|
+
records: recordPaths
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
function operationName(scope, op) {
|
|
609
|
+
const parts = `${scope} ${op}`.split(/[^0-9A-Za-z]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1));
|
|
610
|
+
const name = parts.join("");
|
|
611
|
+
return /^[_A-Za-z]/.test(name) ? name : `Foir${name}`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// src/data/transport.ts
|
|
615
|
+
function foirDataEndpoint(apiUrl, path) {
|
|
616
|
+
return foirApiEndpoint(apiUrl, path);
|
|
617
|
+
}
|
|
618
|
+
async function resolveBearerToken(config) {
|
|
619
|
+
const source = config.bearerToken;
|
|
620
|
+
if (typeof source === "function") return await source() ?? void 0;
|
|
621
|
+
return source ?? void 0;
|
|
622
|
+
}
|
|
623
|
+
function foirRoute(input) {
|
|
624
|
+
if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
|
|
625
|
+
if (input.bearerToken) return { lane: "origin", reason: "bearer" };
|
|
626
|
+
if (input.mutation) return { lane: "origin", reason: "operation" };
|
|
627
|
+
if (input.channel === "draft") return { lane: "origin", reason: "channel" };
|
|
628
|
+
return { lane: "shared" };
|
|
629
|
+
}
|
|
630
|
+
async function executeFoirDocument(config, plan) {
|
|
631
|
+
const data = await runFoirDocument(config, plan.document, plan.mutation, plan.variables);
|
|
632
|
+
if (!(plan.rootField in data)) {
|
|
633
|
+
throw new FoirRequestError(
|
|
634
|
+
`[foir] the response has no "${plan.rootField}" field` + (Object.keys(data).length ? ` (got ${Object.keys(data).join(", ")})` : "")
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return data[plan.rootField];
|
|
638
|
+
}
|
|
639
|
+
async function runFoirDocument(config, document2, mutation, variables) {
|
|
640
|
+
const doFetch = config.fetch ?? globalThis.fetch;
|
|
641
|
+
const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
|
|
642
|
+
const bearerToken = await resolveBearerToken(config);
|
|
174
643
|
const headers = buildFoirHeaders({
|
|
175
|
-
apiKey,
|
|
176
|
-
draftChannel:
|
|
177
|
-
bearerToken
|
|
644
|
+
apiKey: config.apiKey,
|
|
645
|
+
draftChannel: config.channel === "draft",
|
|
646
|
+
bearerToken,
|
|
647
|
+
extra: config.headers
|
|
648
|
+
});
|
|
649
|
+
const hash = await documentHash(document2);
|
|
650
|
+
const route = foirRoute({
|
|
651
|
+
apiKey: config.apiKey,
|
|
652
|
+
bearerToken,
|
|
653
|
+
mutation,
|
|
654
|
+
channel: config.channel
|
|
655
|
+
});
|
|
656
|
+
const post = (body) => doFetch(endpoint, {
|
|
657
|
+
method: "POST",
|
|
658
|
+
headers,
|
|
659
|
+
body: JSON.stringify(body),
|
|
660
|
+
...config.requestInit
|
|
178
661
|
});
|
|
179
|
-
const
|
|
662
|
+
const extensions = persistedQueryExtension(hash);
|
|
180
663
|
let res;
|
|
181
664
|
try {
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
|
|
665
|
+
if (route.lane === "shared") {
|
|
666
|
+
res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
|
|
185
667
|
method: "GET",
|
|
186
|
-
headers
|
|
668
|
+
headers,
|
|
669
|
+
...config.requestInit
|
|
187
670
|
});
|
|
188
|
-
if (await
|
|
189
|
-
|
|
190
|
-
method: "POST",
|
|
191
|
-
headers,
|
|
192
|
-
body: JSON.stringify({
|
|
193
|
-
query: documentText,
|
|
194
|
-
variables,
|
|
195
|
-
extensions: persistedQueryExtension(hash)
|
|
196
|
-
})
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
if (await isSharedRefusal(shared)) {
|
|
200
|
-
res = await fetch(endpoint, {
|
|
201
|
-
method: "POST",
|
|
202
|
-
headers,
|
|
203
|
-
body: JSON.stringify({ query: documentText, variables })
|
|
204
|
-
});
|
|
205
|
-
} else {
|
|
206
|
-
res = shared;
|
|
671
|
+
if (await hasFoirErrorCode(res, PERSISTED_QUERY_NOT_FOUND, NOT_SHAREABLE)) {
|
|
672
|
+
res = await post({ query: document2, variables, extensions });
|
|
207
673
|
}
|
|
208
674
|
} else {
|
|
209
|
-
res = await
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
});
|
|
675
|
+
res = await post({ variables, extensions });
|
|
676
|
+
if (await hasFoirErrorCode(res, PERSISTED_QUERY_NOT_FOUND)) {
|
|
677
|
+
res = await post({ query: document2, variables, extensions });
|
|
678
|
+
}
|
|
214
679
|
}
|
|
215
680
|
} catch (err) {
|
|
216
681
|
throw new FoirRequestError("[foir] network error", { cause: err });
|
|
217
682
|
}
|
|
218
|
-
options?.onRateLimit?.(readFoirRateLimit(res.headers));
|
|
219
683
|
if (!res.ok) {
|
|
220
|
-
const errors = await
|
|
221
|
-
const
|
|
222
|
-
throw new FoirRequestError(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
);
|
|
684
|
+
const errors = await readErrorBody(res);
|
|
685
|
+
const detail = errors ? `: ${errors.map((e) => e.message).join("; ")}` : "";
|
|
686
|
+
throw new FoirRequestError(`[foir] HTTP ${res.status} ${res.statusText}${detail}`, {
|
|
687
|
+
status: res.status,
|
|
688
|
+
graphQLErrors: errors
|
|
689
|
+
});
|
|
226
690
|
}
|
|
227
691
|
const json = await res.json();
|
|
228
692
|
return readFoirEnvelope(json);
|
|
229
693
|
}
|
|
230
|
-
async function
|
|
231
|
-
let errors;
|
|
694
|
+
async function readErrorBody(res) {
|
|
232
695
|
try {
|
|
233
|
-
const
|
|
234
|
-
errors
|
|
696
|
+
const json = await res.json();
|
|
697
|
+
return json?.errors?.length ? json.errors : void 0;
|
|
235
698
|
} catch {
|
|
236
|
-
|
|
699
|
+
return void 0;
|
|
237
700
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// src/data/manifest.ts
|
|
704
|
+
async function fetchFoirManifest(config) {
|
|
705
|
+
const url = foirDataEndpoint(config.apiUrl, "/v1/manifest");
|
|
706
|
+
const doFetch = config.fetch ?? globalThis.fetch;
|
|
707
|
+
let res;
|
|
708
|
+
try {
|
|
709
|
+
res = await doFetch(url, {
|
|
710
|
+
method: "GET",
|
|
711
|
+
headers: buildFoirHeaders({
|
|
712
|
+
apiKey: config.apiKey,
|
|
713
|
+
draftChannel: config.channel === "draft",
|
|
714
|
+
bearerToken: await resolveBearerToken(config),
|
|
715
|
+
extra: config.headers
|
|
716
|
+
}),
|
|
717
|
+
...config.requestInit
|
|
718
|
+
});
|
|
719
|
+
} catch (err) {
|
|
720
|
+
throw new FoirRequestError("[foir] network error", { cause: err });
|
|
248
721
|
}
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
722
|
+
if (!res.ok) {
|
|
723
|
+
throw new FoirRequestError(
|
|
724
|
+
`[foir] GET /v1/manifest failed: HTTP ${res.status} ${res.statusText}`,
|
|
725
|
+
{ status: res.status }
|
|
726
|
+
);
|
|
254
727
|
}
|
|
255
|
-
return
|
|
728
|
+
return await res.json();
|
|
256
729
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
730
|
+
|
|
731
|
+
// src/auth/ops.ts
|
|
732
|
+
var AUTH_OPS = {
|
|
733
|
+
// Pre-credential reads a login screen renders itself from.
|
|
734
|
+
authConfig: "authConfig",
|
|
735
|
+
authProviders: "authProviders",
|
|
736
|
+
defaultAuthProvider: "defaultAuthProvider",
|
|
737
|
+
customerSignupContext: "customerSignupContext",
|
|
738
|
+
// Post-credential self reads.
|
|
739
|
+
currentUser: "currentUser",
|
|
740
|
+
myProviderToken: "myProviderToken",
|
|
741
|
+
// Methods.
|
|
742
|
+
customerLogin: "customerLogin",
|
|
743
|
+
customerRegister: "customerRegister",
|
|
744
|
+
customerRequestOTP: "customerRequestOTP",
|
|
745
|
+
customerLoginOTP: "customerLoginOTP",
|
|
746
|
+
// Session lifecycle.
|
|
747
|
+
customerRefreshToken: "customerRefreshToken",
|
|
748
|
+
customerLogout: "customerLogout",
|
|
749
|
+
customerJoinWaitlist: "customerJoinWaitlist",
|
|
750
|
+
// Password management and account lifecycle — the block that had no path.
|
|
751
|
+
customerRequestPasswordReset: "customerRequestPasswordReset",
|
|
752
|
+
customerResetPassword: "customerResetPassword",
|
|
753
|
+
customerUpdatePassword: "customerUpdatePassword",
|
|
754
|
+
customerVerifyEmail: "customerVerifyEmail",
|
|
755
|
+
deleteMyAccount: "deleteMyAccount",
|
|
756
|
+
// External identity providers.
|
|
757
|
+
customerLoginWithProvider: "customerLoginWithProvider",
|
|
758
|
+
customerProviderCallback: "customerProviderCallback",
|
|
759
|
+
customerProviderVerifyOTP: "customerProviderVerifyOTP"
|
|
760
|
+
};
|
|
761
|
+
function authClientConfig(env, options) {
|
|
762
|
+
const apiKey = resolveFoirApiKey(env);
|
|
763
|
+
if (!apiKey) {
|
|
764
|
+
throw new FoirRequestError(
|
|
765
|
+
"[foir] no api key configured: set PLATFORM_API_KEY (or PLATFORM_PREVIEW_API_KEY under FOIR_PREVIEW=true)"
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
const apiUrl = env.PLATFORM_API_URL;
|
|
769
|
+
if (!apiUrl) {
|
|
770
|
+
throw new FoirRequestError("[foir] no api url configured: set PLATFORM_API_URL");
|
|
264
771
|
}
|
|
772
|
+
return {
|
|
773
|
+
apiUrl,
|
|
774
|
+
apiKey,
|
|
775
|
+
channel: isFoirPreview(env) ? "draft" : "published",
|
|
776
|
+
bearerToken: options?.bearerToken,
|
|
777
|
+
headers: options?.headers,
|
|
778
|
+
fetch: options?.fetch
|
|
779
|
+
};
|
|
265
780
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
781
|
+
var MANIFEST_CACHE_LIMIT = 8;
|
|
782
|
+
var manifestCache = /* @__PURE__ */ new Map();
|
|
783
|
+
function authManifest(config) {
|
|
784
|
+
const key = `${config.apiUrl}\0${config.apiKey}\0${config.channel ?? "published"}`;
|
|
785
|
+
const held = manifestCache.get(key);
|
|
786
|
+
if (held) return held;
|
|
787
|
+
const pending = fetchFoirManifest(config).catch((err) => {
|
|
788
|
+
manifestCache.delete(key);
|
|
789
|
+
throw err;
|
|
790
|
+
});
|
|
791
|
+
manifestCache.set(key, pending);
|
|
792
|
+
while (manifestCache.size > MANIFEST_CACHE_LIMIT) {
|
|
793
|
+
const oldest = manifestCache.keys().next();
|
|
794
|
+
if (oldest.done) break;
|
|
795
|
+
manifestCache.delete(oldest.value);
|
|
274
796
|
}
|
|
797
|
+
return pending;
|
|
798
|
+
}
|
|
799
|
+
async function authOp(env, op, args, options) {
|
|
800
|
+
const config = authClientConfig(env, options);
|
|
801
|
+
const manifest = await authManifest(config);
|
|
802
|
+
const plan = planFoirNamespaceCall(manifest, {
|
|
803
|
+
namespace: "auth",
|
|
804
|
+
op,
|
|
805
|
+
args: args ? stripUndefined(args) : {}
|
|
806
|
+
});
|
|
807
|
+
return executeFoirDocument(config, plan);
|
|
808
|
+
}
|
|
809
|
+
function stripUndefined(args) {
|
|
810
|
+
const out = {};
|
|
811
|
+
for (const [key, value] of Object.entries(args)) {
|
|
812
|
+
if (value !== void 0) out[key] = value;
|
|
813
|
+
}
|
|
814
|
+
return out;
|
|
275
815
|
}
|
|
276
|
-
|
|
277
|
-
// src/auth/operations.ts
|
|
278
|
-
import { parse } from "graphql";
|
|
279
|
-
var AUTH_RESULT_FIELDS = "token refreshToken customerId email";
|
|
280
|
-
var CustomerLoginDocument = parse(
|
|
281
|
-
`mutation CustomerLogin($email: String!, $password: String!) {
|
|
282
|
-
customerLogin(email: $email, password: $password) { ${AUTH_RESULT_FIELDS} }
|
|
283
|
-
}`
|
|
284
|
-
);
|
|
285
|
-
var CustomerRegisterDocument = parse(
|
|
286
|
-
`mutation CustomerRegister($email: String!, $password: String!) {
|
|
287
|
-
customerRegister(email: $email, password: $password) { ${AUTH_RESULT_FIELDS} }
|
|
288
|
-
}`
|
|
289
|
-
);
|
|
290
|
-
var CustomerRefreshTokenDocument = parse(
|
|
291
|
-
`mutation CustomerRefreshToken($refreshToken: String!) {
|
|
292
|
-
customerRefreshToken(refreshToken: $refreshToken) { ${AUTH_RESULT_FIELDS} }
|
|
293
|
-
}`
|
|
294
|
-
);
|
|
295
|
-
var CustomerRequestOTPDocument = parse(
|
|
296
|
-
`mutation CustomerRequestOTP($email: String!) {
|
|
297
|
-
customerRequestOTP(email: $email)
|
|
298
|
-
}`
|
|
299
|
-
);
|
|
300
|
-
var CustomerLoginOTPDocument = parse(
|
|
301
|
-
`mutation CustomerLoginOTP($email: String!, $otp: String!) {
|
|
302
|
-
customerLoginOTP(email: $email, otp: $otp) { ${AUTH_RESULT_FIELDS} }
|
|
303
|
-
}`
|
|
304
|
-
);
|
|
305
|
-
var CustomerLoginWithProviderDocument = parse(
|
|
306
|
-
`mutation CustomerLoginWithProvider($input: ProviderLoginInput!) {
|
|
307
|
-
customerLoginWithProvider(input: $input) { redirectUrl token }
|
|
308
|
-
}`
|
|
309
|
-
);
|
|
310
|
-
var CustomerProviderCallbackDocument = parse(
|
|
311
|
-
`mutation CustomerProviderCallback($input: ProviderCallbackInput!) {
|
|
312
|
-
customerProviderCallback(input: $input) { ${AUTH_RESULT_FIELDS} }
|
|
313
|
-
}`
|
|
314
|
-
);
|
|
315
|
-
var CustomerProviderVerifyOTPDocument = parse(
|
|
316
|
-
`mutation CustomerProviderVerifyOTP($input: ProviderOTPVerifyInput!) {
|
|
317
|
-
customerProviderVerifyOTP(input: $input) { ${AUTH_RESULT_FIELDS} }
|
|
318
|
-
}`
|
|
319
|
-
);
|
|
320
|
-
var AuthProvidersDocument = parse(
|
|
321
|
-
`query AuthProviders {
|
|
322
|
-
authProviders { key name type isDefault }
|
|
323
|
-
}`
|
|
324
|
-
);
|
|
325
|
-
var CustomerJoinWaitlistDocument = parse(
|
|
326
|
-
`mutation CustomerJoinWaitlist($email: String!) {
|
|
327
|
-
customerJoinWaitlist(email: $email) { id email status }
|
|
328
|
-
}`
|
|
329
|
-
);
|
|
330
|
-
var CustomerSignupContextDocument = parse(
|
|
331
|
-
`query CustomerSignupContext {
|
|
332
|
-
customerSignupContext { signupMode }
|
|
333
|
-
}`
|
|
334
|
-
);
|
|
335
816
|
|
|
336
817
|
// src/auth/auth.ts
|
|
337
818
|
var TOKEN_KEY = "foir:token";
|
|
@@ -339,28 +820,28 @@ var REFRESH_KEY = "foir:refreshToken";
|
|
|
339
820
|
var CUSTOMER_ID_KEY = "foir:customerId";
|
|
340
821
|
var EMAIL_KEY = "foir:email";
|
|
341
822
|
async function customerLogin(env, vars) {
|
|
342
|
-
return (
|
|
823
|
+
return authOp(env, AUTH_OPS.customerLogin, vars);
|
|
343
824
|
}
|
|
344
825
|
async function customerRegister(env, vars) {
|
|
345
|
-
return (
|
|
826
|
+
return authOp(env, AUTH_OPS.customerRegister, vars);
|
|
346
827
|
}
|
|
347
828
|
async function customerRequestOTP(env, email) {
|
|
348
|
-
return (
|
|
829
|
+
return authOp(env, AUTH_OPS.customerRequestOTP, { email });
|
|
349
830
|
}
|
|
350
831
|
async function customerLoginOTP(env, vars) {
|
|
351
|
-
return (
|
|
832
|
+
return authOp(env, AUTH_OPS.customerLoginOTP, vars);
|
|
352
833
|
}
|
|
353
834
|
async function customerRefreshToken(env, refreshToken) {
|
|
354
|
-
return (
|
|
835
|
+
return authOp(env, AUTH_OPS.customerRefreshToken, { refreshToken });
|
|
355
836
|
}
|
|
356
837
|
async function customerLoginWithProvider(env, input) {
|
|
357
|
-
return (
|
|
838
|
+
return authOp(env, AUTH_OPS.customerLoginWithProvider, { input });
|
|
358
839
|
}
|
|
359
840
|
async function customerProviderCallback(env, input) {
|
|
360
|
-
return (
|
|
841
|
+
return authOp(env, AUTH_OPS.customerProviderCallback, { input });
|
|
361
842
|
}
|
|
362
843
|
async function getAuthProviders(env) {
|
|
363
|
-
return (
|
|
844
|
+
return authOp(env, AUTH_OPS.authProviders);
|
|
364
845
|
}
|
|
365
846
|
async function saveSession(adapter, result) {
|
|
366
847
|
await adapter.set(TOKEN_KEY, result.token);
|
|
@@ -685,7 +1166,7 @@ function FoirProvider({
|
|
|
685
1166
|
},
|
|
686
1167
|
[apply, assertTokenMode]
|
|
687
1168
|
);
|
|
688
|
-
const
|
|
1169
|
+
const signOut2 = useCallback(async () => {
|
|
689
1170
|
if (hosted) {
|
|
690
1171
|
await hosted.signOut();
|
|
691
1172
|
return;
|
|
@@ -725,7 +1206,7 @@ function FoirProvider({
|
|
|
725
1206
|
signInWithOtp,
|
|
726
1207
|
signInWithProvider,
|
|
727
1208
|
completeProviderLogin,
|
|
728
|
-
signOut,
|
|
1209
|
+
signOut: signOut2,
|
|
729
1210
|
refresh,
|
|
730
1211
|
getToken,
|
|
731
1212
|
getProviders,
|
|
@@ -741,7 +1222,7 @@ function FoirProvider({
|
|
|
741
1222
|
signInWithOtp,
|
|
742
1223
|
signInWithProvider,
|
|
743
1224
|
completeProviderLogin,
|
|
744
|
-
|
|
1225
|
+
signOut2,
|
|
745
1226
|
refresh,
|
|
746
1227
|
getToken,
|
|
747
1228
|
getProviders,
|
|
@@ -777,7 +1258,7 @@ function useFoirAuth() {
|
|
|
777
1258
|
signInWithOtp,
|
|
778
1259
|
signInWithProvider,
|
|
779
1260
|
completeProviderLogin,
|
|
780
|
-
signOut,
|
|
1261
|
+
signOut: signOut2,
|
|
781
1262
|
refresh,
|
|
782
1263
|
getToken,
|
|
783
1264
|
getProviders,
|
|
@@ -790,7 +1271,7 @@ function useFoirAuth() {
|
|
|
790
1271
|
signInWithOtp,
|
|
791
1272
|
signInWithProvider,
|
|
792
1273
|
completeProviderLogin,
|
|
793
|
-
signOut,
|
|
1274
|
+
signOut: signOut2,
|
|
794
1275
|
refresh,
|
|
795
1276
|
getToken,
|
|
796
1277
|
getProviders,
|
|
@@ -873,6 +1354,7 @@ var inputStyle = {
|
|
|
873
1354
|
var submitStyle = { ...inputStyle, cursor: "pointer", fontWeight: 600 };
|
|
874
1355
|
var errorStyle = { color: "#c0392b", fontSize: 13, margin: 0 };
|
|
875
1356
|
var dividerStyle = { fontSize: 12, opacity: 0.6, textAlign: "center" };
|
|
1357
|
+
var SIGN_IN_FAILED = "Sign-in failed. Please try again.";
|
|
876
1358
|
function SignIn({
|
|
877
1359
|
method = "password",
|
|
878
1360
|
hostedUrl,
|
|
@@ -914,7 +1396,8 @@ function SignIn({
|
|
|
914
1396
|
finish(await signIn({ email, password }));
|
|
915
1397
|
}
|
|
916
1398
|
} catch (err) {
|
|
917
|
-
|
|
1399
|
+
console.error("[foir] sign-in failed", err);
|
|
1400
|
+
setError(SIGN_IN_FAILED);
|
|
918
1401
|
} finally {
|
|
919
1402
|
setBusy(false);
|
|
920
1403
|
}
|
|
@@ -969,7 +1452,10 @@ function SignIn({
|
|
|
969
1452
|
] }),
|
|
970
1453
|
oauth && /* @__PURE__ */ jsxs2(Fragment, { children: [
|
|
971
1454
|
/* @__PURE__ */ jsx3("div", { "data-foir-divider": true, style: styled ? dividerStyle : void 0, children: "or" }),
|
|
972
|
-
/* @__PURE__ */ jsx3(OAuthButtons, { returnTo: afterSignInUrl, onError: (err) =>
|
|
1455
|
+
/* @__PURE__ */ jsx3(OAuthButtons, { returnTo: afterSignInUrl, onError: (err) => {
|
|
1456
|
+
console.error("[foir] provider sign-in failed", err);
|
|
1457
|
+
setError(SIGN_IN_FAILED);
|
|
1458
|
+
} })
|
|
973
1459
|
] })
|
|
974
1460
|
] });
|
|
975
1461
|
}
|
|
@@ -989,11 +1475,11 @@ var btnStyle2 = {
|
|
|
989
1475
|
};
|
|
990
1476
|
function UserButton({ afterSignOut, afterSignOutUrl, className }) {
|
|
991
1477
|
const user = useFoirUser();
|
|
992
|
-
const { signOut } = useFoirAuth();
|
|
1478
|
+
const { signOut: signOut2 } = useFoirAuth();
|
|
993
1479
|
if (!user) return null;
|
|
994
1480
|
const styled = !className;
|
|
995
1481
|
const onSignOut = async () => {
|
|
996
|
-
await
|
|
1482
|
+
await signOut2();
|
|
997
1483
|
afterSignOut?.();
|
|
998
1484
|
if (afterSignOutUrl && typeof window !== "undefined") {
|
|
999
1485
|
window.location.assign(afterSignOutUrl);
|
|
@@ -1004,7 +1490,599 @@ function UserButton({ afterSignOut, afterSignOutUrl, className }) {
|
|
|
1004
1490
|
/* @__PURE__ */ jsx4("button", { type: "button", onClick: onSignOut, style: styled ? btnStyle2 : void 0, children: "Sign out" })
|
|
1005
1491
|
] });
|
|
1006
1492
|
}
|
|
1493
|
+
|
|
1494
|
+
// src/react/FoirFeedback.tsx
|
|
1495
|
+
import {
|
|
1496
|
+
useCallback as useCallback2,
|
|
1497
|
+
useEffect as useEffect3,
|
|
1498
|
+
useRef as useRef2,
|
|
1499
|
+
useState as useState4,
|
|
1500
|
+
useSyncExternalStore
|
|
1501
|
+
} from "react";
|
|
1502
|
+
|
|
1503
|
+
// src/media/screen-capture.ts
|
|
1504
|
+
var DEFAULT_MAX_WIDTH = 1600;
|
|
1505
|
+
var DEFAULT_QUALITY = 0.7;
|
|
1506
|
+
var CAPTURE_MIME_TYPE = "image/jpeg";
|
|
1507
|
+
function isFoirScreenCaptureSupported() {
|
|
1508
|
+
return typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getDisplayMedia === "function";
|
|
1509
|
+
}
|
|
1510
|
+
async function captureFoirScreen(options = {}) {
|
|
1511
|
+
if (!isFoirScreenCaptureSupported()) {
|
|
1512
|
+
throw new Error(
|
|
1513
|
+
"[foir] this browser cannot capture the screen: navigator.mediaDevices.getDisplayMedia is unavailable. It needs a secure context (https, or localhost) and is absent on iOS. Call isFoirScreenCaptureSupported() before offering capture."
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
const maxWidth = options.maxWidth ?? DEFAULT_MAX_WIDTH;
|
|
1517
|
+
const quality = options.quality ?? DEFAULT_QUALITY;
|
|
1518
|
+
let stream;
|
|
1519
|
+
try {
|
|
1520
|
+
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
1521
|
+
video: { displaySurface: "browser" }
|
|
1522
|
+
});
|
|
1523
|
+
} catch (err) {
|
|
1524
|
+
if (isDismissal(err)) return null;
|
|
1525
|
+
throw err;
|
|
1526
|
+
}
|
|
1527
|
+
try {
|
|
1528
|
+
const video = document.createElement("video");
|
|
1529
|
+
video.muted = true;
|
|
1530
|
+
video.playsInline = true;
|
|
1531
|
+
video.srcObject = stream;
|
|
1532
|
+
await waitForMetadata(video);
|
|
1533
|
+
await video.play();
|
|
1534
|
+
const { videoWidth, videoHeight } = video;
|
|
1535
|
+
if (!videoWidth || !videoHeight) {
|
|
1536
|
+
throw new Error(
|
|
1537
|
+
"[foir] the captured stream reported no dimensions, so there is no frame to draw. This is the browser ending the capture before the first frame arrived \u2014 retry it."
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
const scale = videoWidth > maxWidth ? maxWidth / videoWidth : 1;
|
|
1541
|
+
const width = Math.round(videoWidth * scale);
|
|
1542
|
+
const height = Math.round(videoHeight * scale);
|
|
1543
|
+
const canvas = document.createElement("canvas");
|
|
1544
|
+
canvas.width = width;
|
|
1545
|
+
canvas.height = height;
|
|
1546
|
+
const ctx = canvas.getContext("2d");
|
|
1547
|
+
if (!ctx) {
|
|
1548
|
+
throw new Error(
|
|
1549
|
+
"[foir] this browser gave no 2d canvas context, so the captured frame cannot be encoded. Nothing was uploaded."
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
1553
|
+
video.srcObject = null;
|
|
1554
|
+
const dataUrl = canvas.toDataURL(CAPTURE_MIME_TYPE, quality);
|
|
1555
|
+
return {
|
|
1556
|
+
blob: dataUrlToBlob(dataUrl),
|
|
1557
|
+
dataUrl,
|
|
1558
|
+
width,
|
|
1559
|
+
height,
|
|
1560
|
+
mimeType: CAPTURE_MIME_TYPE
|
|
1561
|
+
};
|
|
1562
|
+
} finally {
|
|
1563
|
+
for (const track of stream.getTracks()) track.stop();
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
function isDismissal(err) {
|
|
1567
|
+
const name = err?.name;
|
|
1568
|
+
return name === "NotAllowedError" || name === "AbortError";
|
|
1569
|
+
}
|
|
1570
|
+
function waitForMetadata(video) {
|
|
1571
|
+
if (video.readyState >= 1) return Promise.resolve();
|
|
1572
|
+
return new Promise((resolve, reject) => {
|
|
1573
|
+
video.addEventListener("loadedmetadata", () => resolve(), { once: true });
|
|
1574
|
+
video.addEventListener(
|
|
1575
|
+
"error",
|
|
1576
|
+
() => reject(
|
|
1577
|
+
new Error("[foir] the captured stream failed to load before a frame could be read")
|
|
1578
|
+
),
|
|
1579
|
+
{ once: true }
|
|
1580
|
+
);
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
function dataUrlToBlob(dataUrl) {
|
|
1584
|
+
const comma = dataUrl.indexOf(",");
|
|
1585
|
+
const header = comma === -1 ? "" : dataUrl.slice(0, comma);
|
|
1586
|
+
if (comma === -1 || !header.includes(";base64")) {
|
|
1587
|
+
throw new Error(
|
|
1588
|
+
`[foir] the canvas produced a data URL this build cannot decode (${header || "no header"}). Expected a base64 data URL.`
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
const binary = atob(dataUrl.slice(comma + 1));
|
|
1592
|
+
const bytes = new Uint8Array(binary.length);
|
|
1593
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
1594
|
+
const mimeType = header.slice("data:".length, header.indexOf(";"));
|
|
1595
|
+
return new Blob([bytes], { type: mimeType || CAPTURE_MIME_TYPE });
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// src/react/feedback-notes.ts
|
|
1599
|
+
var SIGN_IN_REFUSALS = ["sign-in required", "unauthorized: no auth"];
|
|
1600
|
+
function classifyNotesFault(err) {
|
|
1601
|
+
for (const message of faultMessages(err)) {
|
|
1602
|
+
const lower = message.toLowerCase();
|
|
1603
|
+
if (SIGN_IN_REFUSALS.some((phrase) => lower.includes(phrase))) return { kind: "signIn" };
|
|
1604
|
+
const missing = /missing required scope: ([a-z0-9_:.*-]+)/i.exec(message) ?? /scoped token lacks ([a-z0-9_:.*-]+)/i.exec(message);
|
|
1605
|
+
if (missing?.[1]) return { kind: "scope", scope: missing[1] };
|
|
1606
|
+
}
|
|
1607
|
+
if (err?.status === 401) return { kind: "signIn" };
|
|
1608
|
+
return { kind: "other" };
|
|
1609
|
+
}
|
|
1610
|
+
function faultMessages(err) {
|
|
1611
|
+
if (!err || typeof err !== "object") return typeof err === "string" ? [err] : [];
|
|
1612
|
+
const out = [];
|
|
1613
|
+
const message = err.message;
|
|
1614
|
+
if (typeof message === "string") out.push(message);
|
|
1615
|
+
const graphQLErrors = err.graphQLErrors;
|
|
1616
|
+
if (Array.isArray(graphQLErrors)) {
|
|
1617
|
+
for (const item of graphQLErrors) {
|
|
1618
|
+
const itemMessage = item?.message;
|
|
1619
|
+
if (typeof itemMessage === "string") out.push(itemMessage);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return out;
|
|
1623
|
+
}
|
|
1624
|
+
async function readNoteConfig(client) {
|
|
1625
|
+
const result = await client.ns.notes.noteConfig();
|
|
1626
|
+
const screenshotsEnabled = result?.screenshotsEnabled;
|
|
1627
|
+
if (typeof screenshotsEnabled !== "boolean") {
|
|
1628
|
+
throw new Error(
|
|
1629
|
+
"[foir] noteConfig answered without a boolean `screenshotsEnabled`. The widget reads it to decide whether to offer screenshot capture, and will not guess."
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
return { screenshotsEnabled };
|
|
1633
|
+
}
|
|
1634
|
+
async function submitFeedbackNote(client, submission) {
|
|
1635
|
+
const attachmentFileIds = [];
|
|
1636
|
+
if (submission.screenshot) {
|
|
1637
|
+
attachmentFileIds.push(
|
|
1638
|
+
await uploadFeedbackAttachment(client, submission.screenshot, submission.fetch)
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
const args = {
|
|
1642
|
+
subjectType: "record",
|
|
1643
|
+
subjectId: submission.subject.recordId,
|
|
1644
|
+
subjectModelKey: submission.subject.modelKey,
|
|
1645
|
+
body: submission.body
|
|
1646
|
+
};
|
|
1647
|
+
if (attachmentFileIds.length > 0) args.attachmentFileIds = attachmentFileIds;
|
|
1648
|
+
return readCreatedNote(await client.ns.notes.createMyNote(args));
|
|
1649
|
+
}
|
|
1650
|
+
async function uploadFeedbackAttachment(client, screenshot, fetchImpl = globalThis.fetch) {
|
|
1651
|
+
const files = client.ns.files;
|
|
1652
|
+
if (!files) {
|
|
1653
|
+
throw new Error(
|
|
1654
|
+
"[foir] this client cannot reach the files namespace, so a screenshot cannot be uploaded. Attaching one needs `files:write` on the key and on the signed-in customer's role."
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
const ticket = readUploadTicket(
|
|
1658
|
+
await files.createFileUpload({
|
|
1659
|
+
filename: screenshotFilename(screenshot.mimeType),
|
|
1660
|
+
mimeType: screenshot.mimeType,
|
|
1661
|
+
// The blob's own size, never a number computed alongside it: this is what
|
|
1662
|
+
// the plan's per-file limit and the storage allowance are checked against,
|
|
1663
|
+
// and the presigned PUT rejects a body whose length disagrees.
|
|
1664
|
+
size: screenshot.blob.size
|
|
1665
|
+
})
|
|
1666
|
+
);
|
|
1667
|
+
const res = await fetchImpl(ticket.uploadUrl, {
|
|
1668
|
+
method: "PUT",
|
|
1669
|
+
headers: { "Content-Type": screenshot.mimeType },
|
|
1670
|
+
body: screenshot.blob
|
|
1671
|
+
});
|
|
1672
|
+
if (!res.ok) {
|
|
1673
|
+
throw new Error(
|
|
1674
|
+
`[foir] uploading the screenshot failed: HTTP ${res.status} ${res.statusText}. The note was not written.`
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
const confirmed = await files.confirmFileUpload({ uploadId: ticket.uploadId });
|
|
1678
|
+
const id = confirmed?.id;
|
|
1679
|
+
if (typeof id !== "string" || id === "") {
|
|
1680
|
+
throw new Error(
|
|
1681
|
+
"[foir] confirmFileUpload answered without a file id, so there is nothing to attach the note to. The note was not written."
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
return id;
|
|
1685
|
+
}
|
|
1686
|
+
function readUploadTicket(result) {
|
|
1687
|
+
const uploadId = result?.uploadId;
|
|
1688
|
+
const uploadUrl = result?.uploadUrl;
|
|
1689
|
+
if (typeof uploadId !== "string" || typeof uploadUrl !== "string" || !uploadId || !uploadUrl) {
|
|
1690
|
+
throw new Error(
|
|
1691
|
+
"[foir] createFileUpload answered without an uploadId and uploadUrl, so the screenshot has nowhere to go. The note was not written."
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
return { uploadId, uploadUrl };
|
|
1695
|
+
}
|
|
1696
|
+
function readCreatedNote(result) {
|
|
1697
|
+
const note = result;
|
|
1698
|
+
if (!note || typeof note.id !== "string" || note.id === "") {
|
|
1699
|
+
throw new Error(
|
|
1700
|
+
"[foir] createMyNote answered with no note. Nothing was saved \u2014 if it had been, the surface would have returned it."
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
return {
|
|
1704
|
+
id: note.id,
|
|
1705
|
+
body: typeof note.body === "string" ? note.body : null,
|
|
1706
|
+
subjectType: note.subjectType ?? "record",
|
|
1707
|
+
subjectId: typeof note.subjectId === "string" ? note.subjectId : "",
|
|
1708
|
+
subjectModelKey: typeof note.subjectModelKey === "string" ? note.subjectModelKey : null,
|
|
1709
|
+
parentNoteId: typeof note.parentNoteId === "string" ? note.parentNoteId : null,
|
|
1710
|
+
threadRootId: typeof note.threadRootId === "string" ? note.threadRootId : null,
|
|
1711
|
+
isResolved: note.isResolved === true,
|
|
1712
|
+
attachments: readAttachments(note.attachments),
|
|
1713
|
+
createdAt: typeof note.createdAt === "string" ? note.createdAt : ""
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
function readAttachments(value) {
|
|
1717
|
+
if (!Array.isArray(value)) return [];
|
|
1718
|
+
const out = [];
|
|
1719
|
+
for (const item of value) {
|
|
1720
|
+
const a = item;
|
|
1721
|
+
if (!a || typeof a.fileId !== "string") continue;
|
|
1722
|
+
out.push({
|
|
1723
|
+
fileId: a.fileId,
|
|
1724
|
+
filename: typeof a.filename === "string" ? a.filename : "",
|
|
1725
|
+
mimeType: typeof a.mimeType === "string" ? a.mimeType : "",
|
|
1726
|
+
size: typeof a.size === "number" ? a.size : 0,
|
|
1727
|
+
url: typeof a.url === "string" ? a.url : ""
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
return out;
|
|
1731
|
+
}
|
|
1732
|
+
function screenshotFilename(mimeType) {
|
|
1733
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1734
|
+
const subtype = mimeType.slice(mimeType.indexOf("/") + 1);
|
|
1735
|
+
const extension = subtype === "jpeg" ? "jpg" : subtype || "bin";
|
|
1736
|
+
return `feedback-${stamp}.${extension}`;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// src/react/FoirFeedback.tsx
|
|
1740
|
+
import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1741
|
+
var COPY = {
|
|
1742
|
+
signIn: "Sign in to send feedback.",
|
|
1743
|
+
signInAction: "Sign in",
|
|
1744
|
+
notEnabled: "Feedback is not available on this account.",
|
|
1745
|
+
unavailable: "Feedback is unavailable right now. Please try again later.",
|
|
1746
|
+
noSubject: "There is nothing on this page to give feedback on.",
|
|
1747
|
+
loading: "One moment\u2026",
|
|
1748
|
+
sendFailed: "Your feedback could not be sent. Please try again.",
|
|
1749
|
+
sent: "Thanks. Your feedback has been sent.",
|
|
1750
|
+
send: "Send feedback",
|
|
1751
|
+
sending: "Sending\u2026",
|
|
1752
|
+
capture: "Add a screenshot",
|
|
1753
|
+
capturing: "Choose what to share\u2026",
|
|
1754
|
+
captureFailed: "The screenshot could not be taken. You can still send your feedback.",
|
|
1755
|
+
screenshotAlt: "The screenshot that will be sent with your feedback",
|
|
1756
|
+
removeScreenshot: "Remove screenshot",
|
|
1757
|
+
about: "About"
|
|
1758
|
+
};
|
|
1759
|
+
var stackStyle = { display: "flex", flexDirection: "column", gap: 10 };
|
|
1760
|
+
var panelStyle = {
|
|
1761
|
+
...stackStyle,
|
|
1762
|
+
maxWidth: 340,
|
|
1763
|
+
padding: 16,
|
|
1764
|
+
borderRadius: 8,
|
|
1765
|
+
border: "1px solid currentColor",
|
|
1766
|
+
font: "inherit"
|
|
1767
|
+
};
|
|
1768
|
+
var buttonStyle = {
|
|
1769
|
+
padding: "8px 10px",
|
|
1770
|
+
borderRadius: 6,
|
|
1771
|
+
border: "1px solid currentColor",
|
|
1772
|
+
background: "transparent",
|
|
1773
|
+
font: "inherit",
|
|
1774
|
+
cursor: "pointer"
|
|
1775
|
+
};
|
|
1776
|
+
var submitStyle2 = { ...buttonStyle, fontWeight: 600 };
|
|
1777
|
+
var textareaStyle = {
|
|
1778
|
+
padding: "8px 10px",
|
|
1779
|
+
borderRadius: 6,
|
|
1780
|
+
border: "1px solid currentColor",
|
|
1781
|
+
background: "transparent",
|
|
1782
|
+
font: "inherit",
|
|
1783
|
+
minHeight: 96,
|
|
1784
|
+
resize: "vertical"
|
|
1785
|
+
};
|
|
1786
|
+
var headingStyle = { margin: 0, fontSize: 15, fontWeight: 600 };
|
|
1787
|
+
var subjectStyle = { margin: 0, fontSize: 13, opacity: 0.75 };
|
|
1788
|
+
var noteStyle = { margin: 0, fontSize: 13 };
|
|
1789
|
+
var errorStyle2 = { margin: 0, fontSize: 13, color: "#c0392b" };
|
|
1790
|
+
var previewStyle = {
|
|
1791
|
+
display: "block",
|
|
1792
|
+
width: "100%",
|
|
1793
|
+
height: 96,
|
|
1794
|
+
objectFit: "cover",
|
|
1795
|
+
objectPosition: "top",
|
|
1796
|
+
borderRadius: 6,
|
|
1797
|
+
border: "1px solid currentColor"
|
|
1798
|
+
};
|
|
1799
|
+
function FoirFeedback({
|
|
1800
|
+
client,
|
|
1801
|
+
subjects,
|
|
1802
|
+
subject,
|
|
1803
|
+
label,
|
|
1804
|
+
triggerLabel = "Feedback",
|
|
1805
|
+
title = "Send feedback",
|
|
1806
|
+
placeholder = "What would you like to tell us?",
|
|
1807
|
+
defaultOpen = false,
|
|
1808
|
+
onSignIn,
|
|
1809
|
+
onSubmitted,
|
|
1810
|
+
onError,
|
|
1811
|
+
className
|
|
1812
|
+
}) {
|
|
1813
|
+
const [open, setOpen] = useState4(defaultOpen);
|
|
1814
|
+
const [probe, setProbe] = useState4({ status: "loading" });
|
|
1815
|
+
const [body, setBody] = useState4("");
|
|
1816
|
+
const [screenshot, setScreenshot] = useState4(null);
|
|
1817
|
+
const [capturing, setCapturing] = useState4(false);
|
|
1818
|
+
const [busy, setBusy] = useState4(false);
|
|
1819
|
+
const [failure, setFailure] = useState4(null);
|
|
1820
|
+
const [sent, setSent] = useState4(false);
|
|
1821
|
+
const onErrorRef = useRef2(onError);
|
|
1822
|
+
onErrorRef.current = onError;
|
|
1823
|
+
const resolved = useResolvedSubject(subjects, subject);
|
|
1824
|
+
const styled = !className;
|
|
1825
|
+
useNoSubjectNotice(open, resolved, subjects !== void 0, subject !== void 0);
|
|
1826
|
+
useEffect3(() => {
|
|
1827
|
+
if (!open) return;
|
|
1828
|
+
let cancelled = false;
|
|
1829
|
+
setProbe({ status: "loading" });
|
|
1830
|
+
void (async () => {
|
|
1831
|
+
try {
|
|
1832
|
+
const config = await readNoteConfig(client);
|
|
1833
|
+
if (!cancelled) {
|
|
1834
|
+
setProbe({ status: "ready", screenshotsEnabled: config.screenshotsEnabled });
|
|
1835
|
+
}
|
|
1836
|
+
} catch (err) {
|
|
1837
|
+
if (cancelled) return;
|
|
1838
|
+
const fault = classifyNotesFault(err);
|
|
1839
|
+
reportFault(fault, err, "the notes surface could not be read");
|
|
1840
|
+
onErrorRef.current?.(err);
|
|
1841
|
+
setProbe({ status: "blocked", fault });
|
|
1842
|
+
}
|
|
1843
|
+
})();
|
|
1844
|
+
return () => {
|
|
1845
|
+
cancelled = true;
|
|
1846
|
+
};
|
|
1847
|
+
}, [open, client]);
|
|
1848
|
+
const capture = useCallback2(async () => {
|
|
1849
|
+
setFailure(null);
|
|
1850
|
+
setCapturing(true);
|
|
1851
|
+
try {
|
|
1852
|
+
const still = await captureFoirScreen();
|
|
1853
|
+
if (still) setScreenshot(still);
|
|
1854
|
+
} catch (err) {
|
|
1855
|
+
console.error("[foir] <FoirFeedback>: capturing the screen failed", err);
|
|
1856
|
+
onErrorRef.current?.(err);
|
|
1857
|
+
setFailure(COPY.captureFailed);
|
|
1858
|
+
} finally {
|
|
1859
|
+
setCapturing(false);
|
|
1860
|
+
}
|
|
1861
|
+
}, []);
|
|
1862
|
+
const onSubmit = async (event) => {
|
|
1863
|
+
event.preventDefault();
|
|
1864
|
+
const prose = body.trim();
|
|
1865
|
+
if (!resolved || !prose || busy) return;
|
|
1866
|
+
setBusy(true);
|
|
1867
|
+
setFailure(null);
|
|
1868
|
+
try {
|
|
1869
|
+
const note = await submitFeedbackNote(client, {
|
|
1870
|
+
subject: resolved,
|
|
1871
|
+
body: prose,
|
|
1872
|
+
screenshot
|
|
1873
|
+
});
|
|
1874
|
+
setBody("");
|
|
1875
|
+
setScreenshot(null);
|
|
1876
|
+
setSent(true);
|
|
1877
|
+
onSubmitted?.(note);
|
|
1878
|
+
} catch (err) {
|
|
1879
|
+
const fault = classifyNotesFault(err);
|
|
1880
|
+
reportFault(fault, err, "sending feedback failed");
|
|
1881
|
+
onErrorRef.current?.(err);
|
|
1882
|
+
setFailure(failureCopy(fault));
|
|
1883
|
+
} finally {
|
|
1884
|
+
setBusy(false);
|
|
1885
|
+
}
|
|
1886
|
+
};
|
|
1887
|
+
const toggle = () => {
|
|
1888
|
+
if (open) {
|
|
1889
|
+
setOpen(false);
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
setSent(false);
|
|
1893
|
+
setFailure(null);
|
|
1894
|
+
setOpen(true);
|
|
1895
|
+
};
|
|
1896
|
+
const subjectName = resolved ? label ?? resolved.label : void 0;
|
|
1897
|
+
return /* @__PURE__ */ jsxs4("div", { className, "data-foir-feedback": true, children: [
|
|
1898
|
+
/* @__PURE__ */ jsx5(
|
|
1899
|
+
"button",
|
|
1900
|
+
{
|
|
1901
|
+
type: "button",
|
|
1902
|
+
"data-foir-feedback-trigger": true,
|
|
1903
|
+
"aria-expanded": open,
|
|
1904
|
+
onClick: toggle,
|
|
1905
|
+
style: styled ? buttonStyle : void 0,
|
|
1906
|
+
children: triggerLabel
|
|
1907
|
+
}
|
|
1908
|
+
),
|
|
1909
|
+
open && /* @__PURE__ */ jsxs4(
|
|
1910
|
+
"section",
|
|
1911
|
+
{
|
|
1912
|
+
"data-foir-feedback-panel": true,
|
|
1913
|
+
"aria-label": title,
|
|
1914
|
+
style: styled ? panelStyle : void 0,
|
|
1915
|
+
children: [
|
|
1916
|
+
/* @__PURE__ */ jsx5("h2", { "data-foir-feedback-title": true, style: styled ? headingStyle : void 0, children: title }),
|
|
1917
|
+
subjectName && /* @__PURE__ */ jsxs4("p", { "data-foir-feedback-subject": true, style: styled ? subjectStyle : void 0, children: [
|
|
1918
|
+
COPY.about,
|
|
1919
|
+
": ",
|
|
1920
|
+
subjectName
|
|
1921
|
+
] }),
|
|
1922
|
+
probe.status === "loading" && /* @__PURE__ */ jsx5(
|
|
1923
|
+
"p",
|
|
1924
|
+
{
|
|
1925
|
+
role: "status",
|
|
1926
|
+
"data-foir-feedback-status": true,
|
|
1927
|
+
style: styled ? noteStyle : void 0,
|
|
1928
|
+
children: COPY.loading
|
|
1929
|
+
}
|
|
1930
|
+
),
|
|
1931
|
+
probe.status === "blocked" && /* @__PURE__ */ jsx5(BlockedNotice, { fault: probe.fault, onSignIn, styled }),
|
|
1932
|
+
probe.status === "ready" && !resolved && /* @__PURE__ */ jsx5(
|
|
1933
|
+
"p",
|
|
1934
|
+
{
|
|
1935
|
+
role: "status",
|
|
1936
|
+
"data-foir-feedback-status": true,
|
|
1937
|
+
style: styled ? noteStyle : void 0,
|
|
1938
|
+
children: COPY.noSubject
|
|
1939
|
+
}
|
|
1940
|
+
),
|
|
1941
|
+
probe.status === "ready" && resolved && /* @__PURE__ */ jsxs4("form", { onSubmit, style: styled ? stackStyle : void 0, children: [
|
|
1942
|
+
/* @__PURE__ */ jsx5(
|
|
1943
|
+
"textarea",
|
|
1944
|
+
{
|
|
1945
|
+
"data-foir-feedback-body": true,
|
|
1946
|
+
"aria-label": placeholder,
|
|
1947
|
+
placeholder,
|
|
1948
|
+
required: true,
|
|
1949
|
+
value: body,
|
|
1950
|
+
disabled: busy,
|
|
1951
|
+
onChange: (event) => {
|
|
1952
|
+
setBody(event.target.value);
|
|
1953
|
+
if (sent) setSent(false);
|
|
1954
|
+
},
|
|
1955
|
+
style: styled ? textareaStyle : void 0
|
|
1956
|
+
}
|
|
1957
|
+
),
|
|
1958
|
+
probe.screenshotsEnabled && isFoirScreenCaptureSupported() && /* @__PURE__ */ jsx5(Fragment2, { children: screenshot ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
|
|
1959
|
+
/* @__PURE__ */ jsx5(
|
|
1960
|
+
"img",
|
|
1961
|
+
{
|
|
1962
|
+
"data-foir-feedback-screenshot": true,
|
|
1963
|
+
src: screenshot.dataUrl,
|
|
1964
|
+
alt: COPY.screenshotAlt,
|
|
1965
|
+
style: styled ? previewStyle : void 0
|
|
1966
|
+
}
|
|
1967
|
+
),
|
|
1968
|
+
/* @__PURE__ */ jsx5(
|
|
1969
|
+
"button",
|
|
1970
|
+
{
|
|
1971
|
+
type: "button",
|
|
1972
|
+
"data-foir-feedback-remove": true,
|
|
1973
|
+
onClick: () => setScreenshot(null),
|
|
1974
|
+
disabled: busy,
|
|
1975
|
+
style: styled ? buttonStyle : void 0,
|
|
1976
|
+
children: COPY.removeScreenshot
|
|
1977
|
+
}
|
|
1978
|
+
)
|
|
1979
|
+
] }) : /* @__PURE__ */ jsx5(
|
|
1980
|
+
"button",
|
|
1981
|
+
{
|
|
1982
|
+
type: "button",
|
|
1983
|
+
"data-foir-feedback-capture": true,
|
|
1984
|
+
onClick: () => void capture(),
|
|
1985
|
+
disabled: busy || capturing,
|
|
1986
|
+
style: styled ? buttonStyle : void 0,
|
|
1987
|
+
children: capturing ? COPY.capturing : COPY.capture
|
|
1988
|
+
}
|
|
1989
|
+
) }),
|
|
1990
|
+
failure && /* @__PURE__ */ jsx5("p", { role: "alert", "data-foir-feedback-error": true, style: styled ? errorStyle2 : void 0, children: failure }),
|
|
1991
|
+
sent && !failure && /* @__PURE__ */ jsx5(
|
|
1992
|
+
"p",
|
|
1993
|
+
{
|
|
1994
|
+
role: "status",
|
|
1995
|
+
"data-foir-feedback-status": true,
|
|
1996
|
+
style: styled ? noteStyle : void 0,
|
|
1997
|
+
children: COPY.sent
|
|
1998
|
+
}
|
|
1999
|
+
),
|
|
2000
|
+
/* @__PURE__ */ jsx5(
|
|
2001
|
+
"button",
|
|
2002
|
+
{
|
|
2003
|
+
type: "submit",
|
|
2004
|
+
"data-foir-feedback-submit": true,
|
|
2005
|
+
disabled: busy || body.trim() === "",
|
|
2006
|
+
style: styled ? submitStyle2 : void 0,
|
|
2007
|
+
children: busy ? COPY.sending : COPY.send
|
|
2008
|
+
}
|
|
2009
|
+
)
|
|
2010
|
+
] })
|
|
2011
|
+
]
|
|
2012
|
+
}
|
|
2013
|
+
)
|
|
2014
|
+
] });
|
|
2015
|
+
}
|
|
2016
|
+
function BlockedNotice({
|
|
2017
|
+
fault,
|
|
2018
|
+
onSignIn,
|
|
2019
|
+
styled
|
|
2020
|
+
}) {
|
|
2021
|
+
if (fault.kind === "signIn") {
|
|
2022
|
+
return /* @__PURE__ */ jsxs4(Fragment2, { children: [
|
|
2023
|
+
/* @__PURE__ */ jsx5("p", { role: "status", "data-foir-feedback-status": true, style: styled ? noteStyle : void 0, children: COPY.signIn }),
|
|
2024
|
+
onSignIn && /* @__PURE__ */ jsx5(
|
|
2025
|
+
"button",
|
|
2026
|
+
{
|
|
2027
|
+
type: "button",
|
|
2028
|
+
"data-foir-feedback-signin": true,
|
|
2029
|
+
onClick: onSignIn,
|
|
2030
|
+
style: styled ? submitStyle2 : void 0,
|
|
2031
|
+
children: COPY.signInAction
|
|
2032
|
+
}
|
|
2033
|
+
)
|
|
2034
|
+
] });
|
|
2035
|
+
}
|
|
2036
|
+
return /* @__PURE__ */ jsx5("p", { role: "status", "data-foir-feedback-status": true, style: styled ? noteStyle : void 0, children: fault.kind === "scope" ? COPY.notEnabled : COPY.unavailable });
|
|
2037
|
+
}
|
|
2038
|
+
function failureCopy(fault) {
|
|
2039
|
+
if (fault.kind === "signIn") return COPY.signIn;
|
|
2040
|
+
if (fault.kind === "scope") return COPY.notEnabled;
|
|
2041
|
+
return COPY.sendFailed;
|
|
2042
|
+
}
|
|
2043
|
+
function reportFault(fault, err, action) {
|
|
2044
|
+
if (fault.kind === "scope") {
|
|
2045
|
+
console.error(
|
|
2046
|
+
`[foir] <FoirFeedback>: the API refused with "missing required scope: ${fault.scope}". This is not a bad API key: the key reached the surface. The signed-in CUSTOMER's role does not grant ${fault.scope}, and a new project's default customer role does not carry the notes scopes. Add it to the role that customer holds, or to the project's default customer role, and this works with no code change.`,
|
|
2047
|
+
err
|
|
2048
|
+
);
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
if (fault.kind === "other") {
|
|
2052
|
+
console.error(`[foir] <FoirFeedback>: ${action}`, err);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
function useResolvedSubject(subjects, override) {
|
|
2056
|
+
const subscribe = useCallback2(
|
|
2057
|
+
(listener) => subjects?.subscribe(listener) ?? (() => {
|
|
2058
|
+
}),
|
|
2059
|
+
[subjects]
|
|
2060
|
+
);
|
|
2061
|
+
const snapshot = useCallback2(() => subjects?.subject(), [subjects]);
|
|
2062
|
+
const serverSnapshot = useCallback2(() => void 0, []);
|
|
2063
|
+
const fetched = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
|
|
2064
|
+
if (override !== void 0) return override ?? void 0;
|
|
2065
|
+
return fetched;
|
|
2066
|
+
}
|
|
2067
|
+
function useNoSubjectNotice(open, resolved, hasRegistry, hasOverride) {
|
|
2068
|
+
const announced = useRef2(false);
|
|
2069
|
+
useEffect3(() => {
|
|
2070
|
+
if (process.env.NODE_ENV === "production") return;
|
|
2071
|
+
if (!open || resolved || announced.current) return;
|
|
2072
|
+
announced.current = true;
|
|
2073
|
+
if (!hasRegistry && !hasOverride) {
|
|
2074
|
+
console.info(
|
|
2075
|
+
"[foir] <FoirFeedback>: no `subjects` registry and no `subject` prop, so this widget can never know what a comment is about. Build one with createFoirSubjectRegistry(), pass it to createFoirClient({ subjects }), and pass the same object here."
|
|
2076
|
+
);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
console.info(
|
|
2080
|
+
"[foir] <FoirFeedback>: nothing to comment on. Every record this page fetched arrived inside a list or a search result, and the registry deliberately does not promote one of those to be the subject. Read one record by name, or pass the `subject` prop."
|
|
2081
|
+
);
|
|
2082
|
+
}, [open, resolved, hasRegistry, hasOverride]);
|
|
2083
|
+
}
|
|
1007
2084
|
export {
|
|
2085
|
+
FoirFeedback,
|
|
1008
2086
|
FoirProvider,
|
|
1009
2087
|
OAuthButtons,
|
|
1010
2088
|
SignIn,
|