@owlmeans/client-auth 0.1.18-rc.8 → 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 (259) 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 +85 -23
  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.d.ts.map +1 -1
  146. package/build/manager/components/authentication/control.js +3 -3
  147. package/build/manager/components/authentication/control.js.map +1 -1
  148. package/build/manager/components/authentication/types.d.ts +2 -2
  149. package/build/manager/components/authentication/types.d.ts.map +1 -1
  150. package/build/manager/components/tunnel-consumer.js +2 -2
  151. package/build/manager/components/tunnel-consumer.js.map +1 -1
  152. package/build/manager/entrypoints.d.ts +11 -0
  153. package/build/manager/entrypoints.d.ts.map +1 -0
  154. package/build/manager/entrypoints.js +17 -0
  155. package/build/manager/entrypoints.js.map +1 -0
  156. package/build/manager/errors.d.ts.map +1 -1
  157. package/build/manager/errors.js.map +1 -1
  158. package/build/manager/plugins/basic-ed25519.d.ts.map +1 -1
  159. package/build/manager/plugins/basic-ed25519.js +5 -0
  160. package/build/manager/plugins/basic-ed25519.js.map +1 -1
  161. package/build/manager/plugins/exports.d.ts +3 -0
  162. package/build/manager/plugins/exports.d.ts.map +1 -1
  163. package/build/manager/plugins/exports.js +2 -0
  164. package/build/manager/plugins/exports.js.map +1 -1
  165. package/build/manager/plugins/index.d.ts +2 -5
  166. package/build/manager/plugins/index.d.ts.map +1 -1
  167. package/build/manager/plugins/index.js +9 -1
  168. package/build/manager/plugins/index.js.map +1 -1
  169. package/build/manager/plugins/methods.d.ts +6 -0
  170. package/build/manager/plugins/methods.d.ts.map +1 -0
  171. package/build/manager/plugins/methods.js +65 -0
  172. package/build/manager/plugins/methods.js.map +1 -0
  173. package/build/manager/plugins/re-captcha.d.ts.map +1 -1
  174. package/build/manager/plugins/re-captcha.js +5 -0
  175. package/build/manager/plugins/re-captcha.js.map +1 -1
  176. package/build/manager/plugins/registry/types.d.ts +10 -0
  177. package/build/manager/plugins/registry/types.d.ts.map +1 -0
  178. package/build/manager/plugins/registry/types.js +2 -0
  179. package/build/manager/plugins/registry/types.js.map +1 -0
  180. package/build/manager/plugins/registry.d.ts +20 -0
  181. package/build/manager/plugins/registry.d.ts.map +1 -0
  182. package/build/manager/plugins/registry.js +23 -0
  183. package/build/manager/plugins/registry.js.map +1 -0
  184. package/build/manager/plugins/tunnel/wallet.d.ts.map +1 -1
  185. package/build/manager/plugins/tunnel-consumer.d.ts.map +1 -1
  186. package/build/manager/plugins/tunnel-consumer.js +10 -3
  187. package/build/manager/plugins/tunnel-consumer.js.map +1 -1
  188. package/build/manager/plugins/types.d.ts +53 -0
  189. package/build/manager/plugins/types.d.ts.map +1 -1
  190. package/build/service.d.ts.map +1 -1
  191. package/build/service.js +1 -1
  192. package/build/service.js.map +1 -1
  193. package/package.json +33 -30
  194. package/src/components/dispatcher/component.tsx +37 -14
  195. package/src/components/dispatcher/types.local.ts +7 -0
  196. package/src/entrypoints.ts +10 -0
  197. package/src/helper.ts +31 -5
  198. package/src/index.ts +1 -1
  199. package/src/login/adopt/types.ts +25 -0
  200. package/src/login/adopt.ts +21 -14
  201. package/src/login/consts.local.ts +34 -0
  202. package/src/login/consts.ts +105 -5
  203. package/src/login/credit/types.ts +6 -0
  204. package/src/login/credit.ts +69 -0
  205. package/src/login/env/types.ts +35 -0
  206. package/src/login/env.ts +60 -72
  207. package/src/login/flow.ts +44 -0
  208. package/src/login/hook.ts +58 -22
  209. package/src/login/i18n/be.json +41 -0
  210. package/src/login/i18n/de.json +41 -0
  211. package/src/login/i18n/en.json +41 -0
  212. package/src/login/i18n/es.json +41 -0
  213. package/src/login/i18n/fr.json +41 -0
  214. package/src/login/i18n/pl.json +41 -0
  215. package/src/login/i18n/ru.json +41 -0
  216. package/src/login/i18n/uk.json +41 -0
  217. package/src/login/i18n.ts +29 -0
  218. package/src/login/index.ts +21 -0
  219. package/src/login/land/types.ts +54 -0
  220. package/src/login/land.ts +149 -0
  221. package/src/login/methods/types.ts +32 -0
  222. package/src/login/methods.ts +109 -0
  223. package/src/login/resume/types.ts +24 -0
  224. package/src/login/resume.ts +45 -0
  225. package/src/login/screen.tsx +140 -0
  226. package/src/login/service.ts +181 -18
  227. package/src/login/start/types.ts +30 -0
  228. package/src/login/start.ts +44 -0
  229. package/src/login/surrogate/types.ts +9 -0
  230. package/src/login/surrogate.ts +37 -0
  231. package/src/login/terms/types.ts +126 -0
  232. package/src/login/terms-config.ts +55 -0
  233. package/src/login/terms.ts +315 -0
  234. package/src/login/types.ts +233 -17
  235. package/src/manager/README.md +37 -80
  236. package/src/manager/components/authentication/control.ts +3 -3
  237. package/src/manager/components/authentication/types.ts +2 -2
  238. package/src/manager/components/tunnel-consumer.tsx +2 -2
  239. package/src/manager/entrypoints.ts +18 -0
  240. package/src/manager/plugins/basic-ed25519.tsx +7 -0
  241. package/src/manager/plugins/exports.ts +3 -0
  242. package/src/manager/plugins/index.ts +11 -4
  243. package/src/manager/plugins/methods.ts +74 -0
  244. package/src/manager/plugins/re-captcha.tsx +7 -0
  245. package/src/manager/plugins/registry/types.ts +10 -0
  246. package/src/manager/plugins/registry.ts +33 -0
  247. package/src/manager/plugins/tunnel-consumer.tsx +12 -3
  248. package/src/manager/plugins/types.ts +54 -0
  249. package/src/service.ts +1 -1
  250. package/tests/context.ts +1 -2
  251. package/tests/i18n.spec.ts +68 -0
  252. package/tests/land.spec.ts +292 -0
  253. package/tests/login.spec.ts +332 -0
  254. package/tests/target.spec.ts +210 -0
  255. package/tests/terms-deferred.spec.ts +65 -0
  256. package/tests/terms.spec.ts +314 -0
  257. package/build/.gitkeep +0 -0
  258. package/src/manager/modules.ts +0 -18
  259. package/src/modules.ts +0 -13
@@ -0,0 +1,29 @@
1
+
2
+
3
+ import en from './i18n/en.json' with { type: 'json' }
4
+ import pl from './i18n/pl.json' with { type: 'json' }
5
+ import ru from './i18n/ru.json' with { type: 'json' }
6
+ import be from './i18n/be.json' with { type: 'json' }
7
+ import uk from './i18n/uk.json' with { type: 'json' }
8
+ import es from './i18n/es.json' with { type: 'json' }
9
+ import de from './i18n/de.json' with { type: 'json' }
10
+ import fr from './i18n/fr.json' with { type: 'json' }
11
+ import { i18nHelper } from '@owlmeans/i18n'
12
+
13
+ /**
14
+ * The sign-in screen's copy, registered into the `auth` library resource.
15
+ *
16
+ * `_addI18n` PUSHES rather than replaces, so this coexists with `@owlmeans/web-client`'s own
17
+ * `auth` registration (which owns `dispatcher.*` and `surrogate.*`) and the two merge by tier and
18
+ * priority. Registering here rather than in a UI package is what lets the plain fallback screen
19
+ * and every styled one read the same keys — so the seven-language rule holds on every path a user
20
+ * can actually reach, not only the pretty one.
21
+ */
22
+ i18nHelper.addI18nLib('en', 'auth', en)
23
+ i18nHelper.addI18nLib('pl', 'auth', pl)
24
+ i18nHelper.addI18nLib('ru', 'auth', ru)
25
+ i18nHelper.addI18nLib('be', 'auth', be)
26
+ i18nHelper.addI18nLib('uk', 'auth', uk)
27
+ i18nHelper.addI18nLib('es', 'auth', es)
28
+ i18nHelper.addI18nLib('de', 'auth', de)
29
+ i18nHelper.addI18nLib('fr', 'auth', fr)
@@ -1,6 +1,27 @@
1
+ import './i18n.js'
2
+
1
3
  export * from './consts.js'
2
4
  export * from './types.js'
5
+ export type * from './env/types.js'
6
+ export type * from './adopt/types.js'
7
+ export type * from './methods/types.js'
8
+ export type * from './resume/types.js'
9
+ export type * from './terms/types.js'
10
+ export type * from './credit/types.js'
11
+ export type * from './surrogate/types.js'
12
+ export type * from './land/types.js'
13
+ export type * from './start/types.js'
3
14
  export * from './env.js'
4
15
  export * from './adopt.js'
16
+ export * from './methods.js'
17
+ export * from './flow.js'
18
+ export * from './resume.js'
19
+ export * from './terms-config.js'
20
+ export * from './terms.js'
21
+ export * from './credit.js'
22
+ export * from './surrogate.js'
23
+ export * from './screen.js'
5
24
  export * from './service.js'
25
+ export * from './start.js'
6
26
  export * from './hook.js'
27
+ export * from './land.js'
@@ -0,0 +1,54 @@
1
+ import type { LoginLandingParams } from '../types.js'
2
+
3
+ /** Where a finished sign-in (or a pending step) lands. */
4
+ export interface LoginLanding {
5
+ alias: string
6
+ params?: LoginLandingParams
7
+ query?: LoginLandingParams
8
+ /** The step alias this landing came from, when it came from one. */
9
+ step?: string
10
+ }
11
+
12
+ /** Options for {@link LoginService.landAfterLogin}-shaped helpers (./land.js). */
13
+ export interface LandOptions {
14
+ /** Where to land when nothing else claims the flow. Defaults to `{ alias: HOME }`. */
15
+ fallback?: LoginLanding
16
+ /** `false` skips `resumeSuspendedFlow` — the caller already has its own concrete destination. */
17
+ resume?: boolean
18
+ /** Skip every step up to and including this alias — the resume point for a step's re-entry. */
19
+ after?: string
20
+ /**
21
+ * Overrides the step/hook timeout for this call. Mainly for tests; production callers leave it
22
+ * at the default (`LOGIN_STEP_TIMEOUT`).
23
+ */
24
+ stepTimeout?: number
25
+ }
26
+
27
+ /** Where one context's finished sign-in goes: its pending steps, a suspended flow, or the fallback. */
28
+ export interface LoginLandingHelper {
29
+ /**
30
+ * Where a finished sign-in should go: the first pending step, else a suspended flow, else the
31
+ * fallback (ordinarily `HOME`).
32
+ *
33
+ * Re-checks `env().surrogate` even though {@link landAfterLogin} already did — this function is
34
+ * also called directly (e.g. from `useContinueLogin`, after the surrogate window has already
35
+ * closed), and the check is cheap insurance either way: **no step and no landing hook ever runs
36
+ * inside the surrogate popup.** It has nothing of its own to collect and nowhere to navigate —
37
+ * the popup's own `DispatcherHOC` has no `navigate() → HOME` at all.
38
+ */
39
+ continueLogin: (opts?: LandOptions) => Promise<LoginLanding>
40
+ /**
41
+ * The whole post-sign-in landing decision: run any due landing hooks for a freshly seen token,
42
+ * then delegate to {@link continueLogin} for where the flow actually goes.
43
+ *
44
+ * This is what a plugin that just adopted a token calls in place of the old inline
45
+ * "`resumeSuspendedFlow` else `HOME`" — `DispatcherHOC`, the supervisor plugin and both Google
46
+ * plugins all replace their own copy of that logic with this one.
47
+ */
48
+ landAfterLogin: (opts?: LandOptions) => Promise<LoginLanding>
49
+ /**
50
+ * The absolute URL a landing resolves to — the same shape the supervisor and Google plugins
51
+ * already build their own `window.location.href` from.
52
+ */
53
+ landingUrl: (landing: LoginLanding) => Promise<string>
54
+ }
@@ -0,0 +1,149 @@
1
+ import type { AuthService } from '@owlmeans/auth-common'
2
+ import { DISPATCHER } from '@owlmeans/auth'
3
+ import { HOME, memoHelper } from '@owlmeans/context'
4
+ import type { ClientContext } from '@owlmeans/client'
5
+ import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
6
+ import { DEFAULT_ALIAS as AUTH_ALIAS } from '../consts.js'
7
+ import { ensureLoginService } from './service.js'
8
+ import type { LoginContext, LoginService } from './types.js'
9
+ import type { LandOptions, LoginLanding, LoginLandingHelper } from './land/types.js'
10
+ import { LOGIN_LANDED_STORAGE, LOGIN_STEP_TIMEOUT } from './consts.js'
11
+ import { flowLandingOf } from '@owlmeans/client-flow'
12
+
13
+ /**
14
+ * Race a promise against a bound, resolving to `onTimeout` (never rejecting) if the bound wins.
15
+ *
16
+ * A synchronous throw from the wrapped call is turned into a rejection by the caller before this
17
+ * ever sees it, so the only two outcomes here are "settled in time" and "timed out". `onTimeout`
18
+ * defaults to `undefined` — every existing caller that omits it keeps its old fail-open meaning;
19
+ * `continueLogin` passes a step's own `required` flag through it so a step that hangs is read the
20
+ * same way as one that throws.
21
+ */
22
+ const withTimeout = async <T>(
23
+ promise: Promise<T>, ms: number, onTimeout?: T
24
+ ): Promise<T | undefined> => {
25
+ let timer: ReturnType<typeof setTimeout> | undefined
26
+ try {
27
+ return await Promise.race<T | undefined>([
28
+ promise,
29
+ new Promise<T | undefined>(resolve => { timer = setTimeout(() => resolve(onTimeout), ms) }),
30
+ ])
31
+ } finally {
32
+ if (timer != null) {
33
+ clearTimeout(timer)
34
+ }
35
+ }
36
+ }
37
+
38
+ export const makeLoginLandingHelper = (ctx: LoginContext): LoginLandingHelper => {
39
+ /**
40
+ * Run every registered landing hook, once per distinct token.
41
+ *
42
+ * The stored token is overwritten BEFORE the hooks run, not after: a hook that throws or hangs
43
+ * must never make the same token re-run the whole set on the next call, since surviving exactly
44
+ * that is what the bound-and-swallow contract is for. The raw token string is the marker rather
45
+ * than a digest of it — it never leaves the browser, and this package already keeps the terms
46
+ * acceptance marker the same way (`LOGIN_TERMS_STORAGE`).
47
+ */
48
+ const runLandingHooks = async (login: LoginService, timeout: number): Promise<void> => {
49
+ const auth = ctx.service<AuthService>(AUTH_ALIAS)
50
+ const token = await auth.authenticated()
51
+ if (token == null) {
52
+ return
53
+ }
54
+
55
+ let last: string | null = null
56
+ try {
57
+ last = window.localStorage.getItem(LOGIN_LANDED_STORAGE)
58
+ } catch {
59
+ // Storage can be unavailable (private modes, blocked cookies). Treated as "never landed",
60
+ // which just re-runs the hooks — the safe direction, same as the terms-acceptance marker.
61
+ }
62
+ if (last === token) {
63
+ return
64
+ }
65
+
66
+ try {
67
+ window.localStorage.setItem(LOGIN_LANDED_STORAGE, token)
68
+ } catch { /* nothing to remember if storage was never available */ }
69
+
70
+ for (const hook of login.landingHooks()) {
71
+ await withTimeout(Promise.resolve().then(() => hook.landed(ctx)), timeout).catch(() => undefined)
72
+ }
73
+ }
74
+
75
+ const continueLogin = async (opts?: LandOptions): Promise<LoginLanding> => {
76
+ const login = ensureLoginService(ctx)
77
+ if (login.env().surrogate) {
78
+ return { alias: DISPATCHER }
79
+ }
80
+
81
+ const timeout = opts?.stepTimeout ?? LOGIN_STEP_TIMEOUT
82
+ const auth = ctx.service<AuthService>(AUTH_ALIAS)
83
+ const token = await auth.authenticated()
84
+
85
+ if (token != null) {
86
+ const steps = login.steps()
87
+ let start = 0
88
+ if (opts?.after != null) {
89
+ const index = steps.findIndex(step => step.alias === opts.after)
90
+ if (index >= 0) {
91
+ start = index + 1
92
+ }
93
+ }
94
+
95
+ for (let i = start; i < steps.length; i++) {
96
+ const step = steps[i]
97
+ // Registration proves only that some module reached the bundle — the step's own screen may
98
+ // not be bound in this tree (an older target, a partial import). Skipped, never thrown.
99
+ if (!ctx.hasEntrypoint(step.entrypoint)) {
100
+ continue
101
+ }
102
+
103
+ // A step that throws or hangs fails OPEN ("not pending") by default — UNLESS it declared
104
+ // itself `required`, in which case a broken read must show the step rather than let the
105
+ // person through it unconfirmed (a Terms box moved here from the sign-in screen).
106
+ const onBroken = step.required === true
107
+ const pending = await withTimeout(
108
+ Promise.resolve().then(() => step.pending(ctx)), timeout, onBroken
109
+ ).catch(() => onBroken)
110
+
111
+ if (pending === true) {
112
+ return { alias: step.entrypoint, query: await step.query?.(ctx), step: step.alias }
113
+ }
114
+ }
115
+ }
116
+
117
+ if (opts?.resume !== false) {
118
+ const landing = await flowLandingOf(ctx as unknown as ClientContext).resumeSuspendedFlow()
119
+ if (landing != null) {
120
+ return { alias: landing.entrypoint, query: landing.query }
121
+ }
122
+ }
123
+
124
+ return opts?.fallback ?? { alias: HOME }
125
+ }
126
+
127
+ const landAfterLogin = async (opts?: LandOptions): Promise<LoginLanding> => {
128
+ const login = ensureLoginService(ctx)
129
+ // Never run a step or a landing hook while THIS document is the surrogate popup — see the note
130
+ // on `continueLogin`.
131
+ if (login.env().surrogate) {
132
+ return { alias: DISPATCHER }
133
+ }
134
+
135
+ await runLandingHooks(login, opts?.stepTimeout ?? LOGIN_STEP_TIMEOUT)
136
+
137
+ return continueLogin(opts)
138
+ }
139
+
140
+ const landingUrl = async (landing: LoginLanding): Promise<string> =>
141
+ await ctx.entrypoint<ClientEntrypoint<string>>(landing.alias).url(
142
+ { params: landing.params, query: landing.query }, { absolute: true }
143
+ )
144
+
145
+ return { continueLogin, landAfterLogin, landingUrl }
146
+ }
147
+
148
+ /** The landing helper of a context — one per context. */
149
+ export const loginLandingOf = memoHelper.oncePer(makeLoginLandingHelper)
@@ -0,0 +1,32 @@
1
+ import type { LoginScreenConfig } from '@owlmeans/config'
2
+ import type { LoginMethod, LoginMethodContext, LoginMethodSource } from '../types.js'
3
+
4
+ /** The sign-in methods on offer: the global source registry and the resolution of a screen's list. */
5
+ export interface LoginMethodsHelper {
6
+ /** Register a source globally — replacing an earlier one of the same alias. */
7
+ registerMethodSource: (source: LoginMethodSource) => void
8
+ /** The globally registered sources, in registration order. */
9
+ listMethodSources: () => LoginMethodSource[]
10
+ /**
11
+ * Everything the current environment may be offered, in the order it should be offered in.
12
+ *
13
+ * Resolution order matters and is not arbitrary:
14
+ *
15
+ * 1. Sources produce candidates. A source that throws is skipped rather than taking the screen
16
+ * down — a misconfigured provider list must not remove the sign-in method that does work.
17
+ * 2. `restricted` candidates are dropped unless the configuration named them, which is what makes
18
+ * an operator login opt-in rather than merely unadvertised.
19
+ * 3. An explicit `methods` list is both a filter AND the order, because an app that bothered to
20
+ * list them means that list.
21
+ */
22
+ resolveLoginMethods: (
23
+ ctx: LoginMethodContext, cfg?: LoginScreenConfig, extra?: LoginMethodSource[]
24
+ ) => LoginMethod[]
25
+ /**
26
+ * The method a screen highlights and focuses.
27
+ *
28
+ * The first `primary` one, else the first offered. It is highlighted, never started: requirement
29
+ * one of this whole feature is that nothing leaves the document without a click.
30
+ */
31
+ primaryLoginMethod: (methods: LoginMethod[]) => LoginMethod | null
32
+ }
@@ -0,0 +1,109 @@
1
+ import type { LoginMethodConfig, LoginScreenConfig } from '@owlmeans/config'
2
+ import { DEFAULT_METHOD_ORDER } from './consts.js'
3
+ import type { LoginMethod, LoginMethodContext, LoginMethodSource } from './types.js'
4
+ import type { LoginMethodsHelper } from './methods/types.js'
5
+ import { logger } from '@owlmeans/log'
6
+
7
+ const log = logger('client-auth:login')
8
+
9
+ /**
10
+ * The module-global registry of method SOURCES.
11
+ *
12
+ * A source, rather than a method, because the two things that offer sign-in methods answer
13
+ * different questions at different times: the plugin registry knows what code is loaded, and the
14
+ * OIDC configuration knows what an entity is federated with. Both are answered per render — a
15
+ * provider list can arrive from the api-config middleware after the first paint.
16
+ *
17
+ * Global, like the `AuthenticationPlugin` map it mirrors, because a package registers its source
18
+ * from a side-effect import that has no context to hang it on. A context-scoped registry exists
19
+ * alongside it on the login service, and both are merged by `resolveLoginMethods`.
20
+ */
21
+ const sources: LoginMethodSource[] = []
22
+
23
+ export const createLoginMethodsHelper = (): LoginMethodsHelper => {
24
+ const registerMethodSource = (source: LoginMethodSource): void => {
25
+ const existing = sources.findIndex(candidate => candidate.alias === source.alias)
26
+ if (existing >= 0) {
27
+ sources.splice(existing, 1)
28
+ }
29
+ sources.push(source)
30
+ }
31
+
32
+ const listMethodSources = (): LoginMethodSource[] => [...sources]
33
+
34
+ /** Normalize a config entry, which may be a bare id or a full override object. */
35
+ const asConfig = (entry: string | LoginMethodConfig): LoginMethodConfig =>
36
+ typeof entry === 'string' ? { id: entry } : entry
37
+
38
+ const merge = (method: LoginMethod, override: LoginMethodConfig | undefined): LoginMethod =>
39
+ override == null ? method : {
40
+ ...method,
41
+ ...(override.label != null ? { label: override.label } : {}),
42
+ ...(override.i18nKey != null ? { i18nKey: override.i18nKey } : {}),
43
+ ...(override.icon != null ? { icon: override.icon } : {}),
44
+ ...(override.order != null ? { order: override.order } : {}),
45
+ ...(override.emphasis != null ? { emphasis: override.emphasis } : {}),
46
+ ...(override.params != null ? { params: { ...method.params, ...override.params } } : {}),
47
+ }
48
+
49
+ const resolveLoginMethods = (
50
+ ctx: LoginMethodContext, cfg?: LoginScreenConfig, extra: LoginMethodSource[] = []
51
+ ): LoginMethod[] => {
52
+ const candidates: LoginMethod[] = []
53
+ for (const source of [...sources, ...extra]) {
54
+ try {
55
+ candidates.push(...source.list(ctx))
56
+ } catch (e) {
57
+ log.warn('Login method source failed', { source: source.alias, error: e })
58
+ }
59
+ }
60
+
61
+ const overrides = cfg?.overrides ?? {}
62
+ const listed = cfg?.methods?.map(asConfig)
63
+ const listedIds = listed?.map(entry => entry.id).filter((id): id is string => id != null)
64
+
65
+ const offered = candidates
66
+ .filter(method => {
67
+ const override = overrides[method.id] ?? listed?.find(entry => entry.id === method.id)
68
+ if (override?.enabled === false || override?.hidden === true) {
69
+ return false
70
+ }
71
+ // A restricted method is offered only where the configuration asked for it — by naming it in
72
+ // `methods`, or by enabling it in `overrides`. Registering the plugin is deliberately not
73
+ // enough: an operator login that appears wherever its code happens to be bundled is an
74
+ // operator login in production.
75
+ if (method.restricted === true
76
+ && listedIds?.includes(method.id) !== true
77
+ && override?.enabled !== true) {
78
+ return false
79
+ }
80
+ return true
81
+ })
82
+ .map(method => merge(method, overrides[method.id]))
83
+
84
+ if (listedIds != null) {
85
+ return listedIds
86
+ .map(id => {
87
+ const method = offered.find(candidate => candidate.id === id)
88
+ return method != null ? merge(method, listed?.find(entry => entry.id === id)) : null
89
+ })
90
+ .filter((method): method is LoginMethod => method != null)
91
+ }
92
+
93
+ return offered.sort((a, b) => {
94
+ const order = (a.order ?? DEFAULT_METHOD_ORDER) - (b.order ?? DEFAULT_METHOD_ORDER)
95
+ return order !== 0 ? order : a.id.localeCompare(b.id)
96
+ })
97
+ }
98
+
99
+ const primaryLoginMethod = (methods: LoginMethod[]): LoginMethod | null =>
100
+ methods.find(method => method.emphasis === 'primary') ?? methods[0] ?? null
101
+
102
+ return { registerMethodSource, listMethodSources, resolveLoginMethods, primaryLoginMethod }
103
+ }
104
+
105
+ export const loginMethodsHelper = createLoginMethodsHelper()
106
+
107
+ /** @deprecated compat:factory-refactor — use `loginMethodsHelper.primaryLoginMethod(…)` */
108
+ export const primaryLoginMethod = (methods: LoginMethod[]): LoginMethod | null =>
109
+ loginMethodsHelper.primaryLoginMethod(methods)
@@ -0,0 +1,24 @@
1
+ import type { LoginOutcome, ResumeAction } from '../consts.js'
2
+
3
+ /** How a settled sign-in outcome is read: by a screen that stayed put, and by a resuming dispatcher. */
4
+ export interface LoginResumeHelper {
5
+ /**
6
+ * The message key a FINISHED sign-in attempt should show, or null when it actually went somewhere.
7
+ *
8
+ * A dispatcher may treat `Passed` as "carry on with the ordinary continuation", because it has one.
9
+ * A screen does not: the user clicked, the document did not move, and nothing rendered — which is
10
+ * indistinguishable from a broken button, and is exactly how a null authorization URL used to
11
+ * present itself. Every outcome that leaves the user looking at the same screen therefore has to
12
+ * say something.
13
+ */
14
+ loginAttemptError: (outcome: LoginOutcome | null) => string | null
15
+ /**
16
+ * The one reading of a `resume` outcome, shared by every dispatcher.
17
+ *
18
+ * Shared because three dispatchers — `web-client`, `web-oidc-rp` and `mui-oidc-rp` — have to agree
19
+ * on it, and the last time they each held their own copy of a decision they drifted, which is how a
20
+ * popup came to render the application inside itself in two packages at once. It is also the whole
21
+ * rule, so it is testable without a DOM.
22
+ */
23
+ resumeAction: (outcome: LoginOutcome) => ResumeAction
24
+ }
@@ -0,0 +1,45 @@
1
+ import { LoginOutcome, ResumeAction } from './consts.js'
2
+ import type { LoginResumeHelper } from './resume/types.js'
3
+
4
+ export const createLoginResumeHelper = (): LoginResumeHelper => {
5
+ const loginAttemptError = (outcome: LoginOutcome | null): string | null => {
6
+ switch (outcome) {
7
+ case LoginOutcome.Gesture:
8
+ case LoginOutcome.Blocked:
9
+ // The window never opened. That is the popup blocker, and it has its own copy.
10
+ return 'login.error.blocked'
11
+ case LoginOutcome.Failed:
12
+ case LoginOutcome.Passed:
13
+ return 'login.error.failed'
14
+ default:
15
+ // Handled, Redirected and Orphaned all did something; Orphaned reports itself elsewhere.
16
+ return null
17
+ }
18
+ }
19
+
20
+ const resumeAction = (outcome: LoginOutcome): ResumeAction => {
21
+ switch (outcome) {
22
+ case LoginOutcome.Handled:
23
+ case LoginOutcome.Redirected:
24
+ return ResumeAction.Stop
25
+ case LoginOutcome.Orphaned:
26
+ case LoginOutcome.Failed:
27
+ case LoginOutcome.Gesture:
28
+ case LoginOutcome.Blocked:
29
+ return ResumeAction.Render
30
+ default:
31
+ return ResumeAction.Navigate
32
+ }
33
+ }
34
+
35
+ return { loginAttemptError, resumeAction }
36
+ }
37
+
38
+ export const loginResumeHelper = createLoginResumeHelper()
39
+
40
+ /** @deprecated compat:factory-refactor — use `loginResumeHelper.loginAttemptError(…)` */
41
+ export const loginAttemptError = (outcome: LoginOutcome | null): string | null =>
42
+ loginResumeHelper.loginAttemptError(outcome)
43
+
44
+ /** @deprecated compat:factory-refactor — use `loginResumeHelper.resumeAction(…)` */
45
+ export const resumeAction = (outcome: LoginOutcome): ResumeAction => loginResumeHelper.resumeAction(outcome)
@@ -0,0 +1,140 @@
1
+ import { useCallback, useState, type CSSProperties, type FC, type ReactNode } from 'react'
2
+ import { useContext } from '@owlmeans/client'
3
+ import type { CommonConfig } from '@owlmeans/config'
4
+ import type { LoginContext, LoginMethod, LoginScreenProps, LoginService } from './types.js'
5
+ import type { TermsSentencePart } from './terms/types.js'
6
+ import { LOGIN_SERVICE } from './consts.js'
7
+ import { loginMethodsHelper } from './methods.js'
8
+ import { loginTermsHelper } from './terms.js'
9
+ import { resolveCredit } from './credit.js'
10
+ import { box, page } from './consts.local.js'
11
+
12
+ const renderParts = (parts: TermsSentencePart[]): ReactNode =>
13
+ parts.map((part, index) => part.href != null
14
+ ? <a key={index} href={part.href} target="_blank" rel="noreferrer noopener">{part.text}</a>
15
+ : <span key={index}>{part.text}</span>)
16
+
17
+ const button = (emphasis?: string): CSSProperties => ({
18
+ display: 'block', width: '100%', marginTop: '.5rem', padding: '.625rem 1rem',
19
+ fontSize: '1rem', cursor: 'pointer', borderRadius: '.375rem',
20
+ border: '1px solid currentColor',
21
+ ...(emphasis === 'primary' ? { fontWeight: 600 } : {}),
22
+ })
23
+
24
+ /**
25
+ * The sign-in screen every application gets, whether or not it registered a styled one.
26
+ *
27
+ * It exists because a relying party must render SOMETHING rather than start a flow on its own, and
28
+ * a relying party may not depend on a UI family. So this is deliberately plain: raw elements and
29
+ * inline styles, no Tailwind class that a consumer's stylesheet would have to scan for, no shadcn
30
+ * primitive a consumer would have to vendor. An application that registers a real screen through
31
+ * `login().registerScreen(...)` never sees it.
32
+ *
33
+ * Because it is the floor rather than the design, it still has to be correct: it offers the same
34
+ * methods, enforces the same terms confirmation, centres the same way, and renders the same
35
+ * credit line.
36
+ */
37
+ export const FallbackLoginScreen: FC<LoginScreenProps> = props => {
38
+ const context = useContext() as unknown as LoginContext
39
+ const t = props.translate ?? ((_key: string, defaultValue: string) => defaultValue)
40
+ const cfg = props.config ?? (context.cfg as CommonConfig).security?.auth?.login
41
+ const brand = (context.cfg as CommonConfig).brand
42
+
43
+ const login = context.service<LoginService>(LOGIN_SERVICE)
44
+ const env = login.env()
45
+ const resolved = loginTermsHelper.resolveTerms(props.terms ?? cfg?.terms)
46
+ const credit = resolveCredit(cfg?.credit, brand, context.cfg.service)
47
+ const resolveLabel = loginTermsHelper.termsLabelResolver(t, props.locale)
48
+ const deferred = loginTermsHelper.termsDeferred(context)
49
+
50
+ const [accepted, setAccepted] = useState(() => loginTermsHelper.termsAccepted(resolved))
51
+ const [attempted, setAttempted] = useState(false)
52
+
53
+ const all = login.methods({ context, env })
54
+ const methods = typeof props.methods === 'function'
55
+ ? props.methods(all)
56
+ : props.methods ?? all
57
+ const primary = loginMethodsHelper.primaryLoginMethod(methods)
58
+
59
+ const blocked = resolved != null && resolved.required && !deferred && !accepted
60
+
61
+ const select = useCallback((method: LoginMethod) => {
62
+ if (blocked) {
63
+ setAttempted(true)
64
+ return
65
+ }
66
+ void method.start({ context, env })
67
+ }, [blocked, context, env])
68
+
69
+ const onAccept = useCallback((value: boolean) => {
70
+ setAccepted(value)
71
+ setAttempted(false)
72
+ loginTermsHelper.acceptTerms(resolved, value)
73
+ }, [resolved])
74
+
75
+ return <div data-login-screen style={page}><div style={box}>
76
+ <h1 style={{ fontSize: '1.25rem', marginBottom: '.25rem' }}>
77
+ {props.title ?? t('login.title', 'Sign in')}
78
+ </h1>
79
+ <p style={{ opacity: .75, marginTop: 0 }}>
80
+ {props.subtitle ?? t('login.subtitle', 'Choose how you would like to continue.')}
81
+ </p>
82
+
83
+ {methods.length < 1
84
+ ? <p role="status">{t('login.empty', 'No sign-in method is configured for this application.')}</p>
85
+ : methods.map(method => <button
86
+ key={method.id} type="button" data-login-method={method.id}
87
+ // `aria-disabled`, never `disabled`: a disabled button swallows the click, so a user who
88
+ // has not confirmed the terms would press it and be told nothing at all.
89
+ aria-disabled={blocked} data-blocked={blocked ? 'true' : undefined}
90
+ style={{ ...button(method.emphasis), opacity: blocked ? .6 : 1 }}
91
+ autoFocus={method.id === primary?.id}
92
+ onClick={() => select(method)}
93
+ >
94
+ {method.label ?? t(`login.method.${method.i18nKey ?? method.id}`, method.id)}
95
+ </button>)}
96
+
97
+ {/* When a registered, bound step confirms the terms instead, this screen shows neither the
98
+ checkbox nor its "last updated" line — that document is agreed to over there, and this
99
+ screen is never `blocked` on it. The privacy notice below still renders either way: it was
100
+ never inside the consented label, so deferring the checkbox does not touch it. */}
101
+ {resolved != null && !deferred && <p style={{ marginTop: '1rem', fontSize: '.875rem' }}>
102
+ {/* The ONLY place `data-login-terms` appears — never duplicated onto a second control. */}
103
+ <label>
104
+ <input
105
+ type="checkbox" checked={accepted} data-login-terms
106
+ onChange={event => onAccept(event.target.checked)}
107
+ />{' '}
108
+ {renderParts(loginTermsHelper.termsSentence(
109
+ t('login.terms.accept', 'I have read and agree to the {{documents}}.'),
110
+ resolved, props.locale, resolveLabel
111
+ ))}
112
+ </label>
113
+ </p>}
114
+
115
+ {resolved?.revisedAt != null && !deferred && <p data-login-revised style={{ fontSize: '.75rem', opacity: .7 }}>
116
+ {t('login.terms.revised', 'Last updated: {{date}}').split('{{date}}').join(resolved.revisedAt)}
117
+ </p>}
118
+
119
+ {/* Outside the checkbox's label: a privacy disclosure is not something it consents to. */}
120
+ {resolved != null && <p data-login-privacy style={{ fontSize: '.875rem' }}>
121
+ {renderParts(loginTermsHelper.termsSentence(
122
+ t('login.terms.notice', 'How we handle your personal data: {{notices}}.'),
123
+ resolved, props.locale, resolveLabel
124
+ ))}
125
+ </p>}
126
+
127
+ {attempted && blocked && resolved != null && <p role="alert" style={{ color: '#b00', fontSize: '.875rem' }}>
128
+ {renderParts(loginTermsHelper.termsSentence(
129
+ t('login.terms.required', 'Please confirm the {{documents}} to continue.'),
130
+ resolved, props.locale, resolveLabel
131
+ ))}
132
+ </p>}
133
+
134
+ {props.footer ?? <p style={{ marginTop: '2rem', fontSize: '.75rem', opacity: .7 }}>
135
+ {credit.poweredBy && <span>{t('login.credit.powered', 'Powered by OwlMeans')}</span>}
136
+ {credit.poweredBy && credit.line != null && ' · '}
137
+ {credit.line}
138
+ </p>}
139
+ </div></div>
140
+ }