@scalemule/news-publication 0.0.70 → 0.0.73
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/README.md +20 -0
- package/dist/article-workflow-client.cjs +14 -2
- package/dist/article-workflow-client.d.cts +14 -1
- package/dist/article-workflow-client.d.ts +14 -1
- package/dist/article-workflow-client.js +14 -2
- package/dist/chunk-CVD64KP6.js +12 -0
- package/dist/index.cjs +13 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -3
- package/dist/reader-sso-client.cjs +364 -0
- package/dist/reader-sso-client.d.cts +131 -0
- package/dist/reader-sso-client.d.ts +131 -0
- package/dist/reader-sso-client.js +347 -0
- package/dist/reader-sso-events-DBw8pGSM.d.cts +16 -0
- package/dist/reader-sso-events-DBw8pGSM.d.ts +16 -0
- package/dist/reader-sso.cjs +506 -0
- package/dist/reader-sso.d.cts +318 -0
- package/dist/reader-sso.d.ts +318 -0
- package/dist/reader-sso.js +460 -0
- package/dist/toys-plugin.js +2 -2
- package/dist/toys-publication.js +2 -2
- package/package.json +11 -1
- package/dist/{chunk-SKG5C3MS.js → chunk-I3FNARQS.js} +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
type ReaderSsoProvider = "google" | "email";
|
|
4
|
+
declare const READER_SSO_BOT_PATTERN: RegExp;
|
|
5
|
+
/** Pages where a background navigation would be unwelcome or pointless. */
|
|
6
|
+
declare const READER_SSO_PRIVATE_PATHS: RegExp[];
|
|
7
|
+
/** One attempt per browser session window; the server's negative cache is the real guard. */
|
|
8
|
+
declare const READER_SSO_ATTEMPT_WINDOW_MS: number;
|
|
9
|
+
type SilentSignInEnvironment = {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
hasSession: boolean;
|
|
12
|
+
suppressed: boolean;
|
|
13
|
+
webdriver: boolean;
|
|
14
|
+
userAgent: string;
|
|
15
|
+
pathname: string;
|
|
16
|
+
search: string;
|
|
17
|
+
fieldFocused: boolean;
|
|
18
|
+
prerendering: boolean;
|
|
19
|
+
visibilityState: string;
|
|
20
|
+
framed: boolean;
|
|
21
|
+
lastAttemptAt: number | null;
|
|
22
|
+
now: number;
|
|
23
|
+
privatePaths?: RegExp[];
|
|
24
|
+
};
|
|
25
|
+
type SilentSignInDecision = {
|
|
26
|
+
attempt: true;
|
|
27
|
+
} | {
|
|
28
|
+
attempt: false;
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
declare function silentSignInDecision(env: SilentSignInEnvironment): SilentSignInDecision;
|
|
32
|
+
declare function silentSignInUrl(startPath: string, pathname: string, search: string): string;
|
|
33
|
+
declare function isFormField(element: Element | null): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Renders nothing. Once per page load, after `load` and an idle period, performs a
|
|
36
|
+
* top-level navigation to the silent sign-in start route when nothing argues
|
|
37
|
+
* against it. The server sets the negative-cache cookie on every silent negative,
|
|
38
|
+
* so this can never loop.
|
|
39
|
+
*/
|
|
40
|
+
declare function ReaderSsoBoot({ enabled, hasSession: hasSessionCookie, suppressed, startPath, sessionPath, privatePaths, idleDelayMs }: {
|
|
41
|
+
enabled?: boolean;
|
|
42
|
+
/** A reader session cookie is present (it may have been revoked elsewhere). */
|
|
43
|
+
hasSession: boolean;
|
|
44
|
+
suppressed: boolean;
|
|
45
|
+
startPath?: string;
|
|
46
|
+
/** Private, no-store endpoint that validates the cookie and clears it when stale. */
|
|
47
|
+
sessionPath?: string;
|
|
48
|
+
privatePaths?: RegExp[];
|
|
49
|
+
idleDelayMs?: number;
|
|
50
|
+
}): null;
|
|
51
|
+
type GreetingProfile = {
|
|
52
|
+
full_name?: string | null;
|
|
53
|
+
locale?: string | null;
|
|
54
|
+
time_zone?: string | null;
|
|
55
|
+
};
|
|
56
|
+
/** The first whitespace-separated token of a display name, Unicode-safe, at most 40 characters. */
|
|
57
|
+
declare function firstName(fullName: string | null | undefined): string;
|
|
58
|
+
declare function localHour(now: Date, timeZone?: string | null, locale?: string | null): number;
|
|
59
|
+
declare function greetingWord(hour: number): "Good morning" | "Good afternoon" | "Good evening";
|
|
60
|
+
declare function greetingText(profile: GreetingProfile, now?: Date, fallback?: string): string;
|
|
61
|
+
type SessionSummaryResponse = {
|
|
62
|
+
authenticated: boolean;
|
|
63
|
+
full_name?: string;
|
|
64
|
+
locale?: string;
|
|
65
|
+
time_zone?: string;
|
|
66
|
+
};
|
|
67
|
+
declare function ReaderGreeting({ endpoint, className, fallback, as: Tag }: {
|
|
68
|
+
endpoint?: string;
|
|
69
|
+
className?: string;
|
|
70
|
+
fallback?: string;
|
|
71
|
+
as?: "span" | "p" | "div";
|
|
72
|
+
}): React.JSX.Element | null;
|
|
73
|
+
type ReaderSsoClassNames = {
|
|
74
|
+
wrapper?: string;
|
|
75
|
+
button?: string;
|
|
76
|
+
fine?: string;
|
|
77
|
+
error?: string;
|
|
78
|
+
form?: string;
|
|
79
|
+
secondary?: string;
|
|
80
|
+
};
|
|
81
|
+
declare const LEDVERY_BUTTON_LABELS: Record<ReaderSsoProvider, {
|
|
82
|
+
label: string;
|
|
83
|
+
busy: string;
|
|
84
|
+
}>;
|
|
85
|
+
declare function LedveryButton({ provider, subscribe, disabled, returnTo, startPath, label, busyLabel, description, classNames, onStart, }: {
|
|
86
|
+
provider?: ReaderSsoProvider;
|
|
87
|
+
subscribe?: boolean;
|
|
88
|
+
disabled?: boolean;
|
|
89
|
+
returnTo?: string;
|
|
90
|
+
startPath?: string;
|
|
91
|
+
label?: string;
|
|
92
|
+
busyLabel?: string;
|
|
93
|
+
description?: string;
|
|
94
|
+
classNames?: ReaderSsoClassNames;
|
|
95
|
+
onStart?: (provider: ReaderSsoProvider) => void;
|
|
96
|
+
}): React.JSX.Element;
|
|
97
|
+
type LinkResult = {
|
|
98
|
+
user?: {
|
|
99
|
+
id: string;
|
|
100
|
+
email?: string;
|
|
101
|
+
email_verified?: boolean;
|
|
102
|
+
};
|
|
103
|
+
switch?: boolean;
|
|
104
|
+
location?: string;
|
|
105
|
+
};
|
|
106
|
+
/** Password proof for an existing local account (HTTP 409 LINK_REQUIRED from the callback). */
|
|
107
|
+
declare function AccountLinkForm({ challengeId, hint, endpoint, next, subscribe, classNames, onLinked, onCancel, }: {
|
|
108
|
+
challengeId: string;
|
|
109
|
+
hint?: string;
|
|
110
|
+
endpoint?: string;
|
|
111
|
+
next?: string;
|
|
112
|
+
subscribe?: boolean;
|
|
113
|
+
classNames?: ReaderSsoClassNames;
|
|
114
|
+
onLinked?: (result: LinkResult) => void;
|
|
115
|
+
onCancel?: () => void;
|
|
116
|
+
}): React.JSX.Element;
|
|
117
|
+
type SwitchChoice = "continue" | "stay";
|
|
118
|
+
type SwitchResult = {
|
|
119
|
+
choice?: SwitchChoice;
|
|
120
|
+
location?: string;
|
|
121
|
+
};
|
|
122
|
+
/** ADR 3.2: Bob stays signed in until the reader explicitly chooses Alice. */
|
|
123
|
+
declare function AccountSwitchPrompt({ currentName, pendingName, endpoint, classNames, onDone, }: {
|
|
124
|
+
currentName: string;
|
|
125
|
+
pendingName: string;
|
|
126
|
+
endpoint?: string;
|
|
127
|
+
classNames?: ReaderSsoClassNames;
|
|
128
|
+
onDone?: (result: SwitchResult) => void;
|
|
129
|
+
}): React.JSX.Element;
|
|
130
|
+
|
|
131
|
+
export { AccountLinkForm, AccountSwitchPrompt, type GreetingProfile, LEDVERY_BUTTON_LABELS, LedveryButton, type LinkResult, READER_SSO_ATTEMPT_WINDOW_MS, READER_SSO_BOT_PATTERN, READER_SSO_PRIVATE_PATHS, ReaderGreeting, ReaderSsoBoot, type ReaderSsoClassNames, type ReaderSsoProvider, type SessionSummaryResponse, type SilentSignInDecision, type SilentSignInEnvironment, type SwitchChoice, type SwitchResult, firstName, greetingText, greetingWord, isFormField, localHour, silentSignInDecision, silentSignInUrl };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
type ReaderSsoProvider = "google" | "email";
|
|
4
|
+
declare const READER_SSO_BOT_PATTERN: RegExp;
|
|
5
|
+
/** Pages where a background navigation would be unwelcome or pointless. */
|
|
6
|
+
declare const READER_SSO_PRIVATE_PATHS: RegExp[];
|
|
7
|
+
/** One attempt per browser session window; the server's negative cache is the real guard. */
|
|
8
|
+
declare const READER_SSO_ATTEMPT_WINDOW_MS: number;
|
|
9
|
+
type SilentSignInEnvironment = {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
hasSession: boolean;
|
|
12
|
+
suppressed: boolean;
|
|
13
|
+
webdriver: boolean;
|
|
14
|
+
userAgent: string;
|
|
15
|
+
pathname: string;
|
|
16
|
+
search: string;
|
|
17
|
+
fieldFocused: boolean;
|
|
18
|
+
prerendering: boolean;
|
|
19
|
+
visibilityState: string;
|
|
20
|
+
framed: boolean;
|
|
21
|
+
lastAttemptAt: number | null;
|
|
22
|
+
now: number;
|
|
23
|
+
privatePaths?: RegExp[];
|
|
24
|
+
};
|
|
25
|
+
type SilentSignInDecision = {
|
|
26
|
+
attempt: true;
|
|
27
|
+
} | {
|
|
28
|
+
attempt: false;
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
declare function silentSignInDecision(env: SilentSignInEnvironment): SilentSignInDecision;
|
|
32
|
+
declare function silentSignInUrl(startPath: string, pathname: string, search: string): string;
|
|
33
|
+
declare function isFormField(element: Element | null): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Renders nothing. Once per page load, after `load` and an idle period, performs a
|
|
36
|
+
* top-level navigation to the silent sign-in start route when nothing argues
|
|
37
|
+
* against it. The server sets the negative-cache cookie on every silent negative,
|
|
38
|
+
* so this can never loop.
|
|
39
|
+
*/
|
|
40
|
+
declare function ReaderSsoBoot({ enabled, hasSession: hasSessionCookie, suppressed, startPath, sessionPath, privatePaths, idleDelayMs }: {
|
|
41
|
+
enabled?: boolean;
|
|
42
|
+
/** A reader session cookie is present (it may have been revoked elsewhere). */
|
|
43
|
+
hasSession: boolean;
|
|
44
|
+
suppressed: boolean;
|
|
45
|
+
startPath?: string;
|
|
46
|
+
/** Private, no-store endpoint that validates the cookie and clears it when stale. */
|
|
47
|
+
sessionPath?: string;
|
|
48
|
+
privatePaths?: RegExp[];
|
|
49
|
+
idleDelayMs?: number;
|
|
50
|
+
}): null;
|
|
51
|
+
type GreetingProfile = {
|
|
52
|
+
full_name?: string | null;
|
|
53
|
+
locale?: string | null;
|
|
54
|
+
time_zone?: string | null;
|
|
55
|
+
};
|
|
56
|
+
/** The first whitespace-separated token of a display name, Unicode-safe, at most 40 characters. */
|
|
57
|
+
declare function firstName(fullName: string | null | undefined): string;
|
|
58
|
+
declare function localHour(now: Date, timeZone?: string | null, locale?: string | null): number;
|
|
59
|
+
declare function greetingWord(hour: number): "Good morning" | "Good afternoon" | "Good evening";
|
|
60
|
+
declare function greetingText(profile: GreetingProfile, now?: Date, fallback?: string): string;
|
|
61
|
+
type SessionSummaryResponse = {
|
|
62
|
+
authenticated: boolean;
|
|
63
|
+
full_name?: string;
|
|
64
|
+
locale?: string;
|
|
65
|
+
time_zone?: string;
|
|
66
|
+
};
|
|
67
|
+
declare function ReaderGreeting({ endpoint, className, fallback, as: Tag }: {
|
|
68
|
+
endpoint?: string;
|
|
69
|
+
className?: string;
|
|
70
|
+
fallback?: string;
|
|
71
|
+
as?: "span" | "p" | "div";
|
|
72
|
+
}): React.JSX.Element | null;
|
|
73
|
+
type ReaderSsoClassNames = {
|
|
74
|
+
wrapper?: string;
|
|
75
|
+
button?: string;
|
|
76
|
+
fine?: string;
|
|
77
|
+
error?: string;
|
|
78
|
+
form?: string;
|
|
79
|
+
secondary?: string;
|
|
80
|
+
};
|
|
81
|
+
declare const LEDVERY_BUTTON_LABELS: Record<ReaderSsoProvider, {
|
|
82
|
+
label: string;
|
|
83
|
+
busy: string;
|
|
84
|
+
}>;
|
|
85
|
+
declare function LedveryButton({ provider, subscribe, disabled, returnTo, startPath, label, busyLabel, description, classNames, onStart, }: {
|
|
86
|
+
provider?: ReaderSsoProvider;
|
|
87
|
+
subscribe?: boolean;
|
|
88
|
+
disabled?: boolean;
|
|
89
|
+
returnTo?: string;
|
|
90
|
+
startPath?: string;
|
|
91
|
+
label?: string;
|
|
92
|
+
busyLabel?: string;
|
|
93
|
+
description?: string;
|
|
94
|
+
classNames?: ReaderSsoClassNames;
|
|
95
|
+
onStart?: (provider: ReaderSsoProvider) => void;
|
|
96
|
+
}): React.JSX.Element;
|
|
97
|
+
type LinkResult = {
|
|
98
|
+
user?: {
|
|
99
|
+
id: string;
|
|
100
|
+
email?: string;
|
|
101
|
+
email_verified?: boolean;
|
|
102
|
+
};
|
|
103
|
+
switch?: boolean;
|
|
104
|
+
location?: string;
|
|
105
|
+
};
|
|
106
|
+
/** Password proof for an existing local account (HTTP 409 LINK_REQUIRED from the callback). */
|
|
107
|
+
declare function AccountLinkForm({ challengeId, hint, endpoint, next, subscribe, classNames, onLinked, onCancel, }: {
|
|
108
|
+
challengeId: string;
|
|
109
|
+
hint?: string;
|
|
110
|
+
endpoint?: string;
|
|
111
|
+
next?: string;
|
|
112
|
+
subscribe?: boolean;
|
|
113
|
+
classNames?: ReaderSsoClassNames;
|
|
114
|
+
onLinked?: (result: LinkResult) => void;
|
|
115
|
+
onCancel?: () => void;
|
|
116
|
+
}): React.JSX.Element;
|
|
117
|
+
type SwitchChoice = "continue" | "stay";
|
|
118
|
+
type SwitchResult = {
|
|
119
|
+
choice?: SwitchChoice;
|
|
120
|
+
location?: string;
|
|
121
|
+
};
|
|
122
|
+
/** ADR 3.2: Bob stays signed in until the reader explicitly chooses Alice. */
|
|
123
|
+
declare function AccountSwitchPrompt({ currentName, pendingName, endpoint, classNames, onDone, }: {
|
|
124
|
+
currentName: string;
|
|
125
|
+
pendingName: string;
|
|
126
|
+
endpoint?: string;
|
|
127
|
+
classNames?: ReaderSsoClassNames;
|
|
128
|
+
onDone?: (result: SwitchResult) => void;
|
|
129
|
+
}): React.JSX.Element;
|
|
130
|
+
|
|
131
|
+
export { AccountLinkForm, AccountSwitchPrompt, type GreetingProfile, LEDVERY_BUTTON_LABELS, LedveryButton, type LinkResult, READER_SSO_ATTEMPT_WINDOW_MS, READER_SSO_BOT_PATTERN, READER_SSO_PRIVATE_PATHS, ReaderGreeting, ReaderSsoBoot, type ReaderSsoClassNames, type ReaderSsoProvider, type SessionSummaryResponse, type SilentSignInDecision, type SilentSignInEnvironment, type SwitchChoice, type SwitchResult, firstName, greetingText, greetingWord, isFormField, localHour, silentSignInDecision, silentSignInUrl };
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useEffect, useId } from 'react';
|
|
3
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
var READER_SSO_BOT_PATTERN = /bot|crawl|spider|headless|lighthouse|pagespeed|preview|slurp|facebookexternalhit|embedly|python|curl|wget|phantom|puppeteer|playwright|selenium|prerender/i;
|
|
6
|
+
var READER_SSO_PRIVATE_PATHS = [
|
|
7
|
+
/^\/account(\/|$)/,
|
|
8
|
+
/^\/api(\/|$)/,
|
|
9
|
+
/^\/review(\/|$)/,
|
|
10
|
+
/^\/reset-password(\/|$)/,
|
|
11
|
+
/^\/verify-email(\/|$)/,
|
|
12
|
+
/^\/claim(\/|$)/,
|
|
13
|
+
/^\/tip(\/|$)/,
|
|
14
|
+
/^\/news\/unsubscribe(\/|$)/,
|
|
15
|
+
/^\/pets\/account(\/|$)/,
|
|
16
|
+
/^\/newsletter(\/|$)/
|
|
17
|
+
];
|
|
18
|
+
var FLOW_QUERY = ["signin", "signed_out", "switch", "link", "subscription", "code", "state", "error"];
|
|
19
|
+
var READER_SSO_ATTEMPT_WINDOW_MS = 5 * 6e4;
|
|
20
|
+
function silentSignInDecision(env) {
|
|
21
|
+
if (!env.enabled) return { attempt: false, reason: "disabled" };
|
|
22
|
+
if (env.hasSession) return { attempt: false, reason: "session" };
|
|
23
|
+
if (env.suppressed) return { attempt: false, reason: "suppressed" };
|
|
24
|
+
if (env.webdriver) return { attempt: false, reason: "webdriver" };
|
|
25
|
+
if (!env.userAgent || READER_SSO_BOT_PATTERN.test(env.userAgent)) return { attempt: false, reason: "bot" };
|
|
26
|
+
if (env.framed) return { attempt: false, reason: "framed" };
|
|
27
|
+
if (env.prerendering || env.visibilityState !== "visible") return { attempt: false, reason: "hidden" };
|
|
28
|
+
if ((env.privatePaths ?? READER_SSO_PRIVATE_PATHS).some((pattern) => pattern.test(env.pathname))) return { attempt: false, reason: "private" };
|
|
29
|
+
const params = new URLSearchParams(env.search);
|
|
30
|
+
if (FLOW_QUERY.some((key) => params.has(key))) return { attempt: false, reason: "flow" };
|
|
31
|
+
if (env.fieldFocused) return { attempt: false, reason: "typing" };
|
|
32
|
+
if (env.lastAttemptAt !== null && env.now - env.lastAttemptAt < READER_SSO_ATTEMPT_WINDOW_MS) return { attempt: false, reason: "recent" };
|
|
33
|
+
return { attempt: true };
|
|
34
|
+
}
|
|
35
|
+
function silentSignInUrl(startPath, pathname, search) {
|
|
36
|
+
return `${startPath}?${new URLSearchParams({ mode: "silent", next: pathname + search })}`;
|
|
37
|
+
}
|
|
38
|
+
function isFormField(element) {
|
|
39
|
+
if (!element) return false;
|
|
40
|
+
const tag = element.tagName;
|
|
41
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || element.isContentEditable === true;
|
|
42
|
+
}
|
|
43
|
+
var ATTEMPT_KEY = "reader-sso-attempt-v1";
|
|
44
|
+
function readAttempt() {
|
|
45
|
+
try {
|
|
46
|
+
const raw = window.sessionStorage.getItem(ATTEMPT_KEY);
|
|
47
|
+
const value = raw ? Number(raw) : NaN;
|
|
48
|
+
return Number.isFinite(value) ? value : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function writeAttempt(now) {
|
|
54
|
+
try {
|
|
55
|
+
window.sessionStorage.setItem(ATTEMPT_KEY, String(now));
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function browserEnvironment(flags) {
|
|
60
|
+
const doc = document;
|
|
61
|
+
let framed = false;
|
|
62
|
+
try {
|
|
63
|
+
framed = window.top !== window.self;
|
|
64
|
+
} catch {
|
|
65
|
+
framed = true;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...flags,
|
|
69
|
+
webdriver: navigator.webdriver === true,
|
|
70
|
+
userAgent: navigator.userAgent,
|
|
71
|
+
pathname: location.pathname,
|
|
72
|
+
search: location.search,
|
|
73
|
+
fieldFocused: isFormField(document.activeElement),
|
|
74
|
+
prerendering: doc.prerendering === true,
|
|
75
|
+
visibilityState: document.visibilityState,
|
|
76
|
+
framed,
|
|
77
|
+
lastAttemptAt: readAttempt(),
|
|
78
|
+
now: Date.now()
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function ReaderSsoBoot({ enabled = true, hasSession: hasSessionCookie, suppressed, startPath = "/api/news/ledvery-start", sessionPath = "/api/news/session", privatePaths, idleDelayMs = 1500 }) {
|
|
82
|
+
const [staleCookie, setStaleCookie] = useState(false);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!enabled || !hasSessionCookie || staleCookie) return;
|
|
85
|
+
let cancelled = false;
|
|
86
|
+
fetch(sessionPath, { credentials: "same-origin", cache: "no-store" }).then((response) => response.json()).then((body) => {
|
|
87
|
+
if (!cancelled && body?.authenticated !== true) setStaleCookie(true);
|
|
88
|
+
}).catch(() => {
|
|
89
|
+
});
|
|
90
|
+
return () => {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
};
|
|
93
|
+
}, [enabled, hasSessionCookie, staleCookie, sessionPath]);
|
|
94
|
+
const hasSession = hasSessionCookie && !staleCookie;
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (silentSignInDecision(browserEnvironment({ enabled, hasSession, suppressed, privatePaths })).attempt !== true) return;
|
|
97
|
+
let cancelled = false;
|
|
98
|
+
let idle;
|
|
99
|
+
const go = () => {
|
|
100
|
+
if (cancelled) return;
|
|
101
|
+
const env = browserEnvironment({ enabled, hasSession, suppressed, privatePaths });
|
|
102
|
+
if (silentSignInDecision(env).attempt !== true) return;
|
|
103
|
+
writeAttempt(env.now);
|
|
104
|
+
window.location.assign(silentSignInUrl(startPath, env.pathname, env.search));
|
|
105
|
+
};
|
|
106
|
+
const whenIdle = () => {
|
|
107
|
+
const w = window;
|
|
108
|
+
if (typeof w.requestIdleCallback === "function") idle = w.requestIdleCallback(() => {
|
|
109
|
+
idle = window.setTimeout(go, idleDelayMs);
|
|
110
|
+
}, { timeout: 4e3 });
|
|
111
|
+
else idle = window.setTimeout(go, idleDelayMs);
|
|
112
|
+
};
|
|
113
|
+
if (document.readyState === "complete") whenIdle();
|
|
114
|
+
else window.addEventListener("load", whenIdle, { once: true });
|
|
115
|
+
return () => {
|
|
116
|
+
cancelled = true;
|
|
117
|
+
window.removeEventListener("load", whenIdle);
|
|
118
|
+
if (idle !== void 0) window.clearTimeout(idle);
|
|
119
|
+
};
|
|
120
|
+
}, [enabled, hasSession, suppressed, startPath, privatePaths, idleDelayMs]);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
function firstName(fullName) {
|
|
124
|
+
if (typeof fullName !== "string") return "";
|
|
125
|
+
const token = fullName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().split(/\s+/u)[0] ?? "";
|
|
126
|
+
return Array.from(token).slice(0, 40).join("");
|
|
127
|
+
}
|
|
128
|
+
function localHour(now, timeZone, locale) {
|
|
129
|
+
const options = { hour: "numeric", hourCycle: "h23" };
|
|
130
|
+
const attempts = [[locale ?? void 0, timeZone ?? void 0], [void 0, timeZone ?? void 0], [void 0, void 0]];
|
|
131
|
+
for (const [loc, tz] of attempts) {
|
|
132
|
+
try {
|
|
133
|
+
const part = new Intl.DateTimeFormat(loc, { ...options, ...tz ? { timeZone: tz } : {} }).formatToParts(now).find((p) => p.type === "hour");
|
|
134
|
+
const hour = part ? Number(part.value) : NaN;
|
|
135
|
+
if (Number.isInteger(hour) && hour >= 0 && hour < 24) return hour;
|
|
136
|
+
} catch {
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return now.getHours();
|
|
140
|
+
}
|
|
141
|
+
function greetingWord(hour) {
|
|
142
|
+
if (hour >= 5 && hour < 12) return "Good morning";
|
|
143
|
+
if (hour >= 12 && hour < 18) return "Good afternoon";
|
|
144
|
+
return "Good evening";
|
|
145
|
+
}
|
|
146
|
+
function greetingText(profile, now = /* @__PURE__ */ new Date(), fallback = "Welcome back") {
|
|
147
|
+
const name = firstName(profile.full_name);
|
|
148
|
+
if (!name) return fallback;
|
|
149
|
+
return `${greetingWord(localHour(now, profile.time_zone, profile.locale))}, ${name}`;
|
|
150
|
+
}
|
|
151
|
+
function ReaderGreeting({ endpoint = "/api/news/session", className, fallback = "Welcome back", as: Tag = "span" }) {
|
|
152
|
+
const [text, setText] = useState(null);
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
let alive = true;
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
fetch(endpoint, { credentials: "same-origin", cache: "no-store", signal: controller.signal }).then((response) => response.ok ? response.json() : null).then((summary) => {
|
|
157
|
+
if (!alive || !summary || summary.authenticated !== true) return;
|
|
158
|
+
setText(greetingText(summary, /* @__PURE__ */ new Date(), fallback));
|
|
159
|
+
}).catch(() => {
|
|
160
|
+
});
|
|
161
|
+
return () => {
|
|
162
|
+
alive = false;
|
|
163
|
+
controller.abort();
|
|
164
|
+
};
|
|
165
|
+
}, [endpoint, fallback]);
|
|
166
|
+
if (text === null) return null;
|
|
167
|
+
return /* @__PURE__ */ jsx(Tag, { className, "data-reader-greeting": "", children: text });
|
|
168
|
+
}
|
|
169
|
+
async function postJson(endpoint, body, timeoutMs = 3e4) {
|
|
170
|
+
const response = await fetch(endpoint, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { "content-type": "application/json" },
|
|
173
|
+
body: JSON.stringify(body),
|
|
174
|
+
credentials: "same-origin",
|
|
175
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
176
|
+
});
|
|
177
|
+
const data = await response.json().catch(() => ({}));
|
|
178
|
+
if (!response.ok) throw Object.assign(new Error(data.error || "Please try again."), { mfaRequired: data.mfa_required === true });
|
|
179
|
+
return data;
|
|
180
|
+
}
|
|
181
|
+
function GoogleMark() {
|
|
182
|
+
return /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", "aria-hidden": "true", children: [
|
|
183
|
+
/* @__PURE__ */ jsx("path", { d: "M21.805 10.023H12.24v3.955h5.483c-.237 1.273-.958 2.352-2.042 3.073v2.552h3.305c1.936-1.782 3.05-4.41 3.05-7.603 0-.676-.06-1.326-.171-1.977Z", fill: "#4285F4" }),
|
|
184
|
+
/* @__PURE__ */ jsx("path", { d: "M12.24 22c2.745 0 5.047-.909 6.73-2.447l-3.305-2.552c-.918.615-2.094.978-3.425.978-2.636 0-4.87-1.78-5.67-4.173H3.154v2.632A10.16 10.16 0 0 0 12.24 22Z", fill: "#34A853" }),
|
|
185
|
+
/* @__PURE__ */ jsx("path", { d: "M6.57 13.806A6.112 6.112 0 0 1 6.253 12c0-.627.109-1.235.317-1.806V7.562H3.154A10.157 10.157 0 0 0 2 12c0 1.633.39 3.177 1.154 4.438l3.416-2.632Z", fill: "#FBBC04" }),
|
|
186
|
+
/* @__PURE__ */ jsx("path", { d: "M12.24 6.02c1.493 0 2.833.514 3.888 1.523l2.913-2.913C17.281 2.998 14.979 2 12.24 2A10.16 10.16 0 0 0 3.154 7.562l3.416 2.632c.8-2.393 3.034-4.174 5.67-4.174Z", fill: "#EA4335" })
|
|
187
|
+
] });
|
|
188
|
+
}
|
|
189
|
+
function MailMark() {
|
|
190
|
+
return /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", "aria-hidden": "true", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
|
|
191
|
+
/* @__PURE__ */ jsx("rect", { x: "3", y: "5", width: "18", height: "14", rx: "2" }),
|
|
192
|
+
/* @__PURE__ */ jsx("path", { d: "m3 7 9 6 9-6" })
|
|
193
|
+
] });
|
|
194
|
+
}
|
|
195
|
+
var LEDVERY_BUTTON_LABELS = {
|
|
196
|
+
google: { label: "Continue with Google", busy: "Opening Google\u2026" },
|
|
197
|
+
email: { label: "Continue with email", busy: "Opening sign-in\u2026" }
|
|
198
|
+
};
|
|
199
|
+
function LedveryButton({
|
|
200
|
+
provider = "google",
|
|
201
|
+
subscribe = false,
|
|
202
|
+
disabled = false,
|
|
203
|
+
returnTo = "",
|
|
204
|
+
startPath = "/api/news/ledvery-start",
|
|
205
|
+
label,
|
|
206
|
+
busyLabel,
|
|
207
|
+
description,
|
|
208
|
+
classNames = {},
|
|
209
|
+
onStart
|
|
210
|
+
}) {
|
|
211
|
+
const [busy, setBusy] = useState(false);
|
|
212
|
+
const [error, setError] = useState("");
|
|
213
|
+
const descriptionId = useId();
|
|
214
|
+
const labels = LEDVERY_BUTTON_LABELS[provider];
|
|
215
|
+
return /* @__PURE__ */ jsxs("div", { className: classNames.wrapper, "data-reader-signin": provider, children: [
|
|
216
|
+
/* @__PURE__ */ jsxs(
|
|
217
|
+
"button",
|
|
218
|
+
{
|
|
219
|
+
type: "button",
|
|
220
|
+
className: classNames.button,
|
|
221
|
+
disabled: busy || disabled,
|
|
222
|
+
"aria-describedby": description ? descriptionId : void 0,
|
|
223
|
+
onClick: async () => {
|
|
224
|
+
setBusy(true);
|
|
225
|
+
setError("");
|
|
226
|
+
try {
|
|
227
|
+
onStart?.(provider);
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const data = await postJson(startPath, { provider, subscribe, consent: subscribe, returnTo });
|
|
232
|
+
if (!data.url) throw new Error("Sign-in is temporarily unavailable. Please try again.");
|
|
233
|
+
window.location.assign(data.url);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
setError(e instanceof Error ? e.message : "Please try again.");
|
|
236
|
+
setBusy(false);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
children: [
|
|
240
|
+
provider === "google" ? /* @__PURE__ */ jsx(GoogleMark, {}) : /* @__PURE__ */ jsx(MailMark, {}),
|
|
241
|
+
/* @__PURE__ */ jsx("span", { children: busy ? busyLabel ?? labels.busy : label ?? labels.label })
|
|
242
|
+
]
|
|
243
|
+
}
|
|
244
|
+
),
|
|
245
|
+
description && /* @__PURE__ */ jsx("p", { id: descriptionId, className: classNames.fine, children: description }),
|
|
246
|
+
error && /* @__PURE__ */ jsx("p", { role: "alert", className: classNames.error, children: error })
|
|
247
|
+
] });
|
|
248
|
+
}
|
|
249
|
+
function AccountLinkForm({
|
|
250
|
+
challengeId,
|
|
251
|
+
hint = "",
|
|
252
|
+
endpoint = "/api/news/ledvery-link",
|
|
253
|
+
next = "",
|
|
254
|
+
subscribe = false,
|
|
255
|
+
classNames = {},
|
|
256
|
+
onLinked,
|
|
257
|
+
onCancel
|
|
258
|
+
}) {
|
|
259
|
+
const [busy, setBusy] = useState(false);
|
|
260
|
+
const [error, setError] = useState("");
|
|
261
|
+
const [password, setPassword] = useState("");
|
|
262
|
+
const [mfaRequired, setMfaRequired] = useState(false);
|
|
263
|
+
const [code, setCode] = useState("");
|
|
264
|
+
return /* @__PURE__ */ jsxs(
|
|
265
|
+
"form",
|
|
266
|
+
{
|
|
267
|
+
className: classNames.form,
|
|
268
|
+
"data-reader-link": "",
|
|
269
|
+
onSubmit: (event) => {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
setBusy(true);
|
|
272
|
+
setError("");
|
|
273
|
+
postJson(endpoint, { challenge_id: challengeId, password, next, subscribe, ...mfaRequired && code ? { mfa_code: code } : {} }).then((result) => {
|
|
274
|
+
setPassword("");
|
|
275
|
+
setCode("");
|
|
276
|
+
if (onLinked) onLinked(result);
|
|
277
|
+
else window.location.assign(result.location || "/account");
|
|
278
|
+
}).catch((e) => {
|
|
279
|
+
if (e?.mfaRequired) setMfaRequired(true);
|
|
280
|
+
setCode("");
|
|
281
|
+
setError(e instanceof Error ? e.message : "Please try again.");
|
|
282
|
+
setBusy(false);
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
children: [
|
|
286
|
+
/* @__PURE__ */ jsx("h2", { children: "You already have a reader account" }),
|
|
287
|
+
/* @__PURE__ */ jsxs("p", { className: classNames.fine, children: [
|
|
288
|
+
hint ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
289
|
+
"An account for ",
|
|
290
|
+
/* @__PURE__ */ jsx("strong", { children: hint }),
|
|
291
|
+
" already exists. "
|
|
292
|
+
] }) : null,
|
|
293
|
+
"Enter its password once to connect your sign-in. Your preferences and subscriptions stay as they are."
|
|
294
|
+
] }),
|
|
295
|
+
/* @__PURE__ */ jsxs("label", { children: [
|
|
296
|
+
"Reader account password",
|
|
297
|
+
/* @__PURE__ */ jsx("input", { type: "password", autoComplete: "current-password", required: true, minLength: 1, maxLength: 1024, value: password, onChange: (e) => setPassword(e.target.value) })
|
|
298
|
+
] }),
|
|
299
|
+
mfaRequired && /* @__PURE__ */ jsxs("label", { children: [
|
|
300
|
+
"Authentication code",
|
|
301
|
+
/* @__PURE__ */ jsx("input", { type: "text", inputMode: "numeric", autoComplete: "one-time-code", required: true, maxLength: 32, value: code, onChange: (e) => setCode(e.target.value) })
|
|
302
|
+
] }),
|
|
303
|
+
/* @__PURE__ */ jsx("button", { className: classNames.button, disabled: busy || !password || mfaRequired && !code, children: busy ? "Connecting\u2026" : "Connect and sign in" }),
|
|
304
|
+
onCancel && /* @__PURE__ */ jsx("button", { type: "button", className: classNames.secondary, disabled: busy, onClick: onCancel, children: "Start over" }),
|
|
305
|
+
error && /* @__PURE__ */ jsx("p", { role: "alert", className: classNames.error, children: error })
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
function AccountSwitchPrompt({
|
|
311
|
+
currentName,
|
|
312
|
+
pendingName,
|
|
313
|
+
endpoint = "/api/news/switch-account",
|
|
314
|
+
classNames = {},
|
|
315
|
+
onDone
|
|
316
|
+
}) {
|
|
317
|
+
const [busy, setBusy] = useState(null);
|
|
318
|
+
const [error, setError] = useState("");
|
|
319
|
+
const current = firstName(currentName) || "your current account";
|
|
320
|
+
const pending = firstName(pendingName) || "the new account";
|
|
321
|
+
const choose = (choice) => {
|
|
322
|
+
setBusy(choice);
|
|
323
|
+
setError("");
|
|
324
|
+
postJson(endpoint, { choice }).then((result) => {
|
|
325
|
+
if (onDone) onDone({ ...result, choice });
|
|
326
|
+
else window.location.assign(result.location || "/account");
|
|
327
|
+
}).catch((e) => {
|
|
328
|
+
setError(e instanceof Error ? e.message : "Please try again.");
|
|
329
|
+
setBusy(null);
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
return /* @__PURE__ */ jsxs("div", { className: classNames.wrapper, "data-reader-switch": "", role: "group", "aria-label": "Choose an account", children: [
|
|
333
|
+
/* @__PURE__ */ jsx("h2", { children: "Which account would you like to use?" }),
|
|
334
|
+
/* @__PURE__ */ jsxs("p", { className: classNames.fine, children: [
|
|
335
|
+
"You are signed in as ",
|
|
336
|
+
current,
|
|
337
|
+
". A different account, ",
|
|
338
|
+
pending,
|
|
339
|
+
", just signed in through Ledvery."
|
|
340
|
+
] }),
|
|
341
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: classNames.button, disabled: busy !== null, onClick: () => choose("continue"), children: busy === "continue" ? "Switching\u2026" : `Continue as ${pending}` }),
|
|
342
|
+
/* @__PURE__ */ jsx("button", { type: "button", className: classNames.secondary, disabled: busy !== null, onClick: () => choose("stay"), children: busy === "stay" ? "One moment\u2026" : `Stay as ${current}` }),
|
|
343
|
+
error && /* @__PURE__ */ jsx("p", { role: "alert", className: classNames.error, children: error })
|
|
344
|
+
] });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export { AccountLinkForm, AccountSwitchPrompt, LEDVERY_BUTTON_LABELS, LedveryButton, READER_SSO_ATTEMPT_WINDOW_MS, READER_SSO_BOT_PATTERN, READER_SSO_PRIVATE_PATHS, ReaderGreeting, ReaderSsoBoot, firstName, greetingText, greetingWord, isFormField, localHour, silentSignInDecision, silentSignInUrl };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader sign-in telemetry registry (ADR-2026-10-19 section 8). Isomorphic on
|
|
3
|
+
* purpose: a site's analytics schema is shared with its browser bundle, so this
|
|
4
|
+
* fragment must not import anything server-only.
|
|
5
|
+
*/
|
|
6
|
+
type ReaderSsoEventName = "reader_signin_started" | "reader_signin_completed" | "reader_signin_failed" | "reader_sso_check" | "reader_signed_out" | "reader_account_linked";
|
|
7
|
+
type ReaderSsoEventDefinition = {
|
|
8
|
+
category: "navigation" | "engagement" | "content" | "conversion" | "system";
|
|
9
|
+
source: "server";
|
|
10
|
+
};
|
|
11
|
+
/** Merge into the site's EVENTS registry: `{ ...EVENTS, ...READER_SSO_EVENTS }`. Server-recorded only. */
|
|
12
|
+
declare const READER_SSO_EVENTS: Record<ReaderSsoEventName, ReaderSsoEventDefinition>;
|
|
13
|
+
/** Property keys the site's analytics sanitizer must allow for these events (bounded short tokens). */
|
|
14
|
+
declare const READER_SSO_PROPERTY_KEYS: readonly ["method", "mode", "is_new", "reason", "outcome", "transaction"];
|
|
15
|
+
|
|
16
|
+
export { READER_SSO_EVENTS as R, READER_SSO_PROPERTY_KEYS as a, type ReaderSsoEventDefinition as b, type ReaderSsoEventName as c };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader sign-in telemetry registry (ADR-2026-10-19 section 8). Isomorphic on
|
|
3
|
+
* purpose: a site's analytics schema is shared with its browser bundle, so this
|
|
4
|
+
* fragment must not import anything server-only.
|
|
5
|
+
*/
|
|
6
|
+
type ReaderSsoEventName = "reader_signin_started" | "reader_signin_completed" | "reader_signin_failed" | "reader_sso_check" | "reader_signed_out" | "reader_account_linked";
|
|
7
|
+
type ReaderSsoEventDefinition = {
|
|
8
|
+
category: "navigation" | "engagement" | "content" | "conversion" | "system";
|
|
9
|
+
source: "server";
|
|
10
|
+
};
|
|
11
|
+
/** Merge into the site's EVENTS registry: `{ ...EVENTS, ...READER_SSO_EVENTS }`. Server-recorded only. */
|
|
12
|
+
declare const READER_SSO_EVENTS: Record<ReaderSsoEventName, ReaderSsoEventDefinition>;
|
|
13
|
+
/** Property keys the site's analytics sanitizer must allow for these events (bounded short tokens). */
|
|
14
|
+
declare const READER_SSO_PROPERTY_KEYS: readonly ["method", "mode", "is_new", "reason", "outcome", "transaction"];
|
|
15
|
+
|
|
16
|
+
export { READER_SSO_EVENTS as R, READER_SSO_PROPERTY_KEYS as a, type ReaderSsoEventDefinition as b, type ReaderSsoEventName as c };
|