@atlasauth/js 0.17.0 → 0.19.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 +14 -0
- package/dist/attempt.d.ts +34 -1
- package/dist/attempt.js +2 -0
- package/dist/attempt.js.map +1 -1
- package/dist/better-auth-bridge.d.ts +62 -0
- package/dist/better-auth-bridge.js +83 -0
- package/dist/better-auth-bridge.js.map +1 -0
- package/dist/captcha-controller.d.ts +3 -0
- package/dist/captcha-controller.js +10 -1
- package/dist/captcha-controller.js.map +1 -1
- package/dist/fapi.d.ts +10 -0
- package/dist/fapi.js +27 -1
- package/dist/fapi.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/passkey-sign-up.d.ts +3 -0
- package/dist/passkey-sign-up.js +1 -0
- package/dist/passkey-sign-up.js.map +1 -1
- package/dist/policies.d.ts +156 -0
- package/dist/policies.js +103 -0
- package/dist/policies.js.map +1 -0
- package/dist/policy-acceptance.d.ts +259 -0
- package/dist/policy-acceptance.js +698 -0
- package/dist/policy-acceptance.js.map +1 -0
- package/dist/policy-dom.d.ts +106 -0
- package/dist/policy-dom.js +438 -0
- package/dist/policy-dom.js.map +1 -0
- package/dist/redirect.d.ts +5 -1
- package/dist/redirect.js +4 -1
- package/dist/redirect.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Policy acceptance (terms of service, privacy, any policy) at sign-up and
|
|
4
|
+
* sign-in: the framework-agnostic half every prebuilt UI shares.
|
|
5
|
+
*
|
|
6
|
+
* What lives here:
|
|
7
|
+
* - the wire types and tolerant normalisers for `GET /v1/client/policies`,
|
|
8
|
+
* `GET /v1/client/me/policies` and a sign-in attempt's `pending_policies`;
|
|
9
|
+
* - {@link PolicyAcceptanceController}, a small observable state machine: the
|
|
10
|
+
* policies to show, one checked/read flag per policy, the document the user
|
|
11
|
+
* has open (and for how long), and the `policy_acceptances` payload to send;
|
|
12
|
+
* - the pure helpers a UI needs: scroll-to-end detection, a focus trap, a safe
|
|
13
|
+
* markdown renderer for policies delivered as markdown, and the 422
|
|
14
|
+
* `POLICY_ACCEPTANCE_REQUIRED` reader;
|
|
15
|
+
* - the FAPI calls: load policies, accept them in a sign-in attempt, accept
|
|
16
|
+
* them from inside the app.
|
|
17
|
+
*
|
|
18
|
+
* Display modes (a policy's `display`):
|
|
19
|
+
* - `checkbox`: a checkbox, combined into one sentence when several policies
|
|
20
|
+
* use it (or one each); the policy name opens the document in an in-page
|
|
21
|
+
* viewer, not a new tab;
|
|
22
|
+
* - `scroll`: an unskippable modal, accepted only after the end of the text is
|
|
23
|
+
* seen (or the text fits); declining cancels;
|
|
24
|
+
* - `link`: a clickwrap sentence under the submit button; submitting accepts.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here is a security boundary: `scrolled_to_end` and `view_ms` are
|
|
27
|
+
* client-attested evidence the server records alongside the acceptance.
|
|
28
|
+
*/
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.PolicyGateController = exports.POLICY_STRINGS = exports.PolicyAcceptanceController = exports.acceptSignInPolicies = void 0;
|
|
31
|
+
exports.normalizePolicy = normalizePolicy;
|
|
32
|
+
exports.normalizePolicies = normalizePolicies;
|
|
33
|
+
exports.pendingPoliciesOf = pendingPoliciesOf;
|
|
34
|
+
exports.normalizeMyPolicies = normalizeMyPolicies;
|
|
35
|
+
exports.isPolicyAcceptanceRequired = isPolicyAcceptanceRequired;
|
|
36
|
+
exports.loadPolicies = loadPolicies;
|
|
37
|
+
exports.getSignUpPolicies = getSignUpPolicies;
|
|
38
|
+
exports.loadMyPolicies = loadMyPolicies;
|
|
39
|
+
exports.acceptMyPolicies = acceptMyPolicies;
|
|
40
|
+
exports.parsePolicyAcceptances = parsePolicyAcceptances;
|
|
41
|
+
exports.createPolicyController = createPolicyController;
|
|
42
|
+
exports.isScrolledToEnd = isScrolledToEnd;
|
|
43
|
+
exports.observeScrollEnd = observeScrollEnd;
|
|
44
|
+
exports.trapFocus = trapFocus;
|
|
45
|
+
exports.markdownToSafeHtml = markdownToSafeHtml;
|
|
46
|
+
exports.policyHtml = policyHtml;
|
|
47
|
+
exports.formatPolicyDate = formatPolicyDate;
|
|
48
|
+
exports.policyTranslator = policyTranslator;
|
|
49
|
+
exports.joinTitles = joinTitles;
|
|
50
|
+
exports.splitTemplate = splitTemplate;
|
|
51
|
+
exports.refreshPoliciesOnRequired = refreshPoliciesOnRequired;
|
|
52
|
+
exports.controllerForPending = controllerForPending;
|
|
53
|
+
const policies_1 = require("./policies");
|
|
54
|
+
const DISPLAYS = ['checkbox', 'scroll', 'link'];
|
|
55
|
+
function str(v) {
|
|
56
|
+
return typeof v === 'string' && v !== '' ? v : null;
|
|
57
|
+
}
|
|
58
|
+
/** A timestamp as ISO text: the API sends epoch milliseconds; text passes through. */
|
|
59
|
+
function when(v) {
|
|
60
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
61
|
+
return new Date(v).toISOString();
|
|
62
|
+
return str(v);
|
|
63
|
+
}
|
|
64
|
+
/** Read one policy from the wire (snake_case, flat or with a nested `version`/`current_version` object). */
|
|
65
|
+
function normalizePolicy(raw) {
|
|
66
|
+
if (!raw || typeof raw !== 'object')
|
|
67
|
+
return null;
|
|
68
|
+
const r = raw;
|
|
69
|
+
const nested = [r.version, r.current_version].find((v) => v && typeof v === 'object');
|
|
70
|
+
const pick = (name) => (r[name] !== undefined ? r[name] : nested?.[name]);
|
|
71
|
+
const key = str(r.key) ?? str(r.policy);
|
|
72
|
+
const versionId = str(pick('version_id')) ?? str(nested?.id);
|
|
73
|
+
if (!key || !versionId)
|
|
74
|
+
return null;
|
|
75
|
+
const display = DISPLAYS.includes(r.display) ? r.display : 'checkbox';
|
|
76
|
+
const version = str(typeof r.version === 'string' ? r.version : nested?.version) ?? '';
|
|
77
|
+
return {
|
|
78
|
+
key,
|
|
79
|
+
title: str(r.title) ?? key,
|
|
80
|
+
display,
|
|
81
|
+
required: r.required !== false,
|
|
82
|
+
appliesTo: r.applies_to === 'sign_in_and_sign_up' ? 'sign_in_and_sign_up' : 'sign_up',
|
|
83
|
+
versionId,
|
|
84
|
+
version,
|
|
85
|
+
contentHtml: str(pick('content_html')),
|
|
86
|
+
contentMarkdown: str(pick('content_markdown')),
|
|
87
|
+
contentUrl: str(pick('content_url')),
|
|
88
|
+
contentSha256: str(pick('content_sha256')) ?? '',
|
|
89
|
+
effectiveAt: when(pick('effective_at')),
|
|
90
|
+
summaryOfChanges: str(pick('summary_of_changes')),
|
|
91
|
+
reaccept: typeof r.reaccept === 'boolean' ? r.reaccept : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Read a list of policies from any of the shapes the API uses (`[..]`, `{policies}`, `{data}`). */
|
|
95
|
+
function normalizePolicies(body) {
|
|
96
|
+
const list = Array.isArray(body)
|
|
97
|
+
? body
|
|
98
|
+
: body && typeof body === 'object'
|
|
99
|
+
? (body.policies ?? body.data)
|
|
100
|
+
: null;
|
|
101
|
+
if (!Array.isArray(list))
|
|
102
|
+
return [];
|
|
103
|
+
return list.map(normalizePolicy).filter((p) => p !== null);
|
|
104
|
+
}
|
|
105
|
+
/** The policies a sign-in attempt is waiting on (`pending_policies`). */
|
|
106
|
+
function pendingPoliciesOf(attempt) {
|
|
107
|
+
return normalizePolicies(attempt?.pending_policies);
|
|
108
|
+
}
|
|
109
|
+
/** Read `GET /v1/client/me/policies`. */
|
|
110
|
+
function normalizeMyPolicies(body) {
|
|
111
|
+
const list = Array.isArray(body)
|
|
112
|
+
? body
|
|
113
|
+
: (body?.policies ?? body?.data);
|
|
114
|
+
if (!Array.isArray(list))
|
|
115
|
+
return [];
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const item of list) {
|
|
118
|
+
if (!item || typeof item !== 'object')
|
|
119
|
+
continue;
|
|
120
|
+
const r = item;
|
|
121
|
+
const key = str(r.key) ?? str(r.policy);
|
|
122
|
+
if (!key)
|
|
123
|
+
continue;
|
|
124
|
+
out.push({
|
|
125
|
+
key,
|
|
126
|
+
title: str(r.title) ?? key,
|
|
127
|
+
acceptedVersion: str(r.accepted_version),
|
|
128
|
+
currentVersion: str(r.current_version),
|
|
129
|
+
needsReaccept: r.needs_reaccept === true,
|
|
130
|
+
graceEndsAt: when(r.grace_ends_at),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/** Whether an error list contains the server's "accept the policies" refusal. */
|
|
136
|
+
function isPolicyAcceptanceRequired(errors) {
|
|
137
|
+
return !!errors?.some((e) => e.code === 'POLICY_ACCEPTANCE_REQUIRED');
|
|
138
|
+
}
|
|
139
|
+
// ---- FAPI calls ----------------------------------------------------------
|
|
140
|
+
// Thin camel-cased views over the wire helpers in `policies.ts` (`getPolicies`,
|
|
141
|
+
// `getMyPolicies`, `acceptPolicies`, `acceptAttemptPolicies`), for the UIs.
|
|
142
|
+
/** `GET /v1/client/policies`, read into {@link Policy} objects (empty on failure). */
|
|
143
|
+
async function loadPolicies(client, opts = {}) {
|
|
144
|
+
const res = await (0, policies_1.getPolicies)(client, opts.locale ? { locale: opts.locale } : {});
|
|
145
|
+
if (!res.ok)
|
|
146
|
+
return [];
|
|
147
|
+
const all = normalizePolicies(res.data);
|
|
148
|
+
return opts.signIn ? all.filter((p) => p.appliesTo === 'sign_in_and_sign_up') : all;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The policies a sign-up must show: every one with a published version. Required
|
|
152
|
+
* ones block the submit; optional ones may be left unticked.
|
|
153
|
+
*/
|
|
154
|
+
async function getSignUpPolicies(client, opts = {}) {
|
|
155
|
+
return loadPolicies(client, opts);
|
|
156
|
+
}
|
|
157
|
+
/** `GET /v1/client/me/policies`, read into {@link MyPolicyStatus} entries (empty on failure). */
|
|
158
|
+
async function loadMyPolicies(client) {
|
|
159
|
+
const res = await (0, policies_1.getMyPolicies)(client);
|
|
160
|
+
return res.ok ? normalizeMyPolicies(res.data) : [];
|
|
161
|
+
}
|
|
162
|
+
/** `POST /v1/client/me/policies/accept`: accept from inside the app (a banner). */
|
|
163
|
+
async function acceptMyPolicies(client, acceptances) {
|
|
164
|
+
const res = await (0, policies_1.acceptPolicies)(client, acceptances);
|
|
165
|
+
return res.ok ? { ok: true, errors: [] } : { ok: false, errors: res.errors };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* `POST /v1/client/sign_ins/:id/accept_policies`: accept the `pending_policies`
|
|
169
|
+
* of an attempt in `needs_policy_acceptance` (the same call as
|
|
170
|
+
* {@link acceptAttemptPolicies}). Returns the continued flow (a session ticket,
|
|
171
|
+
* or still held when a newer version was published); on a refusal the attempt is kept.
|
|
172
|
+
*/
|
|
173
|
+
exports.acceptSignInPolicies = policies_1.acceptAttemptPolicies;
|
|
174
|
+
/** Parse the JSON the headless `advance` step carries in `values.policy_acceptances`. */
|
|
175
|
+
function parsePolicyAcceptances(value) {
|
|
176
|
+
if (!value)
|
|
177
|
+
return [];
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(value);
|
|
180
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The state machine behind every policy UI. Subscribe for changes; read
|
|
188
|
+
* {@link satisfied} to enable the submit and {@link build} for the request body.
|
|
189
|
+
* State is kept per `versionId`, so a refreshed list (after a 422) keeps the
|
|
190
|
+
* ticks the user already made on versions that did not change.
|
|
191
|
+
*/
|
|
192
|
+
class PolicyAcceptanceController {
|
|
193
|
+
list = [];
|
|
194
|
+
entries = new Map();
|
|
195
|
+
open = null;
|
|
196
|
+
listeners = new Set();
|
|
197
|
+
now;
|
|
198
|
+
combined;
|
|
199
|
+
constructor(policies = [], options = {}) {
|
|
200
|
+
this.combined = options.combined !== false;
|
|
201
|
+
this.now = options.now ?? (() => Date.now());
|
|
202
|
+
this.setPolicies(policies);
|
|
203
|
+
}
|
|
204
|
+
subscribe(fn) {
|
|
205
|
+
this.listeners.add(fn);
|
|
206
|
+
return () => this.listeners.delete(fn);
|
|
207
|
+
}
|
|
208
|
+
emit() {
|
|
209
|
+
for (const fn of [...this.listeners])
|
|
210
|
+
fn();
|
|
211
|
+
}
|
|
212
|
+
entry(key) {
|
|
213
|
+
const policy = this.list.find((p) => p.key === key);
|
|
214
|
+
const id = policy ? policy.versionId : key;
|
|
215
|
+
let e = this.entries.get(id);
|
|
216
|
+
if (!e) {
|
|
217
|
+
e = { checked: false, scrolledToEnd: false, viewMs: 0, openedAt: null };
|
|
218
|
+
this.entries.set(id, e);
|
|
219
|
+
}
|
|
220
|
+
return e;
|
|
221
|
+
}
|
|
222
|
+
get policies() {
|
|
223
|
+
return this.list;
|
|
224
|
+
}
|
|
225
|
+
get viewing() {
|
|
226
|
+
return this.list.find((p) => p.key === this.open) ?? null;
|
|
227
|
+
}
|
|
228
|
+
snapshot() {
|
|
229
|
+
const boxes = this.checkboxPolicies();
|
|
230
|
+
return {
|
|
231
|
+
policies: this.list,
|
|
232
|
+
combined: this.combined,
|
|
233
|
+
viewing: this.open,
|
|
234
|
+
combinedChecked: boxes.length > 0 && boxes.every((p) => this.isChecked(p.key)),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** Replace the policies (e.g. after a refresh); ticks on unchanged versions survive. */
|
|
238
|
+
setPolicies(policies) {
|
|
239
|
+
this.list = policies;
|
|
240
|
+
if (this.open && !policies.some((p) => p.key === this.open))
|
|
241
|
+
this.open = null;
|
|
242
|
+
this.emit();
|
|
243
|
+
}
|
|
244
|
+
checkboxPolicies() {
|
|
245
|
+
return this.list.filter((p) => p.display === 'checkbox');
|
|
246
|
+
}
|
|
247
|
+
scrollPolicies() {
|
|
248
|
+
return this.list.filter((p) => p.display === 'scroll');
|
|
249
|
+
}
|
|
250
|
+
linkPolicies() {
|
|
251
|
+
return this.list.filter((p) => p.display === 'link');
|
|
252
|
+
}
|
|
253
|
+
isChecked(key) {
|
|
254
|
+
const p = this.list.find((x) => x.key === key);
|
|
255
|
+
if (!p)
|
|
256
|
+
return false;
|
|
257
|
+
// A clickwrap sentence is accepted by submitting.
|
|
258
|
+
if (p.display === 'link')
|
|
259
|
+
return true;
|
|
260
|
+
return this.entry(key).checked;
|
|
261
|
+
}
|
|
262
|
+
setChecked(key, checked) {
|
|
263
|
+
this.entry(key).checked = checked;
|
|
264
|
+
this.emit();
|
|
265
|
+
}
|
|
266
|
+
/** Tick or untick every checkbox-mode policy at once (the combined box). */
|
|
267
|
+
setAllChecked(checked) {
|
|
268
|
+
for (const p of this.checkboxPolicies())
|
|
269
|
+
this.entry(p.key).checked = checked;
|
|
270
|
+
this.emit();
|
|
271
|
+
}
|
|
272
|
+
/** The user accepted a scroll-mode policy in its modal. */
|
|
273
|
+
acceptScroll(key) {
|
|
274
|
+
const e = this.entry(key);
|
|
275
|
+
e.checked = true;
|
|
276
|
+
e.scrolledToEnd = true;
|
|
277
|
+
this.emit();
|
|
278
|
+
}
|
|
279
|
+
/** The first scroll-mode policy still to be read and accepted, or null. */
|
|
280
|
+
pendingScroll() {
|
|
281
|
+
return this.scrollPolicies().find((p) => !this.isChecked(p.key)) ?? null;
|
|
282
|
+
}
|
|
283
|
+
/** Open a document in the in-page viewer and start its reading clock. */
|
|
284
|
+
openDocument(key) {
|
|
285
|
+
if (this.open)
|
|
286
|
+
this.closeDocument();
|
|
287
|
+
if (!this.list.some((p) => p.key === key))
|
|
288
|
+
return;
|
|
289
|
+
this.open = key;
|
|
290
|
+
this.entry(key).openedAt = this.now();
|
|
291
|
+
this.emit();
|
|
292
|
+
}
|
|
293
|
+
closeDocument() {
|
|
294
|
+
if (!this.open)
|
|
295
|
+
return;
|
|
296
|
+
this.stopClock(this.open);
|
|
297
|
+
this.open = null;
|
|
298
|
+
this.emit();
|
|
299
|
+
}
|
|
300
|
+
/** Start the reading clock for a policy shown some other way (a modal). */
|
|
301
|
+
startClock(key) {
|
|
302
|
+
const e = this.entry(key);
|
|
303
|
+
if (e.openedAt === null)
|
|
304
|
+
e.openedAt = this.now();
|
|
305
|
+
}
|
|
306
|
+
stopClock(key) {
|
|
307
|
+
const e = this.entry(key);
|
|
308
|
+
if (e.openedAt !== null) {
|
|
309
|
+
e.viewMs += Math.max(0, this.now() - e.openedAt);
|
|
310
|
+
e.openedAt = null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** The scroll-to-end flag was reached while reading `key`. */
|
|
314
|
+
markScrolledToEnd(key) {
|
|
315
|
+
const e = this.entry(key);
|
|
316
|
+
if (!e.scrolledToEnd) {
|
|
317
|
+
e.scrolledToEnd = true;
|
|
318
|
+
this.emit();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
hasScrolledToEnd(key) {
|
|
322
|
+
return this.entry(key).scrolledToEnd;
|
|
323
|
+
}
|
|
324
|
+
viewMs(key) {
|
|
325
|
+
const e = this.entry(key);
|
|
326
|
+
return Math.round(e.viewMs + (e.openedAt !== null ? Math.max(0, this.now() - e.openedAt) : 0));
|
|
327
|
+
}
|
|
328
|
+
/** The required policies still unaccepted. Scroll-mode ones are accepted in their modal, not by a box. */
|
|
329
|
+
missing() {
|
|
330
|
+
return this.list.filter((p) => p.required && !this.isChecked(p.key));
|
|
331
|
+
}
|
|
332
|
+
/** Whether the submit may go: every required policy is accepted. Scroll-mode ones count as pending until read. */
|
|
333
|
+
satisfied() {
|
|
334
|
+
return this.missing().length === 0;
|
|
335
|
+
}
|
|
336
|
+
/** Whether the submit button may be enabled: scroll-mode policies are read on submit, so they do not block it. */
|
|
337
|
+
canSubmit() {
|
|
338
|
+
return this.list.every((p) => !p.required || p.display === 'scroll' || this.isChecked(p.key));
|
|
339
|
+
}
|
|
340
|
+
/** The `policy_acceptances` body: every accepted policy (and every clickwrap one). */
|
|
341
|
+
build() {
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const p of this.list) {
|
|
344
|
+
if (!this.isChecked(p.key))
|
|
345
|
+
continue;
|
|
346
|
+
const e = this.entry(p.key);
|
|
347
|
+
const viewMs = this.viewMs(p.key);
|
|
348
|
+
out.push({
|
|
349
|
+
policy: p.key,
|
|
350
|
+
version_id: p.versionId,
|
|
351
|
+
content_sha256: p.contentSha256,
|
|
352
|
+
display: p.display,
|
|
353
|
+
...(p.display === 'scroll' || e.scrolledToEnd ? { scrolled_to_end: e.scrolledToEnd } : {}),
|
|
354
|
+
...(viewMs > 0 ? { view_ms: viewMs } : {}),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
exports.PolicyAcceptanceController = PolicyAcceptanceController;
|
|
361
|
+
/** Build a controller already holding `policies` (a sign-in's `pending_policies`, a banner's, …). */
|
|
362
|
+
function createPolicyController(policies = [], options = {}) {
|
|
363
|
+
return new PolicyAcceptanceController(policies, options);
|
|
364
|
+
}
|
|
365
|
+
// ---- DOM-free helpers -----------------------------------------------------
|
|
366
|
+
/** Whether a scroll container has reached its end (also true when the content fits). */
|
|
367
|
+
function isScrolledToEnd(el, slack = 2) {
|
|
368
|
+
return el.scrollHeight - el.clientHeight - el.scrollTop <= slack;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Call `onEnd` once the end of a scroll container has been seen: an
|
|
372
|
+
* IntersectionObserver on an end sentinel (scrolling, the keyboard, a screen
|
|
373
|
+
* reader all count), with a scroll listener fallback; and straight away when the
|
|
374
|
+
* content fits. Returns a function that stops watching.
|
|
375
|
+
*/
|
|
376
|
+
function observeScrollEnd(scroller, sentinel, onEnd) {
|
|
377
|
+
let done = false;
|
|
378
|
+
let observer = null;
|
|
379
|
+
const fire = () => {
|
|
380
|
+
if (done)
|
|
381
|
+
return;
|
|
382
|
+
done = true;
|
|
383
|
+
stop();
|
|
384
|
+
onEnd();
|
|
385
|
+
};
|
|
386
|
+
const check = () => {
|
|
387
|
+
if (isScrolledToEnd(scroller))
|
|
388
|
+
fire();
|
|
389
|
+
};
|
|
390
|
+
const stop = () => {
|
|
391
|
+
observer?.disconnect();
|
|
392
|
+
scroller.removeEventListener('scroll', check);
|
|
393
|
+
if (typeof window !== 'undefined')
|
|
394
|
+
window.removeEventListener('resize', check);
|
|
395
|
+
};
|
|
396
|
+
if (sentinel && typeof IntersectionObserver !== 'undefined') {
|
|
397
|
+
observer = new IntersectionObserver((entries) => {
|
|
398
|
+
if (entries.some((e) => e.isIntersecting))
|
|
399
|
+
fire();
|
|
400
|
+
}, { root: scroller, threshold: 0 });
|
|
401
|
+
observer.observe(sentinel);
|
|
402
|
+
}
|
|
403
|
+
scroller.addEventListener('scroll', check, { passive: true });
|
|
404
|
+
if (typeof window !== 'undefined')
|
|
405
|
+
window.addEventListener('resize', check);
|
|
406
|
+
// Content that fits needs no scrolling.
|
|
407
|
+
check();
|
|
408
|
+
return () => {
|
|
409
|
+
done = true;
|
|
410
|
+
stop();
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
414
|
+
/**
|
|
415
|
+
* Keep Tab inside `container` (a modal) and, when `escape` is `false`, swallow
|
|
416
|
+
* Escape so the dialog can only be left by its buttons. Returns a release
|
|
417
|
+
* function that also restores focus to whatever had it.
|
|
418
|
+
*/
|
|
419
|
+
function trapFocus(container, options = {}) {
|
|
420
|
+
const doc = container.ownerDocument;
|
|
421
|
+
const previous = doc.activeElement;
|
|
422
|
+
const onKey = (event) => {
|
|
423
|
+
if (event.key === 'Escape' && options.escape !== true) {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
event.stopPropagation();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (event.key !== 'Tab')
|
|
429
|
+
return;
|
|
430
|
+
const items = Array.from(container.querySelectorAll(FOCUSABLE)).filter((el) => !el.hasAttribute('hidden'));
|
|
431
|
+
if (items.length === 0) {
|
|
432
|
+
event.preventDefault();
|
|
433
|
+
container.focus();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const first = items[0];
|
|
437
|
+
const last = items[items.length - 1];
|
|
438
|
+
const active = doc.activeElement;
|
|
439
|
+
if (event.shiftKey && (active === first || !container.contains(active))) {
|
|
440
|
+
event.preventDefault();
|
|
441
|
+
last.focus();
|
|
442
|
+
}
|
|
443
|
+
else if (!event.shiftKey && (active === last || !container.contains(active))) {
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
first.focus();
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
const onFocusIn = (event) => {
|
|
449
|
+
if (event.target instanceof Node && !container.contains(event.target)) {
|
|
450
|
+
(options.initialFocus ?? container).focus();
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
doc.addEventListener('keydown', onKey, true);
|
|
454
|
+
doc.addEventListener('focusin', onFocusIn);
|
|
455
|
+
(options.initialFocus ?? container).focus();
|
|
456
|
+
return () => {
|
|
457
|
+
doc.removeEventListener('keydown', onKey, true);
|
|
458
|
+
doc.removeEventListener('focusin', onFocusIn);
|
|
459
|
+
previous?.focus?.();
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
// ---- Content rendering ----------------------------------------------------
|
|
463
|
+
function escapeHtml(s) {
|
|
464
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
465
|
+
}
|
|
466
|
+
function inlineMarkdown(text) {
|
|
467
|
+
let out = escapeHtml(text);
|
|
468
|
+
out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
469
|
+
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
470
|
+
out = out.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>');
|
|
471
|
+
out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* A small, safe markdown renderer (headings, paragraphs, lists, bold, italics,
|
|
476
|
+
* code, http(s) links). Every character of input is HTML-escaped first, so it
|
|
477
|
+
* can never inject markup. It is the fallback for a policy delivered as
|
|
478
|
+
* markdown only; the server normally sends sanitised HTML.
|
|
479
|
+
*/
|
|
480
|
+
function markdownToSafeHtml(markdown) {
|
|
481
|
+
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
|
|
482
|
+
const html = [];
|
|
483
|
+
let para = [];
|
|
484
|
+
let list = null;
|
|
485
|
+
const flushPara = () => {
|
|
486
|
+
if (para.length)
|
|
487
|
+
html.push(`<p>${inlineMarkdown(para.join(' '))}</p>`);
|
|
488
|
+
para = [];
|
|
489
|
+
};
|
|
490
|
+
const closeList = () => {
|
|
491
|
+
if (list)
|
|
492
|
+
html.push(`</${list}>`);
|
|
493
|
+
list = null;
|
|
494
|
+
};
|
|
495
|
+
for (const line of lines) {
|
|
496
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
497
|
+
const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
|
|
498
|
+
const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
499
|
+
if (heading) {
|
|
500
|
+
flushPara();
|
|
501
|
+
closeList();
|
|
502
|
+
const level = heading[1].length;
|
|
503
|
+
html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
|
504
|
+
}
|
|
505
|
+
else if (bullet || numbered) {
|
|
506
|
+
flushPara();
|
|
507
|
+
const kind = bullet ? 'ul' : 'ol';
|
|
508
|
+
if (list !== kind) {
|
|
509
|
+
closeList();
|
|
510
|
+
html.push(`<${kind}>`);
|
|
511
|
+
list = kind;
|
|
512
|
+
}
|
|
513
|
+
html.push(`<li>${inlineMarkdown((bullet ?? numbered)[1])}</li>`);
|
|
514
|
+
}
|
|
515
|
+
else if (line.trim() === '') {
|
|
516
|
+
flushPara();
|
|
517
|
+
closeList();
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
closeList();
|
|
521
|
+
para.push(line.trim());
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
flushPara();
|
|
525
|
+
closeList();
|
|
526
|
+
return html.join('\n');
|
|
527
|
+
}
|
|
528
|
+
/** The HTML to draw for a policy: the server's sanitised HTML, else its markdown rendered safely. */
|
|
529
|
+
function policyHtml(policy) {
|
|
530
|
+
if (policy.contentHtml)
|
|
531
|
+
return policy.contentHtml;
|
|
532
|
+
return policy.contentMarkdown ? markdownToSafeHtml(policy.contentMarkdown) : '';
|
|
533
|
+
}
|
|
534
|
+
/** A readable "effective 24 Oct 2026" style date, or '' when unparsable. */
|
|
535
|
+
function formatPolicyDate(iso) {
|
|
536
|
+
if (!iso)
|
|
537
|
+
return '';
|
|
538
|
+
const d = new Date(iso);
|
|
539
|
+
if (Number.isNaN(d.getTime()))
|
|
540
|
+
return '';
|
|
541
|
+
return d.toISOString().slice(0, 10);
|
|
542
|
+
}
|
|
543
|
+
// ---- Strings ---------------------------------------------------------------
|
|
544
|
+
/** The English strings every policy UI draws (`policy.*` catalog keys). */
|
|
545
|
+
exports.POLICY_STRINGS = Object.freeze({
|
|
546
|
+
'policy.agreeCombined': 'I agree to the {policies}',
|
|
547
|
+
'policy.agreeOne': 'I agree to the {policy}',
|
|
548
|
+
'policy.clickwrap': 'By continuing you agree to the {policies}.',
|
|
549
|
+
'policy.and': 'and',
|
|
550
|
+
'policy.read': 'Read the {policy}',
|
|
551
|
+
'policy.viewerBack': 'Back',
|
|
552
|
+
'policy.version': 'Version {version}',
|
|
553
|
+
'policy.effective': 'Effective {date}',
|
|
554
|
+
'policy.scrollHint': 'Scroll to the end to accept',
|
|
555
|
+
'policy.accept': 'Accept',
|
|
556
|
+
'policy.decline': 'Decline',
|
|
557
|
+
'policy.summaryTitle': 'What changed',
|
|
558
|
+
'policy.reacceptTitle': '{policy} has been updated',
|
|
559
|
+
'policy.reacceptIntro': 'Please review and accept the updated terms to continue.',
|
|
560
|
+
'policy.pendingTitle': 'Accept to continue',
|
|
561
|
+
'policy.pendingIntro': 'Please accept the following to continue signing in.',
|
|
562
|
+
'policy.required': 'Please accept the {policy} to continue.',
|
|
563
|
+
'policy.requiredGeneric': 'Please accept the required policies to continue.',
|
|
564
|
+
'policy.declined': 'You need to accept the {policy} to use this app.',
|
|
565
|
+
'policy.loading': 'Loading…',
|
|
566
|
+
'policy.bannerTitle': 'Updated policies',
|
|
567
|
+
'policy.bannerBody': 'We have updated: {policies}. Please review and accept.',
|
|
568
|
+
'policy.bannerGrace': 'Accept by {date} to keep your access.',
|
|
569
|
+
'policy.bannerReview': 'Review and accept',
|
|
570
|
+
'policy.submit': 'Accept and continue',
|
|
571
|
+
});
|
|
572
|
+
/** A `t` function over a catalog lookup that falls back to the English policy strings. */
|
|
573
|
+
function policyTranslator(lookup) {
|
|
574
|
+
return (key, vars = {}) => {
|
|
575
|
+
const out = lookup(key, vars);
|
|
576
|
+
if (out && out !== key)
|
|
577
|
+
return out;
|
|
578
|
+
const template = exports.POLICY_STRINGS[key] ?? key;
|
|
579
|
+
return template.replace(/\{(\w+)\}/g, (m, n) => vars[n] ?? m);
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/** Join titles as "A, B and C". */
|
|
583
|
+
function joinTitles(titles, and) {
|
|
584
|
+
if (titles.length <= 1)
|
|
585
|
+
return titles[0] ?? '';
|
|
586
|
+
return `${titles.slice(0, -1).join(', ')} ${and} ${titles[titles.length - 1]}`;
|
|
587
|
+
}
|
|
588
|
+
/** Split a `{name}` template into the text before and after the placeholder (for sentences that embed links). */
|
|
589
|
+
function splitTemplate(template, name) {
|
|
590
|
+
const at = template.indexOf(`{${name}}`);
|
|
591
|
+
if (at < 0)
|
|
592
|
+
return [template, ''];
|
|
593
|
+
return [template.slice(0, at), template.slice(at + name.length + 2)];
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* After the server answered 422 `POLICY_ACCEPTANCE_REQUIRED` (a policy was
|
|
597
|
+
* published or changed while the form was open): reload the policies into the
|
|
598
|
+
* controller so the form re-shows what is needed now. Returns whether the error
|
|
599
|
+
* was that refusal.
|
|
600
|
+
*/
|
|
601
|
+
async function refreshPoliciesOnRequired(client, controller, errors, opts = {}) {
|
|
602
|
+
if (!isPolicyAcceptanceRequired(errors))
|
|
603
|
+
return false;
|
|
604
|
+
const fresh = await loadPolicies(client, opts);
|
|
605
|
+
if (fresh.length > 0)
|
|
606
|
+
controller.setPolicies(fresh);
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* For a sign-in attempt in `needs_policy_acceptance`: a controller holding its
|
|
611
|
+
* `pending_policies` — with the content fetched from `GET /v1/client/policies`
|
|
612
|
+
* when the attempt only listed them.
|
|
613
|
+
*/
|
|
614
|
+
async function controllerForPending(client, attempt, opts = {}) {
|
|
615
|
+
const rawList = Array.isArray(attempt?.pending_policies) ? attempt.pending_policies : [];
|
|
616
|
+
if (rawList.length === 0) {
|
|
617
|
+
// Resumed from a redirect (the URL carries the attempt, not its pending
|
|
618
|
+
// list): show every required policy; the server checks each acceptance.
|
|
619
|
+
const all = await loadPolicies(client, opts);
|
|
620
|
+
return new PolicyAcceptanceController(all.filter((p) => p.required), opts);
|
|
621
|
+
}
|
|
622
|
+
let pending = rawList.map(normalizePolicy).filter((p) => p !== null);
|
|
623
|
+
if (pending.some((p) => !p.contentHtml && !p.contentMarkdown)) {
|
|
624
|
+
const all = await loadPolicies(client, opts);
|
|
625
|
+
pending = pending.map((p, i) => {
|
|
626
|
+
const full = all.find((a) => a.key === p.key);
|
|
627
|
+
if (!full)
|
|
628
|
+
return p;
|
|
629
|
+
const raw = (rawList.find((r) => normalizePolicy(r)?.key === p.key) ?? rawList[i]);
|
|
630
|
+
const has = (name) => raw[name] !== undefined && raw[name] !== null;
|
|
631
|
+
return {
|
|
632
|
+
...full,
|
|
633
|
+
...(has('title') ? { title: p.title } : {}),
|
|
634
|
+
...(has('display') ? { display: p.display } : {}),
|
|
635
|
+
versionId: p.versionId,
|
|
636
|
+
version: p.version || full.version,
|
|
637
|
+
contentHtml: p.contentHtml ?? full.contentHtml,
|
|
638
|
+
contentMarkdown: p.contentMarkdown ?? full.contentMarkdown,
|
|
639
|
+
contentSha256: p.contentSha256 || full.contentSha256,
|
|
640
|
+
summaryOfChanges: p.summaryOfChanges ?? full.summaryOfChanges,
|
|
641
|
+
reaccept: p.reaccept,
|
|
642
|
+
};
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
// A sign-in acceptance is always required of the user, whatever the policy says elsewhere.
|
|
646
|
+
return new PolicyAcceptanceController(pending.map((p) => ({ ...p, required: true })), opts);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* In-app re-acceptance for a signed-in user, framework-free: loads
|
|
650
|
+
* `GET /v1/client/me/policies`, builds a controller for the policies that need
|
|
651
|
+
* re-accepting ({@link begin}) and posts the acceptance ({@link accept}). The
|
|
652
|
+
* framework SDKs' `<PolicyGate>` wraps it.
|
|
653
|
+
*/
|
|
654
|
+
class PolicyGateController {
|
|
655
|
+
client;
|
|
656
|
+
locale;
|
|
657
|
+
/** The policies that need re-acceptance (empty when none, or before {@link load}). */
|
|
658
|
+
pending = [];
|
|
659
|
+
loaded = false;
|
|
660
|
+
listeners = new Set();
|
|
661
|
+
constructor(client, locale) {
|
|
662
|
+
this.client = client;
|
|
663
|
+
this.locale = locale;
|
|
664
|
+
}
|
|
665
|
+
subscribe(fn) {
|
|
666
|
+
this.listeners.add(fn);
|
|
667
|
+
return () => this.listeners.delete(fn);
|
|
668
|
+
}
|
|
669
|
+
emit() {
|
|
670
|
+
for (const fn of [...this.listeners])
|
|
671
|
+
fn();
|
|
672
|
+
}
|
|
673
|
+
async load() {
|
|
674
|
+
this.pending = (await loadMyPolicies(this.client)).filter((p) => p.needsReaccept);
|
|
675
|
+
this.loaded = true;
|
|
676
|
+
this.emit();
|
|
677
|
+
}
|
|
678
|
+
/** The soonest end of grace among the pending policies, or null. */
|
|
679
|
+
get graceEndsAt() {
|
|
680
|
+
return this.pending.map((p) => p.graceEndsAt).filter((x) => !!x).sort()[0] ?? null;
|
|
681
|
+
}
|
|
682
|
+
/** A controller over the pending policies' current versions, all required. */
|
|
683
|
+
async begin() {
|
|
684
|
+
const all = await loadPolicies(this.client, { locale: this.locale });
|
|
685
|
+
const wanted = new Set(this.pending.map((p) => p.key));
|
|
686
|
+
return new PolicyAcceptanceController(all.filter((p) => wanted.has(p.key)).map((p) => ({ ...p, required: true })));
|
|
687
|
+
}
|
|
688
|
+
async accept(controller) {
|
|
689
|
+
const res = await acceptMyPolicies(this.client, controller.build());
|
|
690
|
+
if (res.ok) {
|
|
691
|
+
this.pending = [];
|
|
692
|
+
this.emit();
|
|
693
|
+
}
|
|
694
|
+
return res;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
exports.PolicyGateController = PolicyGateController;
|
|
698
|
+
//# sourceMappingURL=policy-acceptance.js.map
|