@owlmeans/client-auth 0.1.18-rc.44 → 0.1.18-rc.45
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/client-auth/SKILL.md +10 -7
- package/build/components/dispatcher/component.d.ts.map +1 -1
- package/build/components/dispatcher/component.js +19 -12
- package/build/components/dispatcher/component.js.map +1 -1
- package/build/login/i18n/be.json +6 -2
- package/build/login/i18n/de.json +6 -2
- package/build/login/i18n/en.json +6 -2
- package/build/login/i18n/es.json +6 -2
- package/build/login/i18n/fr.json +6 -2
- package/build/login/i18n/pl.json +6 -2
- package/build/login/i18n/ru.json +6 -2
- package/build/login/i18n/uk.json +6 -2
- package/build/login/index.d.ts +2 -0
- package/build/login/index.d.ts.map +1 -1
- package/build/login/index.js +2 -0
- package/build/login/index.js.map +1 -1
- package/build/login/land.d.ts +44 -0
- package/build/login/land.d.ts.map +1 -0
- package/build/login/land.js +158 -0
- package/build/login/land.js.map +1 -0
- package/build/login/screen.d.ts.map +1 -1
- package/build/login/screen.js +27 -2
- package/build/login/screen.js.map +1 -1
- package/build/login/service.d.ts.map +1 -1
- package/build/login/service.js +20 -0
- package/build/login/service.js.map +1 -1
- package/build/login/terms-config.d.ts +59 -0
- package/build/login/terms-config.d.ts.map +1 -0
- package/build/login/terms-config.js +2 -0
- package/build/login/terms-config.js.map +1 -0
- package/build/login/terms.d.ts +38 -0
- package/build/login/terms.d.ts.map +1 -1
- package/build/login/terms.js +0 -0
- package/build/login/terms.js.map +1 -1
- package/build/login/types.d.ts +77 -0
- package/build/login/types.d.ts.map +1 -1
- package/package.json +22 -22
- package/src/components/dispatcher/component.tsx +25 -12
- package/src/login/i18n/be.json +6 -2
- package/src/login/i18n/de.json +6 -2
- package/src/login/i18n/en.json +6 -2
- package/src/login/i18n/es.json +6 -2
- package/src/login/i18n/fr.json +6 -2
- package/src/login/i18n/pl.json +6 -2
- package/src/login/i18n/ru.json +6 -2
- package/src/login/i18n/uk.json +6 -2
- package/src/login/index.ts +2 -0
- package/src/login/land.ts +186 -0
- package/src/login/screen.tsx +58 -14
- package/src/login/service.ts +26 -2
- package/src/login/terms-config.ts +49 -0
- package/src/login/terms.ts +0 -0
- package/src/login/types.ts +82 -0
- package/src/manager/README.md +1 -1
- package/tests/i18n.spec.ts +68 -0
- package/tests/land.spec.ts +241 -0
- package/tests/terms.spec.ts +202 -0
package/src/login/i18n/en.json
CHANGED
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
"fallback": "Continue with {{method}}"
|
|
17
17
|
},
|
|
18
18
|
"terms": {
|
|
19
|
-
"
|
|
19
|
+
"accept": "I have read and agree to the {{documents}}.",
|
|
20
20
|
"terms": "Terms & Conditions",
|
|
21
21
|
"privacy": "Privacy Policy",
|
|
22
22
|
"cookies": "Cookie Policy",
|
|
23
|
-
"
|
|
23
|
+
"billing": "Billing Terms",
|
|
24
|
+
"product": "{{product}} Product Terms",
|
|
25
|
+
"notice": "How we handle your personal data: {{notices}}.",
|
|
26
|
+
"revised": "Last updated: {{date}}",
|
|
27
|
+
"required": "Please confirm the {{documents}} to continue."
|
|
24
28
|
},
|
|
25
29
|
"consent": {
|
|
26
30
|
"required": "Signing in stores a session cookie. Accept essential cookies to continue."
|
package/src/login/i18n/es.json
CHANGED
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
"fallback": "Continuar con {{method}}"
|
|
17
17
|
},
|
|
18
18
|
"terms": {
|
|
19
|
-
"
|
|
19
|
+
"accept": "He leído y acepto {{documents}}.",
|
|
20
20
|
"terms": "Términos y Condiciones",
|
|
21
21
|
"privacy": "Política de Privacidad",
|
|
22
22
|
"cookies": "Política de Cookies",
|
|
23
|
-
"
|
|
23
|
+
"billing": "Condiciones de Facturación",
|
|
24
|
+
"product": "Condiciones del Producto {{product}}",
|
|
25
|
+
"notice": "Cómo tratamos tus datos personales: {{notices}}.",
|
|
26
|
+
"revised": "Última actualización: {{date}}",
|
|
27
|
+
"required": "Confirma {{documents}} para continuar."
|
|
24
28
|
},
|
|
25
29
|
"consent": {
|
|
26
30
|
"required": "Iniciar sesión guarda una cookie de sesión. Acepta las cookies esenciales para continuar."
|
package/src/login/i18n/fr.json
CHANGED
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
"fallback": "Continuer avec {{method}}"
|
|
17
17
|
},
|
|
18
18
|
"terms": {
|
|
19
|
-
"
|
|
19
|
+
"accept": "J’ai lu et j’accepte {{documents}}.",
|
|
20
20
|
"terms": "conditions générales",
|
|
21
21
|
"privacy": "politique de confidentialité",
|
|
22
22
|
"cookies": "Politique relative aux cookies",
|
|
23
|
-
"
|
|
23
|
+
"billing": "Conditions de facturation",
|
|
24
|
+
"product": "Conditions du produit {{product}}",
|
|
25
|
+
"notice": "Comment nous traitons vos données personnelles : {{notices}}.",
|
|
26
|
+
"revised": "Dernière mise à jour : {{date}}",
|
|
27
|
+
"required": "Confirmez {{documents}} pour continuer."
|
|
24
28
|
},
|
|
25
29
|
"consent": {
|
|
26
30
|
"required": "La connexion enregistre un cookie de session. Acceptez les cookies essentiels pour continuer."
|
package/src/login/i18n/pl.json
CHANGED
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
"fallback": "Kontynuuj z {{method}}"
|
|
17
17
|
},
|
|
18
18
|
"terms": {
|
|
19
|
-
"
|
|
19
|
+
"accept": "Przeczytałem(-am) i akceptuję {{documents}}.",
|
|
20
20
|
"terms": "Regulamin",
|
|
21
21
|
"privacy": "Politykę prywatności",
|
|
22
22
|
"cookies": "Politykę plików cookie",
|
|
23
|
-
"
|
|
23
|
+
"billing": "Warunki rozliczeń",
|
|
24
|
+
"product": "Warunki produktu {{product}}",
|
|
25
|
+
"notice": "Jak przetwarzamy Twoje dane osobowe: {{notices}}.",
|
|
26
|
+
"revised": "Ostatnia aktualizacja: {{date}}",
|
|
27
|
+
"required": "Aby kontynuować, potwierdź {{documents}}."
|
|
24
28
|
},
|
|
25
29
|
"consent": {
|
|
26
30
|
"required": "Logowanie zapisuje plik cookie sesji. Zaakceptuj niezbędne pliki cookie, aby kontynuować."
|
package/src/login/i18n/ru.json
CHANGED
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
"fallback": "Продолжить с {{method}}"
|
|
17
17
|
},
|
|
18
18
|
"terms": {
|
|
19
|
-
"
|
|
19
|
+
"accept": "Я прочитал(а) и принимаю {{documents}}.",
|
|
20
20
|
"terms": "Условия использования",
|
|
21
21
|
"privacy": "Политику конфиденциальности",
|
|
22
22
|
"cookies": "Политику файлов cookie",
|
|
23
|
-
"
|
|
23
|
+
"billing": "Условия оплаты",
|
|
24
|
+
"product": "Условия использования продукта {{product}}",
|
|
25
|
+
"notice": "Как мы обрабатываем ваши персональные данные: {{notices}}.",
|
|
26
|
+
"revised": "Последнее обновление: {{date}}",
|
|
27
|
+
"required": "Чтобы продолжить, подтвердите {{documents}}."
|
|
24
28
|
},
|
|
25
29
|
"consent": {
|
|
26
30
|
"required": "Вход сохраняет сессионный файл cookie. Примите необходимые файлы cookie, чтобы продолжить."
|
package/src/login/i18n/uk.json
CHANGED
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
"fallback": "Продовжити з {{method}}"
|
|
17
17
|
},
|
|
18
18
|
"terms": {
|
|
19
|
-
"
|
|
19
|
+
"accept": "Я прочитав(-ла) і приймаю {{documents}}.",
|
|
20
20
|
"terms": "Умови користування",
|
|
21
21
|
"privacy": "Політику конфіденційності",
|
|
22
22
|
"cookies": "Політику файлів cookie",
|
|
23
|
-
"
|
|
23
|
+
"billing": "Умови оплати",
|
|
24
|
+
"product": "Умови продукту {{product}}",
|
|
25
|
+
"notice": "Як ми обробляємо ваші персональні дані: {{notices}}.",
|
|
26
|
+
"revised": "Востаннє оновлено: {{date}}",
|
|
27
|
+
"required": "Щоб продовжити, підтвердьте {{documents}}."
|
|
24
28
|
},
|
|
25
29
|
"consent": {
|
|
26
30
|
"required": "Вхід зберігає сеансовий файл cookie. Прийміть необхідні файли cookie, щоб продовжити."
|
package/src/login/index.ts
CHANGED
|
@@ -7,9 +7,11 @@ export * from './adopt.js'
|
|
|
7
7
|
export * from './methods.js'
|
|
8
8
|
export * from './flow.js'
|
|
9
9
|
export * from './resume.js'
|
|
10
|
+
export * from './terms-config.js'
|
|
10
11
|
export * from './terms.js'
|
|
11
12
|
export * from './credit.js'
|
|
12
13
|
export * from './surrogate.js'
|
|
13
14
|
export * from './screen.js'
|
|
14
15
|
export * from './service.js'
|
|
15
16
|
export * from './hook.js'
|
|
17
|
+
export * from './land.js'
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import type { AuthService } from '@owlmeans/auth-common'
|
|
3
|
+
import { DISPATCHER } from '@owlmeans/auth'
|
|
4
|
+
import { HOME } from '@owlmeans/context'
|
|
5
|
+
import { useContext, useNavigate } from '@owlmeans/client'
|
|
6
|
+
import type { ClientContext } from '@owlmeans/client'
|
|
7
|
+
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
8
|
+
import { resumeSuspendedFlow } from '@owlmeans/client-flow'
|
|
9
|
+
import { DEFAULT_ALIAS as AUTH_ALIAS } from '../consts.js'
|
|
10
|
+
import { ensureLoginService } from './service.js'
|
|
11
|
+
import type { LandOptions, LoginContext, LoginLanding, LoginService } from './types.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bound on a single step's `pending` check, or a landing hook's `landed` call.
|
|
15
|
+
*
|
|
16
|
+
* A step/hook that hangs must never hang sign-in itself — both `continueLogin` and
|
|
17
|
+
* `landAfterLogin` treat a timeout exactly like a rejection: fail OPEN, never block.
|
|
18
|
+
*/
|
|
19
|
+
export const LOGIN_STEP_TIMEOUT = 5_000
|
|
20
|
+
|
|
21
|
+
/** Where a browser records the last token a landing hook has already run for. */
|
|
22
|
+
export const LOGIN_LANDED_STORAGE = '_owlmeans-login-landed'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Race a promise against a bound, resolving `undefined` (never rejecting) on timeout.
|
|
26
|
+
*
|
|
27
|
+
* A synchronous throw from the wrapped call is turned into a rejection by the caller before this
|
|
28
|
+
* ever sees it, so the only two outcomes here are "settled in time" and "timed out".
|
|
29
|
+
*/
|
|
30
|
+
const withTimeout = async <T>(promise: Promise<T>, ms: number): Promise<T | undefined> => {
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
32
|
+
try {
|
|
33
|
+
return await Promise.race<T | undefined>([
|
|
34
|
+
promise,
|
|
35
|
+
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), ms) }),
|
|
36
|
+
])
|
|
37
|
+
} finally {
|
|
38
|
+
if (timer != null) {
|
|
39
|
+
clearTimeout(timer)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run every registered landing hook, once per distinct token.
|
|
46
|
+
*
|
|
47
|
+
* The stored token is overwritten BEFORE the hooks run, not after: a hook that throws or hangs
|
|
48
|
+
* must never make the same token re-run the whole set on the next call, since surviving exactly
|
|
49
|
+
* that is what the bound-and-swallow contract is for. The raw token string is the marker rather
|
|
50
|
+
* than a digest of it — it never leaves the browser, and this package already keeps the terms
|
|
51
|
+
* acceptance marker the same way (`LOGIN_TERMS_STORAGE`).
|
|
52
|
+
*/
|
|
53
|
+
const runLandingHooks = async (
|
|
54
|
+
ctx: LoginContext, login: LoginService, timeout: number
|
|
55
|
+
): Promise<void> => {
|
|
56
|
+
const auth = ctx.service<AuthService>(AUTH_ALIAS)
|
|
57
|
+
const token = await auth.authenticated()
|
|
58
|
+
if (token == null) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let last: string | null = null
|
|
63
|
+
try {
|
|
64
|
+
last = window.localStorage.getItem(LOGIN_LANDED_STORAGE)
|
|
65
|
+
} catch {
|
|
66
|
+
// Storage can be unavailable (private modes, blocked cookies). Treated as "never landed",
|
|
67
|
+
// which just re-runs the hooks — the safe direction, same as the terms-acceptance marker.
|
|
68
|
+
}
|
|
69
|
+
if (last === token) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
window.localStorage.setItem(LOGIN_LANDED_STORAGE, token)
|
|
75
|
+
} catch { /* nothing to remember if storage was never available */ }
|
|
76
|
+
|
|
77
|
+
for (const hook of login.landingHooks()) {
|
|
78
|
+
await withTimeout(Promise.resolve().then(() => hook.landed(ctx)), timeout).catch(() => undefined)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Where a finished sign-in should go: the first pending step, else a suspended flow, else the
|
|
84
|
+
* fallback (ordinarily `HOME`).
|
|
85
|
+
*
|
|
86
|
+
* Re-checks `env().surrogate` even though {@link landAfterLogin} already did — this function is
|
|
87
|
+
* also called directly (e.g. from {@link useContinueLogin}, after the surrogate window has
|
|
88
|
+
* already closed), and the check is cheap insurance either way: **no step and no landing hook
|
|
89
|
+
* ever runs inside the surrogate popup.** It has nothing of its own to collect and nowhere to
|
|
90
|
+
* navigate — the popup's own `DispatcherHOC` has no `navigate() → HOME` at all.
|
|
91
|
+
*/
|
|
92
|
+
export const continueLogin = async (ctx: LoginContext, opts?: LandOptions): Promise<LoginLanding> => {
|
|
93
|
+
const login = ensureLoginService(ctx)
|
|
94
|
+
if (login.env().surrogate) {
|
|
95
|
+
return { alias: DISPATCHER }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const timeout = opts?.stepTimeout ?? LOGIN_STEP_TIMEOUT
|
|
99
|
+
const auth = ctx.service<AuthService>(AUTH_ALIAS)
|
|
100
|
+
const token = await auth.authenticated()
|
|
101
|
+
|
|
102
|
+
if (token != null) {
|
|
103
|
+
const steps = login.steps()
|
|
104
|
+
let start = 0
|
|
105
|
+
if (opts?.after != null) {
|
|
106
|
+
const index = steps.findIndex(step => step.alias === opts.after)
|
|
107
|
+
if (index >= 0) {
|
|
108
|
+
start = index + 1
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (let i = start; i < steps.length; i++) {
|
|
113
|
+
const step = steps[i]
|
|
114
|
+
// Registration proves only that some module reached the bundle — the step's own screen may
|
|
115
|
+
// not be bound in this tree (an older target, a partial import). Skipped, never thrown.
|
|
116
|
+
if (!ctx.hasEntrypoint(step.entrypoint)) {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A step that throws or hangs must never block sign-in: fail OPEN, i.e. "not pending".
|
|
121
|
+
const pending = await withTimeout(
|
|
122
|
+
Promise.resolve().then(() => step.pending(ctx)), timeout
|
|
123
|
+
).catch(() => false)
|
|
124
|
+
|
|
125
|
+
if (pending === true) {
|
|
126
|
+
return { alias: step.entrypoint, query: await step.query?.(ctx), step: step.alias }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (opts?.resume !== false) {
|
|
132
|
+
const landing = await resumeSuspendedFlow(ctx as unknown as ClientContext)
|
|
133
|
+
if (landing != null) {
|
|
134
|
+
return { alias: landing.entrypoint, query: landing.query }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return opts?.fallback ?? { alias: HOME }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The whole post-sign-in landing decision: run any due landing hooks for a freshly seen token,
|
|
143
|
+
* then delegate to {@link continueLogin} for where the flow actually goes.
|
|
144
|
+
*
|
|
145
|
+
* This is what a plugin that just adopted a token calls in place of the old inline
|
|
146
|
+
* "`resumeSuspendedFlow` else `HOME`" — `DispatcherHOC`, the supervisor plugin and both Google
|
|
147
|
+
* plugins all replace their own copy of that logic with this one.
|
|
148
|
+
*/
|
|
149
|
+
export const landAfterLogin = async (ctx: LoginContext, opts?: LandOptions): Promise<LoginLanding> => {
|
|
150
|
+
const login = ensureLoginService(ctx)
|
|
151
|
+
// Never run a step or a landing hook while THIS document is the surrogate popup — see the note
|
|
152
|
+
// on `continueLogin`.
|
|
153
|
+
if (login.env().surrogate) {
|
|
154
|
+
return { alias: DISPATCHER }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await runLandingHooks(ctx, login, opts?.stepTimeout ?? LOGIN_STEP_TIMEOUT)
|
|
158
|
+
|
|
159
|
+
return continueLogin(ctx, opts)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The absolute URL a landing resolves to — the same shape the supervisor and Google plugins
|
|
164
|
+
* already build their own `window.location.href` from.
|
|
165
|
+
*/
|
|
166
|
+
export const landingUrl = async (ctx: LoginContext, landing: LoginLanding): Promise<string> =>
|
|
167
|
+
await ctx.entrypoint<ClientEntrypoint<string>>(landing.alias).url(
|
|
168
|
+
{ params: landing.params, query: landing.query }, { absolute: true }
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* For a component that runs its own post-login step (the marketing-consent screen, for one) to
|
|
173
|
+
* call once it is satisfied — moves the flow on to the next step, or the ordinary landing.
|
|
174
|
+
*
|
|
175
|
+
* Not async at the call site by accident of its own — it is a callback a component holds and
|
|
176
|
+
* fires later, so nothing here needs the synchronous-gesture discipline `useLogin`/`useLogout` do.
|
|
177
|
+
*/
|
|
178
|
+
export const useContinueLogin = (): ((opts?: LandOptions) => Promise<void>) => {
|
|
179
|
+
const context = useContext() as unknown as ClientContext
|
|
180
|
+
const nav = useNavigate()
|
|
181
|
+
|
|
182
|
+
return useCallback(async (opts?: LandOptions) => {
|
|
183
|
+
const landing = await continueLogin(context, opts)
|
|
184
|
+
await nav.go(landing.alias, { params: landing.params, query: landing.query })
|
|
185
|
+
}, [context, nav])
|
|
186
|
+
}
|
package/src/login/screen.tsx
CHANGED
|
@@ -1,13 +1,46 @@
|
|
|
1
1
|
import { useCallback, useState } from 'react'
|
|
2
|
-
import type { CSSProperties, FC } from 'react'
|
|
2
|
+
import type { CSSProperties, FC, ReactNode } from 'react'
|
|
3
3
|
import { useContext } from '@owlmeans/client'
|
|
4
4
|
import type { CommonConfig } from '@owlmeans/config'
|
|
5
5
|
import type { LoginContext, LoginMethod, LoginScreenProps, LoginService } from './types.js'
|
|
6
6
|
import { LOGIN_SERVICE } from './consts.js'
|
|
7
7
|
import { primaryLoginMethod } from './methods.js'
|
|
8
|
-
import { acceptTerms, resolveTerms, termsAccepted } from './terms.js'
|
|
8
|
+
import { acceptTerms, resolveTerms, termsAccepted, termsSentence } from './terms.js'
|
|
9
|
+
import type { ResolvedTermsDocument, TermsSentencePart } from './terms.js'
|
|
9
10
|
import { resolveCredit } from './credit.js'
|
|
10
11
|
|
|
12
|
+
/** The same English fallbacks `@owlmeans/web-panel`'s `LoginTerms` uses, kept in sync by hand. */
|
|
13
|
+
const DEFAULT_LABEL: Record<string, string> = {
|
|
14
|
+
terms: 'Terms & Conditions',
|
|
15
|
+
privacy: 'Privacy Policy',
|
|
16
|
+
cookies: 'Cookie Policy',
|
|
17
|
+
billing: 'Billing Terms',
|
|
18
|
+
product: '{{product}} Product Terms',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resolveLabelFor = (
|
|
22
|
+
translate: (key: string, defaultValue: string) => string, locale: string | undefined
|
|
23
|
+
) => (doc: ResolvedTermsDocument): string => {
|
|
24
|
+
const fromMap = doc.labelMap != null
|
|
25
|
+
? (locale != null ? doc.labelMap[locale] : undefined) ?? Object.values(doc.labelMap)[0]
|
|
26
|
+
: undefined
|
|
27
|
+
let label = doc.label ?? fromMap
|
|
28
|
+
?? (doc.i18nKey != null ? translate(doc.i18nKey, DEFAULT_LABEL[doc.key] ?? doc.key) : doc.key)
|
|
29
|
+
|
|
30
|
+
if (doc.params != null) {
|
|
31
|
+
for (const [key, value] of Object.entries(doc.params)) {
|
|
32
|
+
label = label.split(`{{${key}}}`).join(value)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return label
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const renderParts = (parts: TermsSentencePart[]): ReactNode =>
|
|
40
|
+
parts.map((part, index) => part.href != null
|
|
41
|
+
? <a key={index} href={part.href} target="_blank" rel="noreferrer noopener">{part.text}</a>
|
|
42
|
+
: <span key={index}>{part.text}</span>)
|
|
43
|
+
|
|
11
44
|
/**
|
|
12
45
|
* The page the card sits in.
|
|
13
46
|
*
|
|
@@ -56,6 +89,7 @@ export const FallbackLoginScreen: FC<LoginScreenProps> = props => {
|
|
|
56
89
|
const env = login.env()
|
|
57
90
|
const resolved = resolveTerms(props.terms ?? cfg?.terms)
|
|
58
91
|
const credit = resolveCredit(cfg?.credit, brand, context.cfg.service)
|
|
92
|
+
const resolveLabel = resolveLabelFor(t, props.locale)
|
|
59
93
|
|
|
60
94
|
const [accepted, setAccepted] = useState(() => termsAccepted(resolved))
|
|
61
95
|
const [attempted, setAttempted] = useState(false)
|
|
@@ -105,26 +139,36 @@ export const FallbackLoginScreen: FC<LoginScreenProps> = props => {
|
|
|
105
139
|
</button>)}
|
|
106
140
|
|
|
107
141
|
{resolved != null && <p style={{ marginTop: '1rem', fontSize: '.875rem' }}>
|
|
142
|
+
{/* The ONLY place `data-login-terms` appears — never duplicated onto a second control. */}
|
|
108
143
|
<label>
|
|
109
144
|
<input
|
|
110
145
|
type="checkbox" checked={accepted} data-login-terms
|
|
111
146
|
onChange={event => onAccept(event.target.checked)}
|
|
112
147
|
/>{' '}
|
|
113
|
-
{
|
|
148
|
+
{renderParts(termsSentence(
|
|
149
|
+
t('login.terms.accept', 'I have read and agree to the {{documents}}.'),
|
|
150
|
+
resolved, props.locale, resolveLabel
|
|
151
|
+
))}
|
|
114
152
|
</label>
|
|
115
|
-
{' '}
|
|
116
|
-
<a href={resolved.terms} target="_blank" rel="noreferrer noopener">
|
|
117
|
-
{t('login.terms.terms', 'Terms & Conditions')}
|
|
118
|
-
</a>
|
|
119
|
-
{' · '}
|
|
120
|
-
<a href={resolved.privacy} target="_blank" rel="noreferrer noopener">
|
|
121
|
-
{t('login.terms.privacy', 'Privacy Policy')}
|
|
122
|
-
</a>
|
|
123
153
|
</p>}
|
|
124
154
|
|
|
125
|
-
{
|
|
126
|
-
{t('login.terms.
|
|
127
|
-
|
|
155
|
+
{resolved?.revisedAt != null && <p data-login-revised style={{ fontSize: '.75rem', opacity: .7 }}>
|
|
156
|
+
{t('login.terms.revised', 'Last updated: {{date}}').split('{{date}}').join(resolved.revisedAt)}
|
|
157
|
+
</p>}
|
|
158
|
+
|
|
159
|
+
{/* Outside the checkbox's label: a privacy disclosure is not something it consents to. */}
|
|
160
|
+
{resolved != null && <p data-login-privacy style={{ fontSize: '.875rem' }}>
|
|
161
|
+
{renderParts(termsSentence(
|
|
162
|
+
t('login.terms.notice', 'How we handle your personal data: {{notices}}.'),
|
|
163
|
+
resolved, props.locale, resolveLabel
|
|
164
|
+
))}
|
|
165
|
+
</p>}
|
|
166
|
+
|
|
167
|
+
{attempted && blocked && resolved != null && <p role="alert" style={{ color: '#b00', fontSize: '.875rem' }}>
|
|
168
|
+
{renderParts(termsSentence(
|
|
169
|
+
t('login.terms.required', 'Please confirm the {{documents}} to continue.'),
|
|
170
|
+
resolved, props.locale, resolveLabel
|
|
171
|
+
))}
|
|
128
172
|
</p>}
|
|
129
173
|
|
|
130
174
|
{props.footer ?? <p style={{ marginTop: '2rem', fontSize: '.75rem', opacity: .7 }}>
|
package/src/login/service.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { adoptToken, revokeToken } from './adopt.js'
|
|
|
7
7
|
import { resolveLoginMethods } from './methods.js'
|
|
8
8
|
import { LoginOutcome } from './types.js'
|
|
9
9
|
import type {
|
|
10
|
-
LoginContext, LoginMethodSource, LoginNotifier, LoginPlugin, LoginPrecondition,
|
|
11
|
-
LoginScreenComponent, LoginService, LoginServiceAppend
|
|
10
|
+
LoginContext, LoginLandingHook, LoginMethodSource, LoginNotifier, LoginPlugin, LoginPrecondition,
|
|
11
|
+
LoginScreenComponent, LoginService, LoginServiceAppend, LoginStep
|
|
12
12
|
} from './types.js'
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -33,6 +33,8 @@ export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService =>
|
|
|
33
33
|
const plugins: LoginPlugin[] = []
|
|
34
34
|
const preconditions: LoginPrecondition[] = []
|
|
35
35
|
const methodSources: LoginMethodSource[] = []
|
|
36
|
+
const steps: LoginStep[] = []
|
|
37
|
+
const hooks: LoginLandingHook[] = []
|
|
36
38
|
let screen: LoginScreenComponent | null = null
|
|
37
39
|
let notifier: LoginNotifier | null = null
|
|
38
40
|
|
|
@@ -77,6 +79,28 @@ export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService =>
|
|
|
77
79
|
|
|
78
80
|
registerNotifier: value => { notifier = value },
|
|
79
81
|
|
|
82
|
+
registerStep: (step: LoginStep) => {
|
|
83
|
+
const existing = steps.findIndex(candidate => candidate.alias === step.alias)
|
|
84
|
+
if (existing >= 0) {
|
|
85
|
+
steps.splice(existing, 1)
|
|
86
|
+
}
|
|
87
|
+
steps.push(step)
|
|
88
|
+
steps.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
steps: () => [...steps],
|
|
92
|
+
|
|
93
|
+
onLanded: (hook: LoginLandingHook) => {
|
|
94
|
+
const existing = hooks.findIndex(candidate => candidate.alias === hook.alias)
|
|
95
|
+
if (existing >= 0) {
|
|
96
|
+
hooks.splice(existing, 1)
|
|
97
|
+
}
|
|
98
|
+
hooks.push(hook)
|
|
99
|
+
hooks.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
landingHooks: () => [...hooks],
|
|
103
|
+
|
|
80
104
|
plugin: env => {
|
|
81
105
|
const environment = env ?? defaultLoginEnv()
|
|
82
106
|
const plugin = plugins.find(
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extra `LoginTermsConfig` fields, added by TypeScript module augmentation rather than by editing
|
|
3
|
+
* `@owlmeans/config` itself.
|
|
4
|
+
*
|
|
5
|
+
* `@owlmeans/config` has roughly 85 dependents, and every one of them sits in the same release
|
|
6
|
+
* closure — a config bump ripples through every package that pins a range against it, whether or
|
|
7
|
+
* not it touches sign-in. The fields below are needed by exactly one consumer family (the sign-in
|
|
8
|
+
* screen and its terms confirmation, all downstream of `@owlmeans/client-auth`), so they are
|
|
9
|
+
* declared here instead, merged onto the base interface by TypeScript's own declaration merging.
|
|
10
|
+
*
|
|
11
|
+
* This file must be part of whatever a consumer's own compilation actually loads, or the merge
|
|
12
|
+
* never happens — an ambient augmentation applies only once the compiler has read the file that
|
|
13
|
+
* declares it. `@owlmeans/client-auth/login`'s barrel (`./index.ts`) re-exports this module for
|
|
14
|
+
* exactly that reason, so ANY import from `@owlmeans/client-auth/login` — which every application
|
|
15
|
+
* wiring sign-in already has — pulls this augmentation into that program. An app that constructs a
|
|
16
|
+
* `LoginTermsConfig` literal in a file that imports NOTHING from `@owlmeans/client-auth/login`
|
|
17
|
+
* still benefits as long as some other file in the same `tsc` program does; if none does (an
|
|
18
|
+
* isolated leaf package that only ever imports `@owlmeans/config`), add
|
|
19
|
+
* `import type {} from '@owlmeans/client-auth/login'` to the file building the literal — an
|
|
20
|
+
* import with an empty type list, kept only to pull this file into that program's graph.
|
|
21
|
+
*
|
|
22
|
+
* `export {}` below is required, not decorative: without at least one top-level import/export this
|
|
23
|
+
* file is a SCRIPT to TypeScript, not a MODULE, and `declare module '@owlmeans/config'` inside a
|
|
24
|
+
* script augments the global scope instead of merging onto the named module's exports.
|
|
25
|
+
*/
|
|
26
|
+
declare module '@owlmeans/config' {
|
|
27
|
+
interface LoginTermsConfig {
|
|
28
|
+
/** `documents[].key` (or a custom document's own key) → the ISO date it was last revised. */
|
|
29
|
+
revisions?: Record<string, string>
|
|
30
|
+
/** Off by default. A bare string is `{ href }`. */
|
|
31
|
+
billing?: string | { href: string, revisedAt?: string } | false
|
|
32
|
+
/** Off by default. `name` is interpolated into the `login.terms.product` translation. */
|
|
33
|
+
product?: { name: string, href: string, revisedAt?: string } | false
|
|
34
|
+
/**
|
|
35
|
+
* Custom documents beyond terms/billing/product, in the order they are agreed to.
|
|
36
|
+
*
|
|
37
|
+
* `label` may be a locale map (`{ en: '...', fr: '...' }`) — resolved at RENDER time, by
|
|
38
|
+
* whichever renderer has the current locale, never here: this file stays locale-free.
|
|
39
|
+
*/
|
|
40
|
+
documents?: Array<{
|
|
41
|
+
key: string, href: string, label?: string | Record<string, string>, i18nKey?: string,
|
|
42
|
+
revisedAt?: string,
|
|
43
|
+
}>
|
|
44
|
+
/** Show the latest revision date beneath the confirmation. Default false. */
|
|
45
|
+
showRevision?: boolean
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { }
|
package/src/login/terms.ts
CHANGED
|
Binary file
|
package/src/login/types.ts
CHANGED
|
@@ -206,6 +206,13 @@ export interface LoginScreenProps {
|
|
|
206
206
|
* reaches for an i18n provider crashes the whole render in an app mounted without one.
|
|
207
207
|
*/
|
|
208
208
|
translate?: (key: string, defaultValue: string) => string
|
|
209
|
+
/**
|
|
210
|
+
* The current language, for `Intl.ListFormat` and a custom document's own locale-keyed label.
|
|
211
|
+
*
|
|
212
|
+
* A prop for the same reason `translate` is: a component that reaches for an i18n context
|
|
213
|
+
* directly crashes the whole render in an app mounted without one.
|
|
214
|
+
*/
|
|
215
|
+
locale?: string
|
|
209
216
|
/** Replace or reorder what the resolver produced. */
|
|
210
217
|
methods?: LoginMethod[] | ((methods: LoginMethod[]) => LoginMethod[])
|
|
211
218
|
terms?: LoginTermsConfig | false
|
|
@@ -258,6 +265,14 @@ export interface LoginService extends LazyService {
|
|
|
258
265
|
screen: () => LoginScreenComponent | null
|
|
259
266
|
/** See {@link LoginNotifier}. Replaces any previously registered notifier. */
|
|
260
267
|
registerNotifier: (notifier: LoginNotifier) => void
|
|
268
|
+
/** Register a post-login step. Replace-by-alias, priority-sorted (higher first). */
|
|
269
|
+
registerStep: (step: LoginStep) => void
|
|
270
|
+
/** Every registered step, priority-sorted (higher first). */
|
|
271
|
+
steps: () => LoginStep[]
|
|
272
|
+
/** Register a hook that observes a freshly authenticated token. Replace-by-alias, priority-sorted. */
|
|
273
|
+
onLanded: (hook: LoginLandingHook) => void
|
|
274
|
+
/** Every registered landing hook, priority-sorted (higher first). */
|
|
275
|
+
landingHooks: () => LoginLandingHook[]
|
|
261
276
|
// Facade — every method re-selects the plugin and delegates.
|
|
262
277
|
// NOTE: these stay plain writable instance properties, never getters, so that alternative
|
|
263
278
|
// implementations (e.g. a native login service) can monkey-patch them directly.
|
|
@@ -274,6 +289,73 @@ export interface LoginService extends LazyService {
|
|
|
274
289
|
revoke: () => Promise<void>
|
|
275
290
|
}
|
|
276
291
|
|
|
292
|
+
/**
|
|
293
|
+
* A step to run after sign-in completes, before the ordinary landing.
|
|
294
|
+
*
|
|
295
|
+
* Registered by a package that must collect something from a freshly authenticated user before
|
|
296
|
+
* the application resumes wherever it was headed — the marketing-consent screen is the first
|
|
297
|
+
* consumer. `landAfterLogin`/`continueLogin` (./land.js) walk the registered steps in priority
|
|
298
|
+
* order and land on the first one whose `pending` resolves `true`.
|
|
299
|
+
*/
|
|
300
|
+
/**
|
|
301
|
+
* The value shape a landing's `params`/`query` carries.
|
|
302
|
+
*
|
|
303
|
+
* Matches `FlowPayload` (`@owlmeans/flow`) and the `AbstractRequest` params/query fields
|
|
304
|
+
* (`@owlmeans/entrypoint`) structurally, rather than the narrower `Record<string, string>`, so a
|
|
305
|
+
* `LoginLanding` can be built directly from either a `SuspendedLandingRecord`'s query or a
|
|
306
|
+
* dispatcher's own forwarded `params`/`query` without a lossy re-cast at every call site.
|
|
307
|
+
*/
|
|
308
|
+
export type LoginLandingParams = Record<string, string | number | boolean | undefined | null>
|
|
309
|
+
|
|
310
|
+
export interface LoginStep {
|
|
311
|
+
alias: string
|
|
312
|
+
/** Higher runs first. Defaults to 0. */
|
|
313
|
+
priority?: number
|
|
314
|
+
/** Entrypoint alias to navigate to while this step is pending. */
|
|
315
|
+
entrypoint: string
|
|
316
|
+
/** Extra query parameters for the landing, alongside the step's own alias. */
|
|
317
|
+
query?: (ctx: LoginContext) => LoginLandingParams | undefined
|
|
318
|
+
/** Whether this step still has something to collect from the signed-in user. */
|
|
319
|
+
pending: (ctx: LoginContext) => Promise<boolean>
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Where a finished sign-in (or a pending step) lands. */
|
|
323
|
+
export interface LoginLanding {
|
|
324
|
+
alias: string
|
|
325
|
+
params?: LoginLandingParams
|
|
326
|
+
query?: LoginLandingParams
|
|
327
|
+
/** The step alias this landing came from, when it came from one. */
|
|
328
|
+
step?: string
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Observes that a freshly authenticated token has landed — unlike {@link LoginStep}, it never
|
|
333
|
+
* blocks the landing, it only reacts to it, once per distinct token.
|
|
334
|
+
*
|
|
335
|
+
* MUST never throw uncaught: the caller (`landAfterLogin`) bounds and swallows every hook's
|
|
336
|
+
* failure, on a timeout as well as a rejection, so a broken hook can never block sign-in.
|
|
337
|
+
*/
|
|
338
|
+
export interface LoginLandingHook {
|
|
339
|
+
alias: string
|
|
340
|
+
priority?: number
|
|
341
|
+
landed: (ctx: LoginContext) => Promise<void>
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Options for {@link LoginService.landAfterLogin}-shaped helpers (./land.js). */
|
|
345
|
+
export interface LandOptions {
|
|
346
|
+
/** Where to land when nothing else claims the flow. Defaults to `{ alias: HOME }`. */
|
|
347
|
+
fallback?: LoginLanding
|
|
348
|
+
/** `false` skips `resumeSuspendedFlow` — the caller already has its own concrete destination. */
|
|
349
|
+
resume?: boolean
|
|
350
|
+
/** Skip every step up to and including this alias — the resume point for a step's re-entry. */
|
|
351
|
+
after?: string
|
|
352
|
+
/**
|
|
353
|
+
* Overrides the step/hook timeout for this call. Mainly for tests; production callers leave it
|
|
354
|
+
* at the default (`LOGIN_STEP_TIMEOUT`).
|
|
355
|
+
*/
|
|
356
|
+
stepTimeout?: number
|
|
357
|
+
}
|
|
358
|
+
|
|
277
359
|
export interface LoginServiceAppend {
|
|
278
360
|
login: () => LoginService
|
|
279
361
|
}
|
package/src/manager/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Comprehensive error handling for authentication scenarios:
|
|
|
37
37
|
This manager is part of the `@owlmeans/client-auth` package:
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
npm install @owlmeans/client-auth@^0.1.18-rc.
|
|
40
|
+
npm install @owlmeans/client-auth@^0.1.18-rc.45
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
## API Reference
|