@owlmeans/client-auth 0.1.18-rc.9 → 0.1.18

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 (255) hide show
  1. package/README.md +283 -37
  2. package/agent-meta/manifest.json +50 -4
  3. package/agent-meta/skills/client-auth/SKILL.md +84 -55
  4. package/agent-meta/skills/client-auth/parts/login.md +77 -0
  5. package/agent-meta/skills/client-auth/parts/manager.md +37 -0
  6. package/build/components/dispatcher/component.d.ts.map +1 -1
  7. package/build/components/dispatcher/component.js +26 -6
  8. package/build/components/dispatcher/component.js.map +1 -1
  9. package/build/components/dispatcher/types.local.d.ts +7 -0
  10. package/build/components/dispatcher/types.local.d.ts.map +1 -0
  11. package/build/components/dispatcher/types.local.js +2 -0
  12. package/build/components/dispatcher/types.local.js.map +1 -0
  13. package/build/entrypoints.d.ts +7 -0
  14. package/build/entrypoints.d.ts.map +1 -0
  15. package/build/entrypoints.js +7 -0
  16. package/build/entrypoints.js.map +1 -0
  17. package/build/helper.d.ts +2 -1
  18. package/build/helper.d.ts.map +1 -1
  19. package/build/helper.js +28 -5
  20. package/build/helper.js.map +1 -1
  21. package/build/index.d.ts +1 -1
  22. package/build/index.d.ts.map +1 -1
  23. package/build/index.js +1 -1
  24. package/build/index.js.map +1 -1
  25. package/build/login/adopt/types.d.ts +26 -0
  26. package/build/login/adopt/types.d.ts.map +1 -0
  27. package/build/login/adopt/types.js +2 -0
  28. package/build/login/adopt/types.js.map +1 -0
  29. package/build/login/adopt.d.ts +7 -12
  30. package/build/login/adopt.d.ts.map +1 -1
  31. package/build/login/adopt.js +15 -14
  32. package/build/login/adopt.js.map +1 -1
  33. package/build/login/consts.d.ts +89 -4
  34. package/build/login/consts.d.ts.map +1 -1
  35. package/build/login/consts.js +93 -5
  36. package/build/login/consts.js.map +1 -1
  37. package/build/login/consts.local.d.ts +16 -0
  38. package/build/login/consts.local.d.ts.map +1 -0
  39. package/build/login/consts.local.js +30 -0
  40. package/build/login/consts.local.js.map +1 -0
  41. package/build/login/credit/types.d.ts +7 -0
  42. package/build/login/credit/types.d.ts.map +1 -0
  43. package/build/login/credit/types.js +2 -0
  44. package/build/login/credit/types.js.map +1 -0
  45. package/build/login/credit.d.ts +19 -0
  46. package/build/login/credit.d.ts.map +1 -0
  47. package/build/login/credit.js +58 -0
  48. package/build/login/credit.js.map +1 -0
  49. package/build/login/env/types.d.ts +35 -0
  50. package/build/login/env/types.d.ts.map +1 -0
  51. package/build/login/env/types.js +2 -0
  52. package/build/login/env/types.js.map +1 -0
  53. package/build/login/env.d.ts +5 -29
  54. package/build/login/env.d.ts.map +1 -1
  55. package/build/login/env.js +63 -80
  56. package/build/login/env.js.map +1 -1
  57. package/build/login/flow.d.ts +20 -0
  58. package/build/login/flow.d.ts.map +1 -0
  59. package/build/login/flow.js +39 -0
  60. package/build/login/flow.js.map +1 -0
  61. package/build/login/hook.d.ts +30 -4
  62. package/build/login/hook.d.ts.map +1 -1
  63. package/build/login/hook.js +53 -18
  64. package/build/login/hook.js.map +1 -1
  65. package/build/login/i18n/be.json +41 -0
  66. package/build/login/i18n/de.json +41 -0
  67. package/build/login/i18n/en.json +41 -0
  68. package/build/login/i18n/es.json +41 -0
  69. package/build/login/i18n/fr.json +41 -0
  70. package/build/login/i18n/pl.json +41 -0
  71. package/build/login/i18n/ru.json +41 -0
  72. package/build/login/i18n/uk.json +41 -0
  73. package/build/login/i18n.d.ts +2 -0
  74. package/build/login/i18n.d.ts.map +1 -0
  75. package/build/login/i18n.js +27 -0
  76. package/build/login/i18n.js.map +1 -0
  77. package/build/login/index.d.ts +20 -0
  78. package/build/login/index.d.ts.map +1 -1
  79. package/build/login/index.js +11 -0
  80. package/build/login/index.js.map +1 -1
  81. package/build/login/land/types.d.ts +52 -0
  82. package/build/login/land/types.d.ts.map +1 -0
  83. package/build/login/land/types.js +2 -0
  84. package/build/login/land/types.js.map +1 -0
  85. package/build/login/land.d.ts +6 -0
  86. package/build/login/land.d.ts.map +1 -0
  87. package/build/login/land.js +122 -0
  88. package/build/login/land.js.map +1 -0
  89. package/build/login/methods/types.d.ts +30 -0
  90. package/build/login/methods/types.d.ts.map +1 -0
  91. package/build/login/methods/types.js +2 -0
  92. package/build/login/methods/types.js.map +1 -0
  93. package/build/login/methods.d.ts +7 -0
  94. package/build/login/methods.d.ts.map +1 -0
  95. package/build/login/methods.js +87 -0
  96. package/build/login/methods.js.map +1 -0
  97. package/build/login/resume/types.d.ts +24 -0
  98. package/build/login/resume/types.d.ts.map +1 -0
  99. package/build/login/resume/types.js +2 -0
  100. package/build/login/resume/types.js.map +1 -0
  101. package/build/login/resume.d.ts +9 -0
  102. package/build/login/resume.d.ts.map +1 -0
  103. package/build/login/resume.js +38 -0
  104. package/build/login/resume.js.map +1 -0
  105. package/build/login/screen.d.ts +17 -0
  106. package/build/login/screen.d.ts.map +1 -0
  107. package/build/login/screen.js +66 -0
  108. package/build/login/screen.js.map +1 -0
  109. package/build/login/service.d.ts +6 -5
  110. package/build/login/service.d.ts.map +1 -1
  111. package/build/login/service.js +141 -15
  112. package/build/login/service.js.map +1 -1
  113. package/build/login/start/types.d.ts +29 -0
  114. package/build/login/start/types.d.ts.map +1 -0
  115. package/build/login/start/types.js +2 -0
  116. package/build/login/start/types.js.map +1 -0
  117. package/build/login/start.d.ts +5 -0
  118. package/build/login/start.d.ts.map +1 -0
  119. package/build/login/start.js +36 -0
  120. package/build/login/start.js.map +1 -0
  121. package/build/login/surrogate/types.d.ts +9 -0
  122. package/build/login/surrogate/types.d.ts.map +1 -0
  123. package/build/login/surrogate/types.js +2 -0
  124. package/build/login/surrogate/types.js.map +1 -0
  125. package/build/login/surrogate.d.ts +13 -0
  126. package/build/login/surrogate.d.ts.map +1 -0
  127. package/build/login/surrogate.js +33 -0
  128. package/build/login/surrogate.js.map +1 -0
  129. package/build/login/terms/types.d.ts +112 -0
  130. package/build/login/terms/types.d.ts.map +1 -0
  131. package/build/login/terms/types.js +2 -0
  132. package/build/login/terms/types.js.map +1 -0
  133. package/build/login/terms-config.d.ts +65 -0
  134. package/build/login/terms-config.d.ts.map +1 -0
  135. package/build/login/terms-config.js +2 -0
  136. package/build/login/terms-config.js.map +1 -0
  137. package/build/login/terms.d.ts +21 -0
  138. package/build/login/terms.d.ts.map +1 -0
  139. package/build/login/terms.js +235 -0
  140. package/build/login/terms.js.map +1 -0
  141. package/build/login/types.d.ts +223 -16
  142. package/build/login/types.d.ts.map +1 -1
  143. package/build/login/types.js +1 -16
  144. package/build/login/types.js.map +1 -1
  145. package/build/manager/components/authentication/control.js +3 -3
  146. package/build/manager/components/authentication/control.js.map +1 -1
  147. package/build/manager/components/authentication/types.d.ts +2 -2
  148. package/build/manager/components/authentication/types.d.ts.map +1 -1
  149. package/build/manager/components/tunnel-consumer.js +2 -2
  150. package/build/manager/components/tunnel-consumer.js.map +1 -1
  151. package/build/manager/entrypoints.d.ts +11 -0
  152. package/build/manager/entrypoints.d.ts.map +1 -0
  153. package/build/manager/entrypoints.js +17 -0
  154. package/build/manager/entrypoints.js.map +1 -0
  155. package/build/manager/plugins/basic-ed25519.d.ts.map +1 -1
  156. package/build/manager/plugins/basic-ed25519.js +5 -0
  157. package/build/manager/plugins/basic-ed25519.js.map +1 -1
  158. package/build/manager/plugins/exports.d.ts +3 -0
  159. package/build/manager/plugins/exports.d.ts.map +1 -1
  160. package/build/manager/plugins/exports.js +2 -0
  161. package/build/manager/plugins/exports.js.map +1 -1
  162. package/build/manager/plugins/index.d.ts +2 -5
  163. package/build/manager/plugins/index.d.ts.map +1 -1
  164. package/build/manager/plugins/index.js +9 -1
  165. package/build/manager/plugins/index.js.map +1 -1
  166. package/build/manager/plugins/methods.d.ts +6 -0
  167. package/build/manager/plugins/methods.d.ts.map +1 -0
  168. package/build/manager/plugins/methods.js +65 -0
  169. package/build/manager/plugins/methods.js.map +1 -0
  170. package/build/manager/plugins/re-captcha.d.ts.map +1 -1
  171. package/build/manager/plugins/re-captcha.js +5 -0
  172. package/build/manager/plugins/re-captcha.js.map +1 -1
  173. package/build/manager/plugins/registry/types.d.ts +10 -0
  174. package/build/manager/plugins/registry/types.d.ts.map +1 -0
  175. package/build/manager/plugins/registry/types.js +2 -0
  176. package/build/manager/plugins/registry/types.js.map +1 -0
  177. package/build/manager/plugins/registry.d.ts +20 -0
  178. package/build/manager/plugins/registry.d.ts.map +1 -0
  179. package/build/manager/plugins/registry.js +23 -0
  180. package/build/manager/plugins/registry.js.map +1 -0
  181. package/build/manager/plugins/tunnel-consumer.d.ts.map +1 -1
  182. package/build/manager/plugins/tunnel-consumer.js +10 -3
  183. package/build/manager/plugins/tunnel-consumer.js.map +1 -1
  184. package/build/manager/plugins/types.d.ts +53 -0
  185. package/build/manager/plugins/types.d.ts.map +1 -1
  186. package/build/modules.d.ts.map +1 -1
  187. package/build/service.js +1 -1
  188. package/build/service.js.map +1 -1
  189. package/package.json +33 -30
  190. package/src/components/dispatcher/component.tsx +37 -14
  191. package/src/components/dispatcher/types.local.ts +7 -0
  192. package/src/entrypoints.ts +10 -0
  193. package/src/helper.ts +31 -5
  194. package/src/index.ts +1 -1
  195. package/src/login/adopt/types.ts +25 -0
  196. package/src/login/adopt.ts +21 -14
  197. package/src/login/consts.local.ts +34 -0
  198. package/src/login/consts.ts +105 -5
  199. package/src/login/credit/types.ts +6 -0
  200. package/src/login/credit.ts +69 -0
  201. package/src/login/env/types.ts +35 -0
  202. package/src/login/env.ts +60 -72
  203. package/src/login/flow.ts +44 -0
  204. package/src/login/hook.ts +58 -22
  205. package/src/login/i18n/be.json +41 -0
  206. package/src/login/i18n/de.json +41 -0
  207. package/src/login/i18n/en.json +41 -0
  208. package/src/login/i18n/es.json +41 -0
  209. package/src/login/i18n/fr.json +41 -0
  210. package/src/login/i18n/pl.json +41 -0
  211. package/src/login/i18n/ru.json +41 -0
  212. package/src/login/i18n/uk.json +41 -0
  213. package/src/login/i18n.ts +29 -0
  214. package/src/login/index.ts +21 -0
  215. package/src/login/land/types.ts +54 -0
  216. package/src/login/land.ts +149 -0
  217. package/src/login/methods/types.ts +32 -0
  218. package/src/login/methods.ts +109 -0
  219. package/src/login/resume/types.ts +24 -0
  220. package/src/login/resume.ts +45 -0
  221. package/src/login/screen.tsx +140 -0
  222. package/src/login/service.ts +181 -18
  223. package/src/login/start/types.ts +30 -0
  224. package/src/login/start.ts +44 -0
  225. package/src/login/surrogate/types.ts +9 -0
  226. package/src/login/surrogate.ts +37 -0
  227. package/src/login/terms/types.ts +126 -0
  228. package/src/login/terms-config.ts +55 -0
  229. package/src/login/terms.ts +315 -0
  230. package/src/login/types.ts +233 -17
  231. package/src/manager/README.md +37 -80
  232. package/src/manager/components/authentication/control.ts +3 -3
  233. package/src/manager/components/authentication/types.ts +2 -2
  234. package/src/manager/components/tunnel-consumer.tsx +2 -2
  235. package/src/manager/entrypoints.ts +18 -0
  236. package/src/manager/plugins/basic-ed25519.tsx +7 -0
  237. package/src/manager/plugins/exports.ts +3 -0
  238. package/src/manager/plugins/index.ts +11 -4
  239. package/src/manager/plugins/methods.ts +74 -0
  240. package/src/manager/plugins/re-captcha.tsx +7 -0
  241. package/src/manager/plugins/registry/types.ts +10 -0
  242. package/src/manager/plugins/registry.ts +33 -0
  243. package/src/manager/plugins/tunnel-consumer.tsx +12 -3
  244. package/src/manager/plugins/types.ts +54 -0
  245. package/src/service.ts +1 -1
  246. package/tests/context.ts +1 -2
  247. package/tests/i18n.spec.ts +68 -0
  248. package/tests/land.spec.ts +292 -0
  249. package/tests/login.spec.ts +332 -0
  250. package/tests/target.spec.ts +210 -0
  251. package/tests/terms-deferred.spec.ts +65 -0
  252. package/tests/terms.spec.ts +314 -0
  253. package/build/.gitkeep +0 -0
  254. package/src/manager/modules.ts +0 -18
  255. package/src/modules.ts +0 -13
@@ -0,0 +1,315 @@
1
+ import { OWLMEANS_COOKIES_URL, OWLMEANS_PRIVACY_URL, OWLMEANS_TERMS_URL, type LoginTermsConfig } from '@owlmeans/config'
2
+ import { LOGIN_SERVICE, LOGIN_TERMS_STORAGE } from './consts.js'
3
+ import type { LoginContext, LoginService } from './types.js'
4
+ import type {
5
+ LoginTermsAcceptance, LoginTermsHelper, ResolvedTerms, ResolvedTermsDocument, TermsSentencePart
6
+ } from './terms/types.js'
7
+ import './terms-config.js'
8
+ import { DEFAULT_LABEL } from './consts.local.js'
9
+
10
+ export const createLoginTermsHelper = (): LoginTermsHelper => {
11
+ /**
12
+ * A stable identity for one set of documents.
13
+ *
14
+ * Acceptance is recorded against it, so changing a URL — or a revision date, or adding a document
15
+ * — re-asks. Cheap and order-independent rather than cryptographic; it only has to change when the
16
+ * content pointer does.
17
+ *
18
+ * With no billing/product/custom documents and no revisions configured, this MUST produce the same
19
+ * digest as before this file grew those fields: `digest([terms, privacy, cookies])`. `terms.spec.ts`
20
+ * pins that byte-identity so no existing user is silently asked to re-accept.
21
+ */
22
+ const digest = (parts: string[]): string => {
23
+ let hash = 0
24
+ for (const char of parts.join(' ')) {
25
+ hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0
26
+ }
27
+
28
+ return (hash >>> 0).toString(36)
29
+ }
30
+
31
+ const withRevisedAt = (
32
+ doc: Omit<ResolvedTermsDocument, 'revisedAt'>, revisedAt: string | undefined
33
+ ): ResolvedTermsDocument => revisedAt != null ? { ...doc, revisedAt } : doc
34
+
35
+ const resolveTerms = (cfg?: LoginTermsConfig | false): ResolvedTerms | null => {
36
+ if (cfg === false) {
37
+ return null
38
+ }
39
+ const terms = cfg?.terms ?? OWLMEANS_TERMS_URL
40
+ const privacy = cfg?.privacy ?? OWLMEANS_PRIVACY_URL
41
+ const cookies = cfg?.cookies ?? OWLMEANS_COOKIES_URL
42
+
43
+ const documents: ResolvedTermsDocument[] = [
44
+ withRevisedAt(
45
+ { key: 'terms', href: terms, i18nKey: 'login.terms.terms' }, cfg?.revisions?.terms
46
+ ),
47
+ ]
48
+
49
+ if (cfg?.billing) {
50
+ const billing = typeof cfg.billing === 'string' ? { href: cfg.billing } : cfg.billing
51
+ documents.push(withRevisedAt(
52
+ { key: 'billing', href: billing.href, i18nKey: 'login.terms.billing' },
53
+ cfg?.revisions?.billing ?? billing.revisedAt
54
+ ))
55
+ }
56
+
57
+ if (cfg?.product) {
58
+ documents.push(withRevisedAt(
59
+ {
60
+ key: 'product', href: cfg.product.href, i18nKey: 'login.terms.product',
61
+ params: { product: cfg.product.name },
62
+ },
63
+ cfg.product.revisedAt ?? cfg?.revisions?.product
64
+ ))
65
+ }
66
+
67
+ for (const custom of cfg?.documents ?? []) {
68
+ documents.push(withRevisedAt(
69
+ {
70
+ key: custom.key, href: custom.href,
71
+ ...(typeof custom.label === 'string' ? { label: custom.label } : {}),
72
+ ...(custom.label != null && typeof custom.label !== 'string' ? { labelMap: custom.label } : {}),
73
+ ...(custom.i18nKey != null ? { i18nKey: custom.i18nKey } : {}),
74
+ },
75
+ custom.revisedAt ?? cfg?.revisions?.[custom.key]
76
+ ))
77
+ }
78
+
79
+ // Cookies is disclosed as ITS OWN notice only when the app named a cookie policy explicitly, or
80
+ // when terms/privacy/cookies are ALL still the OwlMeans defaults — never as a silent extra on an
81
+ // app that customized terms and privacy but left cookies unset.
82
+ const allDefault = terms === OWLMEANS_TERMS_URL && privacy === OWLMEANS_PRIVACY_URL
83
+ && cookies === OWLMEANS_COOKIES_URL
84
+ const includeCookies = cfg?.cookies != null || allDefault
85
+
86
+ const notices: ResolvedTermsDocument[] = [
87
+ withRevisedAt({ key: 'privacy', href: privacy, i18nKey: 'login.terms.privacy' }, cfg?.revisions?.privacy),
88
+ ...(includeCookies
89
+ ? [withRevisedAt({ key: 'cookies', href: cookies, i18nKey: 'login.terms.cookies' }, cfg?.revisions?.cookies)]
90
+ : []),
91
+ ]
92
+
93
+ // The digest input for a plain config (no billing/product/custom/revisions) is BYTE-IDENTICAL to
94
+ // the pre-existing `[terms, privacy, cookies]` — see `terms.spec.ts`.
95
+ const digestInput = [terms, privacy, cookies]
96
+ for (const doc of documents) {
97
+ if (doc.key !== 'terms') {
98
+ digestInput.push(`${doc.key}=${doc.href}`)
99
+ }
100
+ }
101
+ for (const doc of [...documents, ...notices]) {
102
+ if (doc.revisedAt != null) {
103
+ digestInput.push(`${doc.key}@${doc.revisedAt}`)
104
+ }
105
+ }
106
+
107
+ const showRevision = cfg?.showRevision === true
108
+ const revisedAtValues = documents
109
+ .map(doc => doc.revisedAt)
110
+ .filter((value): value is string => value != null)
111
+ const revisedAt = showRevision && revisedAtValues.length > 0
112
+ ? revisedAtValues.reduce((max, value) => value > max ? value : max)
113
+ : undefined
114
+
115
+ return {
116
+ required: cfg?.required ?? true,
117
+ terms, privacy, cookies,
118
+ version: cfg?.version ?? digest(digestInput),
119
+ // Translations are alternate destinations for this canonical revision, not a new agreement.
120
+ documents: documents.map(doc => withLocalizedHrefs(doc, cfg)),
121
+ notices: notices.map(doc => withLocalizedHrefs(doc, cfg)),
122
+ showRevision,
123
+ ...(revisedAt != null ? { revisedAt } : {}),
124
+ }
125
+ }
126
+
127
+ const withLocalizedHrefs = (doc: ResolvedTermsDocument, cfg?: LoginTermsConfig): ResolvedTermsDocument => {
128
+ const hrefMap = cfg?.localizedHrefs?.[doc.key]
129
+
130
+ return hrefMap != null ? { ...doc, hrefMap } : doc
131
+ }
132
+
133
+ const termsHrefOf = (doc: ResolvedTermsDocument, locale?: string): string => {
134
+ const localized = locale != null
135
+ ? doc.hrefMap?.[locale] ?? doc.hrefMap?.[locale.toLowerCase()]
136
+ ?? doc.hrefMap?.[locale.split('-')[0].toLowerCase()]
137
+ : undefined
138
+
139
+ return typeof localized === 'string' && localized.trim() !== '' ? localized : doc.href
140
+ }
141
+
142
+ const termsAccepted = (resolved: ResolvedTerms | null): boolean => {
143
+ if (resolved == null || !resolved.required) {
144
+ return true
145
+ }
146
+ try {
147
+ return window.localStorage.getItem(LOGIN_TERMS_STORAGE) === resolved.version
148
+ } catch {
149
+ // Storage can be unavailable (private modes, blocked cookies). Refusing to remember is not
150
+ // refusing to proceed: the user is asked again, every time, which is the safe direction.
151
+ return false
152
+ }
153
+ }
154
+
155
+ const acceptTerms = (resolved: ResolvedTerms | null, accepted: boolean): void => {
156
+ if (resolved == null) {
157
+ return
158
+ }
159
+ try {
160
+ if (accepted) {
161
+ window.localStorage.setItem(LOGIN_TERMS_STORAGE, resolved.version)
162
+ } else {
163
+ window.localStorage.removeItem(LOGIN_TERMS_STORAGE)
164
+ }
165
+ } catch { /* nothing to remember if storage was never available */ }
166
+ }
167
+
168
+ const termsLabelResolver = (
169
+ translate: (key: string, defaultValue: string) => string, locale: string | undefined
170
+ ) => (doc: ResolvedTermsDocument): string => {
171
+ const fromMap = doc.labelMap != null
172
+ ? (locale != null ? doc.labelMap[locale] : undefined) ?? Object.values(doc.labelMap)[0]
173
+ : undefined
174
+ let label = doc.label ?? fromMap
175
+ ?? (doc.i18nKey != null ? translate(doc.i18nKey, DEFAULT_LABEL[doc.key] ?? doc.key) : doc.key)
176
+
177
+ if (doc.params != null) {
178
+ for (const [key, value] of Object.entries(doc.params)) {
179
+ label = label.split(`{{${key}}}`).join(value)
180
+ }
181
+ }
182
+
183
+ return label
184
+ }
185
+
186
+ const termsAcceptanceOf = (
187
+ resolved: Pick<ResolvedTerms, 'documents' | 'notices' | 'version'>, locale?: string
188
+ ): LoginTermsAcceptance => ({
189
+ documents: resolved.documents.map(doc => ({ key: doc.key, href: termsHrefOf(doc, locale), revisedAt: doc.revisedAt })),
190
+ notices: resolved.notices.map(doc => ({ key: doc.key, href: termsHrefOf(doc, locale), revisedAt: doc.revisedAt })),
191
+ version: resolved.version,
192
+ ...(locale != null ? { locale } : {}),
193
+ })
194
+
195
+ const termsDeferred = (ctx: LoginContext): boolean =>
196
+ ctx.hasService(LOGIN_SERVICE)
197
+ && ctx.service<LoginService>(LOGIN_SERVICE).steps()
198
+ .some(step => step.confirmsTerms === true && ctx.hasEntrypoint(step.entrypoint))
199
+
200
+ /**
201
+ * Format a list of documents into sentence parts, via `Intl.ListFormat` when it exists ("A, B and
202
+ * C" in the caller's own locale and conjunction word), falling back to a plain ", "/" and " join
203
+ * when it does not (an old runtime with no `Intl.ListFormat`).
204
+ */
205
+ const formatDocumentList = (
206
+ docs: ResolvedTermsDocument[], locale: string | undefined, resolveLabel: (doc: ResolvedTermsDocument) => string
207
+ ): TermsSentencePart[] => {
208
+ if (docs.length < 1) {
209
+ return []
210
+ }
211
+ const labels = docs.map(resolveLabel)
212
+
213
+ if (typeof Intl !== 'undefined' && typeof Intl.ListFormat === 'function') {
214
+ let cursor = 0
215
+ const formatter = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' })
216
+
217
+ return formatter.formatToParts(labels).map(part => {
218
+ if (part.type === 'element') {
219
+ const doc = docs[cursor++]
220
+
221
+ return { text: part.value, href: termsHrefOf(doc, locale), documentKey: doc.key }
222
+ }
223
+
224
+ return { text: part.value }
225
+ })
226
+ }
227
+
228
+ const parts: TermsSentencePart[] = []
229
+ docs.forEach((doc, index) => {
230
+ if (index > 0) {
231
+ parts.push({ text: index === docs.length - 1 ? ' and ' : ', ' })
232
+ }
233
+ parts.push({ text: labels[index], href: termsHrefOf(doc, locale), documentKey: doc.key })
234
+ })
235
+
236
+ return parts
237
+ }
238
+
239
+ const termsSentence = (
240
+ template: string,
241
+ resolved: Pick<ResolvedTerms, 'documents' | 'notices'>,
242
+ locale: string | undefined,
243
+ resolveLabel: (doc: ResolvedTermsDocument) => string
244
+ ): TermsSentencePart[] => {
245
+ const byKey = (key: string): ResolvedTermsDocument | undefined =>
246
+ resolved.documents.find(doc => doc.key === key) ?? resolved.notices.find(doc => doc.key === key)
247
+
248
+ const placeholder = /\{\{(documents|notices|terms|privacy|cookies)\}\}/g
249
+ const parts: TermsSentencePart[] = []
250
+ let lastIndex = 0
251
+ let match: RegExpExecArray | null
252
+
253
+ while ((match = placeholder.exec(template)) != null) {
254
+ if (match.index > lastIndex) {
255
+ parts.push({ text: template.slice(lastIndex, match.index) })
256
+ }
257
+
258
+ const token = match[1]
259
+ if (token === 'documents') {
260
+ parts.push(...formatDocumentList(resolved.documents, locale, resolveLabel))
261
+ } else if (token === 'notices') {
262
+ parts.push(...formatDocumentList(resolved.notices, locale, resolveLabel))
263
+ } else {
264
+ const doc = byKey(token)
265
+ if (doc != null) {
266
+ parts.push({ text: resolveLabel(doc), href: termsHrefOf(doc, locale), documentKey: doc.key })
267
+ }
268
+ }
269
+
270
+ lastIndex = placeholder.lastIndex
271
+ }
272
+ if (lastIndex < template.length) {
273
+ parts.push({ text: template.slice(lastIndex) })
274
+ }
275
+
276
+ return parts
277
+ }
278
+
279
+ return {
280
+ resolveTerms, termsAccepted, acceptTerms, termsLabelResolver, termsHrefOf, termsAcceptanceOf, termsDeferred, termsSentence,
281
+ }
282
+ }
283
+
284
+ export const loginTermsHelper = createLoginTermsHelper()
285
+
286
+ /** @deprecated compat:factory-refactor — use `loginTermsHelper.resolveTerms(…)` */
287
+ export const resolveTerms = (cfg?: LoginTermsConfig | false): ResolvedTerms | null => loginTermsHelper.resolveTerms(cfg)
288
+
289
+ /** @deprecated compat:factory-refactor — use `loginTermsHelper.termsAccepted(…)` */
290
+ export const termsAccepted = (resolved: ResolvedTerms | null): boolean => loginTermsHelper.termsAccepted(resolved)
291
+
292
+ /** @deprecated compat:factory-refactor — use `loginTermsHelper.acceptTerms(…)` */
293
+ export const acceptTerms = (resolved: ResolvedTerms | null, accepted: boolean): void =>
294
+ loginTermsHelper.acceptTerms(resolved, accepted)
295
+
296
+ /** @deprecated compat:factory-refactor — use `loginTermsHelper.termsLabelResolver(…)` */
297
+ export const termsLabelResolver = (
298
+ translate: (key: string, defaultValue: string) => string, locale: string | undefined
299
+ ): ((doc: ResolvedTermsDocument) => string) => loginTermsHelper.termsLabelResolver(translate, locale)
300
+
301
+ /** @deprecated compat:factory-refactor — use `loginTermsHelper.termsAcceptanceOf(…)` */
302
+ export const termsAcceptanceOf = (
303
+ resolved: Pick<ResolvedTerms, 'documents' | 'notices' | 'version'>, locale?: string
304
+ ): LoginTermsAcceptance => loginTermsHelper.termsAcceptanceOf(resolved, locale)
305
+
306
+ /** @deprecated compat:factory-refactor — use `loginTermsHelper.termsDeferred(…)` */
307
+ export const termsDeferred = (ctx: LoginContext): boolean => loginTermsHelper.termsDeferred(ctx)
308
+
309
+ /** @deprecated compat:factory-refactor — use `loginTermsHelper.termsSentence(…)` */
310
+ export const termsSentence = (
311
+ template: string,
312
+ resolved: Pick<ResolvedTerms, 'documents' | 'notices'>,
313
+ locale: string | undefined,
314
+ resolveLabel: (doc: ResolvedTermsDocument) => string
315
+ ): TermsSentencePart[] => loginTermsHelper.termsSentence(template, resolved, locale, resolveLabel)
@@ -1,4 +1,7 @@
1
+ import type { ComponentType, CSSProperties, ReactNode } from 'react'
1
2
  import type { LazyService, BasicContext } from '@owlmeans/context'
3
+ import type { LoginMethodEmphasis, LoginScreenConfig, LoginTermsConfig } from '@owlmeans/config'
4
+ import { LoginOutcome } from './consts.js'
2
5
 
3
6
  /**
4
7
  * The context a login plugin is handed.
@@ -27,22 +30,6 @@ export interface LoginEnv {
27
30
  hasOpener: boolean
28
31
  }
29
32
 
30
- /** What a stage did, and therefore what the caller must do next. */
31
- export enum LoginOutcome {
32
- /** The plugin took the flow over and it is finished. The caller does nothing more. */
33
- Handled = 'handled',
34
- /** The plugin did nothing — the caller carries on with its ordinary continuation. */
35
- Passed = 'passed',
36
- /** The browser is leaving this document. The caller must not navigate or render. */
37
- Redirected = 'redirected',
38
- /** Cannot proceed without a fresh user gesture — the caller renders a sign-in control. */
39
- Gesture = 'gesture',
40
- /** Authenticated, but with no channel back to the window that started the flow. */
41
- Orphaned = 'orphaned',
42
- /** The attempt ended with no token (blocked window, user closed it, provider refused). */
43
- Failed = 'failed',
44
- }
45
-
46
33
  export interface LoginRequest {
47
34
  /** Where the login flow starts — a resolved dispatcher path, or the current address. */
48
35
  url: string
@@ -52,10 +39,28 @@ export interface LoginRequest {
52
39
  * fallback is a full page load of {@link url}.
53
40
  */
54
41
  navigate?: () => void | Promise<void>
55
- /** Entrypoint alias to return to after login. */
42
+ /**
43
+ * Entrypoint alias to land on once sign-in completes.
44
+ *
45
+ * The facade parks it (`suspendLanding` from `@owlmeans/client-flow`, the record
46
+ * `resumeSuspendedFlow` reads) once the preconditions have passed, and holds {@link navigate}
47
+ * until the write has landed — so the continuation still goes to the dispatcher, whose ordinary
48
+ * post-sign-in landing (`landAfterLogin`: landing hooks, pending steps such as a consent screen,
49
+ * then the suspended landing) ends on this screen. It is never a navigation target of its own:
50
+ * a guarded screen reached before sign-in renders signed out. An attempt that ends without a
51
+ * sign-in (`Blocked`, `Failed`, `Gesture`) discards the parked screen again. A caller with no
52
+ * {@link navigate} leaves the document at once, so its parked screen is best-effort only.
53
+ */
56
54
  target?: string
57
55
  }
58
56
 
57
+ export interface LogoutRequest {
58
+ /** Where a surrogate logout runs — a resolved surrogate path. */
59
+ url: string
60
+ /** The in-app continuation once the local session is gone. */
61
+ navigate?: () => void | Promise<void>
62
+ }
63
+
59
64
  /**
60
65
  * A pluggable login mechanic — WHERE the authorization round trip runs.
61
66
  *
@@ -95,14 +100,161 @@ export interface LoginPlugin {
95
100
 
96
101
  /** A bearer token was issued in this document — decide where it goes. */
97
102
  complete: (ctx: LoginContext, token: string, env: LoginEnv) => Promise<LoginOutcome>
103
+
104
+ /**
105
+ * This document ALREADY holds a session — decide whether it is useful here.
106
+ *
107
+ * Absent means `Passed`: keep it and carry on, which is what an ordinary tab has always done.
108
+ * A surrogate hands it back to its opener instead, which is the whole point: a popup that
109
+ * discovers an existing session must sign the framed application in, not display the
110
+ * application to itself.
111
+ *
112
+ * Deliberately not `complete`: "a token was just issued here" and "a token was already here"
113
+ * are different facts, and a silent-refresh plugin will need to tell them apart.
114
+ */
115
+ resume?: (ctx: LoginContext, token: string, env: LoginEnv) => Promise<LoginOutcome>
116
+
117
+ /**
118
+ * Start logout from a user gesture.
119
+ *
120
+ * MUST open any window synchronously, for exactly the reason `begin` must — logging out of a
121
+ * framed application opens a window too. Implement as a NON-async function.
122
+ */
123
+ logout?: (ctx: LoginContext, request: LogoutRequest, env: LoginEnv) => Promise<LoginOutcome>
124
+
125
+ /** The local session is gone in this document — decide what to tell whom. */
126
+ logoutComplete?: (ctx: LoginContext, env: LoginEnv) => Promise<LoginOutcome>
127
+ }
128
+
129
+ /**
130
+ * Something that must be true before a login flow may start.
131
+ *
132
+ * SYNCHRONOUS on purpose. `begin` must not cross a microtask boundary before a plugin's
133
+ * `window.open`, or the popup blocker eats the window. A precondition that has to ask a server
134
+ * belongs somewhere else entirely.
135
+ *
136
+ * Returning false stops the flow and resolves `begin` as {@link LoginOutcome.Gesture} — which
137
+ * already means "cannot proceed without a fresh user gesture; render a control", and is exactly
138
+ * the state a user is in after a blocking dialog has opened over the page.
139
+ */
140
+ export interface LoginPrecondition {
141
+ alias: string
142
+ /** Higher runs first. Defaults to 0. */
143
+ priority?: number
144
+ check: (ctx: LoginContext, request: LoginRequest, env: LoginEnv) => boolean
145
+ }
146
+
147
+ /** What a source needs in order to describe the methods it offers. */
148
+ export interface LoginMethodContext {
149
+ context: LoginContext
150
+ env: LoginEnv
151
+ /** In-app navigation, when a component supplied it. */
152
+ navigate?: (alias: string, params?: Record<string, string>) => void | Promise<void>
153
+ }
154
+
155
+ /**
156
+ * One way to sign in, as the screen renders it.
157
+ *
158
+ * `start` is what a button calls. It MUST be callable synchronously from a click — branch on
159
+ * `env.embedded && !env.surrogate` first and open any window before the first `await`.
160
+ */
161
+ export interface LoginMethod {
162
+ id: string
163
+ /** Set when an `AuthenticationPlugin` drives this method. */
164
+ type?: string
165
+ label?: string
166
+ i18nKey?: string
167
+ icon?: string
168
+ order?: number
169
+ emphasis?: LoginMethodEmphasis
170
+ restricted?: boolean
171
+ params?: Record<string, string>
172
+ start: (ctx: LoginMethodContext) => Promise<LoginOutcome>
173
+ }
174
+
175
+ export interface LoginMethodSource {
176
+ alias: string
177
+ list: (ctx: LoginMethodContext) => LoginMethod[]
98
178
  }
99
179
 
180
+ /** What every rendering of the sign-in screen accepts, whatever its UI family. */
181
+ export interface LoginScreenProps {
182
+ /** The one thing a consuming application is expected to supply. */
183
+ Logo?: ComponentType<{ className?: string }> | ReactNode
184
+ title?: ReactNode
185
+ subtitle?: ReactNode
186
+ /**
187
+ * `(key, defaultValue) => string`. A prop, never an implicit context read: a component that
188
+ * reaches for an i18n provider crashes the whole render in an app mounted without one.
189
+ */
190
+ translate?: (key: string, defaultValue: string) => string
191
+ /**
192
+ * The current language, for `Intl.ListFormat`, locale-keyed document labels and explicit hrefs.
193
+ *
194
+ * A prop for the same reason `translate` is: a component that reaches for an i18n context
195
+ * directly crashes the whole render in an app mounted without one.
196
+ */
197
+ locale?: string
198
+ /** Replace or reorder what the resolver produced. */
199
+ methods?: LoginMethod[] | ((methods: LoginMethod[]) => LoginMethod[])
200
+ terms?: LoginTermsConfig | false
201
+ config?: LoginScreenConfig
202
+ /** Replaces the composed credit line entirely. */
203
+ footer?: ReactNode
204
+ className?: string
205
+ containerClassName?: string
206
+ /**
207
+ * Inline overrides for the screen's outer box.
208
+ *
209
+ * It exists because the outer box carries its viewport height inline rather than as a utility
210
+ * class, and an escape hatch that a class can no longer provide has to be provided some other
211
+ * way. See the note on the screen itself.
212
+ */
213
+ style?: CSSProperties
214
+ }
215
+
216
+ export type LoginScreenComponent = ComponentType<LoginScreenProps>
217
+
218
+ /**
219
+ * A way to surface a `begin`/`logout` outcome that has no inline screen to render it on.
220
+ *
221
+ * A header "Log in"/"Log out" control (`useLogin`/`useLogout`) fires the facade and forgets the
222
+ * result — it renders nothing of its own, unlike the sign-in screen, which already shows
223
+ * `loginAttemptError` inline. Registering one is how a UI package (`web-panel`, `mui-panel`) gives
224
+ * that control a way to speak — e.g. a toast when {@link LoginOutcome.Blocked} fires. Unregistered,
225
+ * it is silence, which is what a non-DOM host and a screen-mounted flow both already have.
226
+ */
227
+ export interface LoginNotifier { (outcome: LoginOutcome, env: LoginEnv): void }
228
+
100
229
  export interface LoginService extends LazyService {
101
230
  registerPlugin: (plugin: LoginPlugin) => void
102
231
  /** Select the active plugin for the given (or default) environment. */
103
232
  plugin: (env?: LoginEnv) => LoginPlugin
104
233
  /** The environment the cascade is currently selecting on. */
105
234
  env: () => LoginEnv
235
+ /** Something that must hold before any flow starts. Checked synchronously, in `begin`. */
236
+ registerPrecondition: (precondition: LoginPrecondition) => void
237
+ /** A source of offerable sign-in methods, scoped to this context. */
238
+ registerMethodSource: (source: LoginMethodSource) => void
239
+ methods: (ctx: LoginMethodContext) => LoginMethod[]
240
+ /**
241
+ * The screen a dispatcher renders when it cannot proceed.
242
+ *
243
+ * A slot rather than an import, because a relying party (`web-oidc-rp`) must never depend on a
244
+ * UI family (`web-panel` / `mui-panel`) — that edge would force every relying party to pick one.
245
+ */
246
+ registerScreen: (screen: LoginScreenComponent) => void
247
+ screen: () => LoginScreenComponent | null
248
+ /** See {@link LoginNotifier}. Replaces any previously registered notifier. */
249
+ registerNotifier: (notifier: LoginNotifier) => void
250
+ /** Register a post-login step. Replace-by-alias, priority-sorted (higher first). */
251
+ registerStep: (step: LoginStep) => void
252
+ /** Every registered step, priority-sorted (higher first). */
253
+ steps: () => LoginStep[]
254
+ /** Register a hook that observes a freshly authenticated token. Replace-by-alias, priority-sorted. */
255
+ onLanded: (hook: LoginLandingHook) => void
256
+ /** Every registered landing hook, priority-sorted (higher first). */
257
+ landingHooks: () => LoginLandingHook[]
106
258
  // Facade — every method re-selects the plugin and delegates.
107
259
  // NOTE: these stay plain writable instance properties, never getters, so that alternative
108
260
  // implementations (e.g. a native login service) can monkey-patch them directly.
@@ -110,8 +262,72 @@ export interface LoginService extends LazyService {
110
262
  begin: (request: LoginRequest) => Promise<LoginOutcome>
111
263
  authorize: (url: string) => Promise<LoginOutcome>
112
264
  complete: (token: string) => Promise<LoginOutcome>
265
+ resume: (token: string) => Promise<LoginOutcome>
266
+ logout: (request: LogoutRequest) => Promise<LoginOutcome>
267
+ logoutComplete: () => Promise<LoginOutcome>
113
268
  /** Adopt an issued bearer token as this context's authentication. */
114
269
  adopt: (token: string) => Promise<void>
270
+ /** Drop this document's authentication. The single de-adoption path. */
271
+ revoke: () => Promise<void>
272
+ }
273
+
274
+ /**
275
+ * The value shape a landing's `params`/`query` carries.
276
+ *
277
+ * Matches `FlowPayload` (`@owlmeans/flow`) and the `AbstractRequest` params/query fields
278
+ * (`@owlmeans/entrypoint`) structurally, rather than the narrower `Record<string, string>`, so a
279
+ * `LoginLanding` can be built directly from either a `SuspendedLandingRecord`'s query or a
280
+ * dispatcher's own forwarded `params`/`query` without a lossy re-cast at every call site.
281
+ */
282
+ export type LoginLandingParams = Record<string, string | number | boolean | undefined | null>
283
+
284
+ /**
285
+ * A step to run after sign-in completes, before the ordinary landing.
286
+ *
287
+ * Registered by a package that must collect something from a freshly authenticated user before
288
+ * the application resumes wherever it was headed — the marketing-consent screen is the first
289
+ * consumer. `landAfterLogin`/`continueLogin` (./land.js) walk the registered steps in priority
290
+ * order and land on the first one whose `pending` resolves `true`.
291
+ */
292
+ export interface LoginStep {
293
+ alias: string
294
+ /** Higher runs first. Defaults to 0. */
295
+ priority?: number
296
+ /** Entrypoint alias to navigate to while this step is pending. */
297
+ entrypoint: string
298
+ /** Extra query parameters for the landing, alongside the step's own alias. */
299
+ query?: (ctx: LoginContext) => LoginLandingParams | undefined
300
+ /** Whether this step still has something to collect from the signed-in user. */
301
+ pending: (ctx: LoginContext) => Promise<boolean>
302
+ /**
303
+ * This step is where the Terms confirmation lives instead of on the sign-in screen.
304
+ *
305
+ * `termsDeferred(ctx)` (`./terms.js`) is true only while a step with this flag is both
306
+ * registered AND bound (`ctx.hasEntrypoint(step.entrypoint)`) — an app that registers the step
307
+ * but never binds its screen keeps the sign-in checkbox, fail-closed.
308
+ */
309
+ confirmsTerms?: boolean
310
+ /**
311
+ * A `pending` that throws or times out counts as PENDING rather than "not pending".
312
+ *
313
+ * The default (`undefined`/`false`) keeps every existing step fail-open, exactly as before —
314
+ * this flag is for a step whose whole point is to gate the landing on something the person must
315
+ * do (Terms confirmation), where a broken read must show the step rather than let it through.
316
+ */
317
+ required?: boolean
318
+ }
319
+
320
+ /**
321
+ * Observes that a freshly authenticated token has landed — unlike {@link LoginStep}, it never
322
+ * blocks the landing, it only reacts to it, once per distinct token.
323
+ *
324
+ * MUST never throw uncaught: the caller (`landAfterLogin`) bounds and swallows every hook's
325
+ * failure, on a timeout as well as a rejection, so a broken hook can never block sign-in.
326
+ */
327
+ export interface LoginLandingHook {
328
+ alias: string
329
+ priority?: number
330
+ landed: (ctx: LoginContext) => Promise<void>
115
331
  }
116
332
 
117
333
  export interface LoginServiceAppend {