@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.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/client-auth/SKILL.md +10 -7
  4. package/build/components/dispatcher/component.d.ts.map +1 -1
  5. package/build/components/dispatcher/component.js +19 -12
  6. package/build/components/dispatcher/component.js.map +1 -1
  7. package/build/login/i18n/be.json +6 -2
  8. package/build/login/i18n/de.json +6 -2
  9. package/build/login/i18n/en.json +6 -2
  10. package/build/login/i18n/es.json +6 -2
  11. package/build/login/i18n/fr.json +6 -2
  12. package/build/login/i18n/pl.json +6 -2
  13. package/build/login/i18n/ru.json +6 -2
  14. package/build/login/i18n/uk.json +6 -2
  15. package/build/login/index.d.ts +2 -0
  16. package/build/login/index.d.ts.map +1 -1
  17. package/build/login/index.js +2 -0
  18. package/build/login/index.js.map +1 -1
  19. package/build/login/land.d.ts +44 -0
  20. package/build/login/land.d.ts.map +1 -0
  21. package/build/login/land.js +158 -0
  22. package/build/login/land.js.map +1 -0
  23. package/build/login/screen.d.ts.map +1 -1
  24. package/build/login/screen.js +27 -2
  25. package/build/login/screen.js.map +1 -1
  26. package/build/login/service.d.ts.map +1 -1
  27. package/build/login/service.js +20 -0
  28. package/build/login/service.js.map +1 -1
  29. package/build/login/terms-config.d.ts +59 -0
  30. package/build/login/terms-config.d.ts.map +1 -0
  31. package/build/login/terms-config.js +2 -0
  32. package/build/login/terms-config.js.map +1 -0
  33. package/build/login/terms.d.ts +38 -0
  34. package/build/login/terms.d.ts.map +1 -1
  35. package/build/login/terms.js +0 -0
  36. package/build/login/terms.js.map +1 -1
  37. package/build/login/types.d.ts +77 -0
  38. package/build/login/types.d.ts.map +1 -1
  39. package/package.json +22 -22
  40. package/src/components/dispatcher/component.tsx +25 -12
  41. package/src/login/i18n/be.json +6 -2
  42. package/src/login/i18n/de.json +6 -2
  43. package/src/login/i18n/en.json +6 -2
  44. package/src/login/i18n/es.json +6 -2
  45. package/src/login/i18n/fr.json +6 -2
  46. package/src/login/i18n/pl.json +6 -2
  47. package/src/login/i18n/ru.json +6 -2
  48. package/src/login/i18n/uk.json +6 -2
  49. package/src/login/index.ts +2 -0
  50. package/src/login/land.ts +186 -0
  51. package/src/login/screen.tsx +58 -14
  52. package/src/login/service.ts +26 -2
  53. package/src/login/terms-config.ts +49 -0
  54. package/src/login/terms.ts +0 -0
  55. package/src/login/types.ts +82 -0
  56. package/src/manager/README.md +1 -1
  57. package/tests/i18n.spec.ts +68 -0
  58. package/tests/land.spec.ts +241 -0
  59. package/tests/terms.spec.ts +202 -0
@@ -16,11 +16,15 @@
16
16
  "fallback": "Continue with {{method}}"
17
17
  },
18
18
  "terms": {
19
- "agreement": "I have read and agree to the {{terms}} and the {{privacy}}.",
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
- "required": "Please confirm the Terms & Conditions and the Privacy Policy to continue."
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."
@@ -16,11 +16,15 @@
16
16
  "fallback": "Continuar con {{method}}"
17
17
  },
18
18
  "terms": {
19
- "agreement": "He leído y acepto los {{terms}} y la {{privacy}}.",
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
- "required": "Confirma los Términos y Condiciones y la Política de Privacidad para continuar."
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."
@@ -16,11 +16,15 @@
16
16
  "fallback": "Continuer avec {{method}}"
17
17
  },
18
18
  "terms": {
19
- "agreement": "J’ai lu et j’accepte les {{terms}} et la {{privacy}}.",
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
- "required": "Confirmez les conditions générales et la politique de confidentialité pour continuer."
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."
@@ -16,11 +16,15 @@
16
16
  "fallback": "Kontynuuj z {{method}}"
17
17
  },
18
18
  "terms": {
19
- "agreement": "Przeczytałem(-am) i akceptuję {{terms}} oraz {{privacy}}.",
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
- "required": "Aby kontynuować, potwierdź Regulamin i Politykę prywatności."
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ć."
@@ -16,11 +16,15 @@
16
16
  "fallback": "Продолжить с {{method}}"
17
17
  },
18
18
  "terms": {
19
- "agreement": "Я прочитал(а) и принимаю {{terms}} и {{privacy}}.",
19
+ "accept": "Я прочитал(а) и принимаю {{documents}}.",
20
20
  "terms": "Условия использования",
21
21
  "privacy": "Политику конфиденциальности",
22
22
  "cookies": "Политику файлов cookie",
23
- "required": "Чтобы продолжить, подтвердите Условия использования и Политику конфиденциальности."
23
+ "billing": "Условия оплаты",
24
+ "product": "Условия использования продукта {{product}}",
25
+ "notice": "Как мы обрабатываем ваши персональные данные: {{notices}}.",
26
+ "revised": "Последнее обновление: {{date}}",
27
+ "required": "Чтобы продолжить, подтвердите {{documents}}."
24
28
  },
25
29
  "consent": {
26
30
  "required": "Вход сохраняет сессионный файл cookie. Примите необходимые файлы cookie, чтобы продолжить."
@@ -16,11 +16,15 @@
16
16
  "fallback": "Продовжити з {{method}}"
17
17
  },
18
18
  "terms": {
19
- "agreement": "Я прочитав(-ла) і приймаю {{terms}} та {{privacy}}.",
19
+ "accept": "Я прочитав(-ла) і приймаю {{documents}}.",
20
20
  "terms": "Умови користування",
21
21
  "privacy": "Політику конфіденційності",
22
22
  "cookies": "Політику файлів cookie",
23
- "required": "Щоб продовжити, підтвердьте Умови користування та Політику конфіденційності."
23
+ "billing": "Умови оплати",
24
+ "product": "Умови продукту {{product}}",
25
+ "notice": "Як ми обробляємо ваші персональні дані: {{notices}}.",
26
+ "revised": "Востаннє оновлено: {{date}}",
27
+ "required": "Щоб продовжити, підтвердьте {{documents}}."
24
28
  },
25
29
  "consent": {
26
30
  "required": "Вхід зберігає сеансовий файл cookie. Прийміть необхідні файли cookie, щоб продовжити."
@@ -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
+ }
@@ -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
- {t('login.terms.agreement', 'I have read and agree to the Terms & Conditions and the Privacy Policy.')}
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
- {attempted && blocked && <p role="alert" style={{ color: '#b00', fontSize: '.875rem' }}>
126
- {t('login.terms.required',
127
- 'Please confirm the Terms & Conditions and the Privacy Policy to continue.')}
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 }}>
@@ -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 { }
Binary file
@@ -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
  }
@@ -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.44
40
+ npm install @owlmeans/client-auth@^0.1.18-rc.45
41
41
  ```
42
42
 
43
43
  ## API Reference