@owlmeans/web-auth-email 0.0.0-stage → 0.1.19

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 (263) hide show
  1. package/README.md +116 -2
  2. package/THIRD-PARTY-NOTICES.txt +24 -0
  3. package/agent-meta/manifest.json +16 -0
  4. package/agent-meta/skills/web-auth-email/SKILL.md +119 -0
  5. package/build/@/components/ui/alert.d.ts +10 -0
  6. package/build/@/components/ui/alert.d.ts.map +1 -0
  7. package/build/@/components/ui/alert.js +26 -0
  8. package/build/@/components/ui/alert.js.map +1 -0
  9. package/build/@/components/ui/button.d.ts +11 -0
  10. package/build/@/components/ui/button.d.ts.map +1 -0
  11. package/build/@/components/ui/button.js +32 -0
  12. package/build/@/components/ui/button.js.map +1 -0
  13. package/build/@/components/ui/card.d.ts +10 -0
  14. package/build/@/components/ui/card.d.ts.map +1 -0
  15. package/build/@/components/ui/card.js +25 -0
  16. package/build/@/components/ui/card.js.map +1 -0
  17. package/build/@/components/ui/input.d.ts +4 -0
  18. package/build/@/components/ui/input.d.ts.map +1 -0
  19. package/build/@/components/ui/input.js +7 -0
  20. package/build/@/components/ui/input.js.map +1 -0
  21. package/build/@/components/ui/label.d.ts +5 -0
  22. package/build/@/components/ui/label.d.ts.map +1 -0
  23. package/build/@/components/ui/label.js +8 -0
  24. package/build/@/components/ui/label.js.map +1 -0
  25. package/build/@/lib/utils.d.ts +3 -0
  26. package/build/@/lib/utils.d.ts.map +1 -0
  27. package/build/@/lib/utils.js +6 -0
  28. package/build/@/lib/utils.js.map +1 -0
  29. package/build/append.d.ts +23 -0
  30. package/build/append.d.ts.map +1 -0
  31. package/build/append.js +49 -0
  32. package/build/append.js.map +1 -0
  33. package/build/components/connected.d.ts +8 -0
  34. package/build/components/connected.d.ts.map +1 -0
  35. package/build/components/connected.js +16 -0
  36. package/build/components/connected.js.map +1 -0
  37. package/build/components/credit.d.ts +5 -0
  38. package/build/components/credit.d.ts.map +1 -0
  39. package/build/components/credit.js +16 -0
  40. package/build/components/credit.js.map +1 -0
  41. package/build/components/fields.d.ts +23 -0
  42. package/build/components/fields.d.ts.map +1 -0
  43. package/build/components/fields.js +39 -0
  44. package/build/components/fields.js.map +1 -0
  45. package/build/components/index.d.ts +9 -0
  46. package/build/components/index.d.ts.map +1 -0
  47. package/build/components/index.js +8 -0
  48. package/build/components/index.js.map +1 -0
  49. package/build/components/offer.d.ts +9 -0
  50. package/build/components/offer.d.ts.map +1 -0
  51. package/build/components/offer.js +99 -0
  52. package/build/components/offer.js.map +1 -0
  53. package/build/components/panel.d.ts +12 -0
  54. package/build/components/panel.d.ts.map +1 -0
  55. package/build/components/panel.js +129 -0
  56. package/build/components/panel.js.map +1 -0
  57. package/build/components/route.d.ts +9 -0
  58. package/build/components/route.d.ts.map +1 -0
  59. package/build/components/route.js +17 -0
  60. package/build/components/route.js.map +1 -0
  61. package/build/components/screen.d.ts +17 -0
  62. package/build/components/screen.d.ts.map +1 -0
  63. package/build/components/screen.js +79 -0
  64. package/build/components/screen.js.map +1 -0
  65. package/build/components/types.d.ts +81 -0
  66. package/build/components/types.d.ts.map +1 -0
  67. package/build/components/types.js +2 -0
  68. package/build/components/types.js.map +1 -0
  69. package/build/consts.d.ts +37 -0
  70. package/build/consts.d.ts.map +1 -0
  71. package/build/consts.js +43 -0
  72. package/build/consts.js.map +1 -0
  73. package/build/entrypoints.d.ts +10 -0
  74. package/build/entrypoints.d.ts.map +1 -0
  75. package/build/entrypoints.js +14 -0
  76. package/build/entrypoints.js.map +1 -0
  77. package/build/hooks/consts.local.d.ts +4 -0
  78. package/build/hooks/consts.local.d.ts.map +1 -0
  79. package/build/hooks/consts.local.js +9 -0
  80. package/build/hooks/consts.local.js.map +1 -0
  81. package/build/hooks/types.d.ts +78 -0
  82. package/build/hooks/types.d.ts.map +1 -0
  83. package/build/hooks/types.js +2 -0
  84. package/build/hooks/types.js.map +1 -0
  85. package/build/hooks/use-email-login.d.ts +13 -0
  86. package/build/hooks/use-email-login.d.ts.map +1 -0
  87. package/build/hooks/use-email-login.js +208 -0
  88. package/build/hooks/use-email-login.js.map +1 -0
  89. package/build/hooks/use-password.d.ts +13 -0
  90. package/build/hooks/use-password.d.ts.map +1 -0
  91. package/build/hooks/use-password.js +118 -0
  92. package/build/hooks/use-password.js.map +1 -0
  93. package/build/hooks/use-translate.d.ts +7 -0
  94. package/build/hooks/use-translate.d.ts.map +1 -0
  95. package/build/hooks/use-translate.js +12 -0
  96. package/build/hooks/use-translate.js.map +1 -0
  97. package/build/i18n/auth/be.json +7 -0
  98. package/build/i18n/auth/de.json +7 -0
  99. package/build/i18n/auth/en.json +7 -0
  100. package/build/i18n/auth/es.json +7 -0
  101. package/build/i18n/auth/fr.json +7 -0
  102. package/build/i18n/auth/pl.json +7 -0
  103. package/build/i18n/auth/ru.json +7 -0
  104. package/build/i18n/auth/uk.json +7 -0
  105. package/build/i18n/be.json +100 -0
  106. package/build/i18n/de.json +100 -0
  107. package/build/i18n/en.json +100 -0
  108. package/build/i18n/es.json +100 -0
  109. package/build/i18n/fr.json +100 -0
  110. package/build/i18n/pl.json +100 -0
  111. package/build/i18n/ru.json +100 -0
  112. package/build/i18n/uk.json +100 -0
  113. package/build/i18n.d.ts +2 -0
  114. package/build/i18n.d.ts.map +1 -0
  115. package/build/i18n.js +40 -0
  116. package/build/i18n.js.map +1 -0
  117. package/build/index.d.ts +23 -0
  118. package/build/index.d.ts.map +1 -0
  119. package/build/index.js +17 -0
  120. package/build/index.js.map +1 -0
  121. package/build/password/types.d.ts +39 -0
  122. package/build/password/types.d.ts.map +1 -0
  123. package/build/password/types.js +2 -0
  124. package/build/password/types.js.map +1 -0
  125. package/build/password.d.ts +4 -0
  126. package/build/password.d.ts.map +1 -0
  127. package/build/password.js +45 -0
  128. package/build/password.js.map +1 -0
  129. package/build/plugin.d.ts +10 -0
  130. package/build/plugin.d.ts.map +1 -0
  131. package/build/plugin.js +21 -0
  132. package/build/plugin.js.map +1 -0
  133. package/build/refusal/consts.local.d.ts +3 -0
  134. package/build/refusal/consts.local.d.ts.map +1 -0
  135. package/build/refusal/consts.local.js +3 -0
  136. package/build/refusal/consts.local.js.map +1 -0
  137. package/build/refusal/types.d.ts +38 -0
  138. package/build/refusal/types.d.ts.map +1 -0
  139. package/build/refusal/types.js +2 -0
  140. package/build/refusal/types.js.map +1 -0
  141. package/build/refusal.d.ts +4 -0
  142. package/build/refusal.d.ts.map +1 -0
  143. package/build/refusal.js +131 -0
  144. package/build/refusal.js.map +1 -0
  145. package/build/service.d.ts +12 -0
  146. package/build/service.d.ts.map +1 -0
  147. package/build/service.js +66 -0
  148. package/build/service.js.map +1 -0
  149. package/build/state/types.d.ts +90 -0
  150. package/build/state/types.d.ts.map +1 -0
  151. package/build/state/types.js +2 -0
  152. package/build/state/types.js.map +1 -0
  153. package/build/state.d.ts +4 -0
  154. package/build/state.d.ts.map +1 -0
  155. package/build/state.js +98 -0
  156. package/build/state.js.map +1 -0
  157. package/build/step.d.ts +16 -0
  158. package/build/step.d.ts.map +1 -0
  159. package/build/step.js +31 -0
  160. package/build/step.js.map +1 -0
  161. package/build/storage/types.d.ts +42 -0
  162. package/build/storage/types.d.ts.map +1 -0
  163. package/build/storage/types.js +2 -0
  164. package/build/storage/types.js.map +1 -0
  165. package/build/storage.d.ts +4 -0
  166. package/build/storage.d.ts.map +1 -0
  167. package/build/storage.js +100 -0
  168. package/build/storage.js.map +1 -0
  169. package/build/types.d.ts +102 -0
  170. package/build/types.d.ts.map +1 -0
  171. package/build/types.js +2 -0
  172. package/build/types.js.map +1 -0
  173. package/build/utils/account.d.ts +4 -0
  174. package/build/utils/account.d.ts.map +1 -0
  175. package/build/utils/account.js +11 -0
  176. package/build/utils/account.js.map +1 -0
  177. package/build/utils/locale.d.ts +6 -0
  178. package/build/utils/locale.d.ts.map +1 -0
  179. package/build/utils/locale.js +10 -0
  180. package/build/utils/locale.js.map +1 -0
  181. package/build/utils/text/types.d.ts +23 -0
  182. package/build/utils/text/types.d.ts.map +1 -0
  183. package/build/utils/text/types.js +2 -0
  184. package/build/utils/text/types.js.map +1 -0
  185. package/build/utils/text.d.ts +4 -0
  186. package/build/utils/text.d.ts.map +1 -0
  187. package/build/utils/text.js +26 -0
  188. package/build/utils/text.js.map +1 -0
  189. package/build/utils.d.ts +8 -0
  190. package/build/utils.d.ts.map +1 -0
  191. package/build/utils.js +9 -0
  192. package/build/utils.js.map +1 -0
  193. package/components.json +21 -0
  194. package/package.json +71 -4
  195. package/src/@/components/ui/alert.tsx +70 -0
  196. package/src/@/components/ui/button.tsx +60 -0
  197. package/src/@/components/ui/card.tsx +93 -0
  198. package/src/@/components/ui/input.tsx +22 -0
  199. package/src/@/components/ui/label.tsx +23 -0
  200. package/src/@/globals.css +57 -0
  201. package/src/@/lib/utils.ts +6 -0
  202. package/src/append.ts +65 -0
  203. package/src/components/connected.tsx +36 -0
  204. package/src/components/credit.tsx +30 -0
  205. package/src/components/fields.tsx +112 -0
  206. package/src/components/index.ts +8 -0
  207. package/src/components/offer.tsx +197 -0
  208. package/src/components/panel.tsx +299 -0
  209. package/src/components/route.tsx +20 -0
  210. package/src/components/screen.tsx +253 -0
  211. package/src/components/types.ts +85 -0
  212. package/src/consts.ts +53 -0
  213. package/src/entrypoints.ts +15 -0
  214. package/src/hooks/consts.local.ts +12 -0
  215. package/src/hooks/types.ts +79 -0
  216. package/src/hooks/use-email-login.ts +234 -0
  217. package/src/hooks/use-password.ts +137 -0
  218. package/src/hooks/use-translate.ts +14 -0
  219. package/src/i18n/auth/be.json +7 -0
  220. package/src/i18n/auth/de.json +7 -0
  221. package/src/i18n/auth/en.json +7 -0
  222. package/src/i18n/auth/es.json +7 -0
  223. package/src/i18n/auth/fr.json +7 -0
  224. package/src/i18n/auth/pl.json +7 -0
  225. package/src/i18n/auth/ru.json +7 -0
  226. package/src/i18n/auth/uk.json +7 -0
  227. package/src/i18n/be.json +100 -0
  228. package/src/i18n/de.json +100 -0
  229. package/src/i18n/en.json +100 -0
  230. package/src/i18n/es.json +100 -0
  231. package/src/i18n/fr.json +100 -0
  232. package/src/i18n/pl.json +100 -0
  233. package/src/i18n/ru.json +100 -0
  234. package/src/i18n/uk.json +100 -0
  235. package/src/i18n.ts +43 -0
  236. package/src/index.ts +22 -0
  237. package/src/password/types.ts +43 -0
  238. package/src/password.ts +65 -0
  239. package/src/plugin.ts +22 -0
  240. package/src/refusal/consts.local.ts +2 -0
  241. package/src/refusal/types.ts +64 -0
  242. package/src/refusal.ts +145 -0
  243. package/src/service.ts +95 -0
  244. package/src/state/types.ts +72 -0
  245. package/src/state.ts +115 -0
  246. package/src/step.ts +38 -0
  247. package/src/storage/types.ts +43 -0
  248. package/src/storage.ts +113 -0
  249. package/src/types.ts +111 -0
  250. package/src/utils/account.ts +12 -0
  251. package/src/utils/locale.ts +11 -0
  252. package/src/utils/text/types.ts +24 -0
  253. package/src/utils/text.ts +34 -0
  254. package/src/utils.ts +9 -0
  255. package/tests/helpers.ts +22 -0
  256. package/tests/i18n.spec.ts +94 -0
  257. package/tests/package-boundary.spec.ts +35 -0
  258. package/tests/password.spec.ts +56 -0
  259. package/tests/refusal.spec.ts +91 -0
  260. package/tests/state.spec.ts +99 -0
  261. package/tests/step.spec.ts +82 -0
  262. package/tests/storage.spec.ts +83 -0
  263. package/tsconfig.json +21 -0
package/src/service.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { createLazyService } from '@owlmeans/context'
2
+ import type { AuthService } from '@owlmeans/auth-common'
3
+ import { DEFAULT_ALIAS as AUTH_SERVICE } from '@owlmeans/client-auth'
4
+ import { loginLandingOf } from '@owlmeans/client-auth/login'
5
+ import type { ClientConfig } from '@owlmeans/client-context'
6
+ import { EMAIL_LOGIN_SERVICE, EmailAuthError } from '@owlmeans/auth-email'
7
+ import type { EmailAuthEntrypoints, EmailAuthOptionsView } from '@owlmeans/auth-email'
8
+ import { emailStorageHelper } from './storage.js'
9
+ import { signedInAccount } from './utils/account.js'
10
+ import type {
11
+ EmailLoginClientContext, EmailLoginClientService, EmailLoginScreenComponent, MakeEmailLoginClientOptions,
12
+ } from './types.js'
13
+
14
+ /**
15
+ * Build the browser-side `EmailLoginClientService` over one protocol tree.
16
+ *
17
+ * Every call goes through `ctx.entrypoint(protocol)`, so the application binds the API routes
18
+ * itself (`bindAll`) — the same split as marketing consent's client.
19
+ */
20
+ export const makeEmailLoginClient = (
21
+ protocols: EmailAuthEntrypoints, opts: MakeEmailLoginClientOptions = {},
22
+ ): EmailLoginClientService => {
23
+ const alias = opts.alias ?? EMAIL_LOGIN_SERVICE
24
+
25
+ let options: EmailAuthOptionsView | null = null
26
+ let screen: EmailLoginScreenComponent | null = opts.screen ?? null
27
+
28
+ const ctx = (): EmailLoginClientContext =>
29
+ service.assertCtx<ClientConfig, EmailLoginClientContext>(alias)
30
+
31
+ const service: EmailLoginClientService = createLazyService<EmailLoginClientService>(alias, {
32
+ protocols: () => protocols,
33
+
34
+ options: async request => {
35
+ if (request?.fresh !== true && options != null) {
36
+ return options
37
+ }
38
+ options = await ctx().entrypoint(protocols.options).call({})
39
+
40
+ return options
41
+ },
42
+
43
+ sendPin: async request => await ctx().entrypoint(protocols.pin).call({ body: request }),
44
+
45
+ signIn: async request => await ctx().entrypoint(protocols.signIn).call({ body: request }),
46
+
47
+ complete: async view => {
48
+ if (view.token == null || view.token === '') {
49
+ throw new EmailAuthError('answer')
50
+ }
51
+ const context = ctx()
52
+ await context.service<AuthService>(AUTH_SERVICE).authenticate({ token: view.token })
53
+
54
+ // Kept AFTER the token is adopted, so the ticket names the account it belongs to and a sign-in
55
+ // that failed keeps nothing. Any older ticket is dropped by the sign-in that does not bring one.
56
+ if (view.offer != null && view.offer !== '') {
57
+ emailStorageHelper.keepOffer(view.offer, signedInAccount(context))
58
+ } else {
59
+ emailStorageHelper.dropOffer()
60
+ }
61
+
62
+ // The supervisor and Google plugins land the same way: a full load of the landing, which
63
+ // inside a surrogate window is the dispatcher that hands the session back to the opener.
64
+ const landings = loginLandingOf(context)
65
+ const landing = await landings.landAfterLogin()
66
+ window.location.href = await landings.landingUrl(landing)
67
+ },
68
+
69
+ passwordStatus: async () => await ctx().entrypoint(protocols.password.status).call({}),
70
+
71
+ passwordPin: async request => await ctx().entrypoint(protocols.password.pin).call({ body: request }),
72
+
73
+ setPassword: async request => await ctx().entrypoint(protocols.password.set).call({ body: request }),
74
+
75
+ removePassword: async request => await ctx().entrypoint(protocols.password.remove).call({ body: request }),
76
+
77
+ declinePassword: async () => await ctx().entrypoint(protocols.password.decline).call({ body: {} }),
78
+
79
+ screen: () => screen,
80
+
81
+ registerScreen: next => { screen = next },
82
+ })
83
+
84
+ return service
85
+ }
86
+
87
+ /** Register the client service, unless the application already registered its own under the alias. */
88
+ export const appendEmailLoginClient = (
89
+ context: EmailLoginClientContext, protocols: EmailAuthEntrypoints, opts: MakeEmailLoginClientOptions = {},
90
+ ): void => {
91
+ const alias = opts.alias ?? EMAIL_LOGIN_SERVICE
92
+ if (!context.hasService(alias)) {
93
+ context.registerService(makeEmailLoginClient(protocols, opts))
94
+ }
95
+ }
@@ -0,0 +1,72 @@
1
+ import type { EmailAuthOptionsView } from '@owlmeans/auth-email'
2
+ import type { EmailRefusal } from '../refusal/types.js'
3
+
4
+ /** Which part of the form is showing. `done` = signed in, the browser is leaving. */
5
+ export type EmailLoginStep = 'email' | 'pin' | 'password' | 'done'
6
+
7
+ /** The call in flight: mailing a PIN, or signing in (PIN or password). */
8
+ export type EmailLoginBusy = 'send' | 'sign-in'
9
+
10
+ /** Why the PIN step is up: a code was asked for, or a password sign-in met an unknown device. */
11
+ export type EmailLoginNotice = 'sent' | 'device'
12
+
13
+ /** The whole e-mail sign-in form, as a plain value. */
14
+ export interface EmailLoginState {
15
+ step: EmailLoginStep
16
+ email: string
17
+ pin: string
18
+ password: string
19
+ /** The password field shows what was typed. */
20
+ reveal: boolean
21
+ /** The signed challenge of the PIN mailed last; null until one is. */
22
+ challenge: string | null
23
+ notice: EmailLoginNotice | null
24
+ busy: EmailLoginBusy | null
25
+ refusal: EmailRefusal | null
26
+ /** What the deployment offers; null until `GET options` answered. */
27
+ options: EmailAuthOptionsView | null
28
+ /** The options read failed — the form still works, and the next send asks again. */
29
+ optionsFailed: boolean
30
+ }
31
+
32
+ /** Everything that changes the form. */
33
+ export type EmailLoginAction =
34
+ | { type: 'options', options: EmailAuthOptionsView }
35
+ | { type: 'options-failed' }
36
+ | { type: 'email', value: string }
37
+ | { type: 'pin', value: string }
38
+ | { type: 'password', value: string }
39
+ | { type: 'reveal', value: boolean }
40
+ | { type: 'choose-password' }
41
+ | { type: 'choose-pin' }
42
+ | { type: 'change-email' }
43
+ | { type: 'start', busy: EmailLoginBusy }
44
+ | { type: 'sent', challenge: string }
45
+ | { type: 'device', challenge: string }
46
+ | { type: 'signed-in' }
47
+ | { type: 'refused', refusal: EmailRefusal }
48
+
49
+ /**
50
+ * The e-mail sign-in form as pure transitions — what `useEmailLogin` runs through `useReducer`, and
51
+ * what a replacement screen or a test can drive without a DOM.
52
+ */
53
+ export interface EmailLoginStateHelper {
54
+ /** The empty form on its e-mail step. */
55
+ initial: (email?: string) => EmailLoginState
56
+ /** The next form after `action`. Never mutates `state`. */
57
+ reduce: (state: EmailLoginState, action: EmailLoginAction) => EmailLoginState
58
+ /** A plausible address: one `@`, a dotted domain, no spaces, within the wire bound. */
59
+ validEmail: (email: string) => boolean
60
+ /** The address as it is sent: trimmed and lowercased. */
61
+ normalizeEmail: (email: string) => string
62
+ /** Digits in a PIN — the deployment's own when known. */
63
+ pinLength: (state: EmailLoginState) => number
64
+ /** Password sign-in is offered here. */
65
+ passwordOffered: (state: EmailLoginState) => boolean
66
+ /** What stops "send a PIN" right now (the address), or null when it may go. */
67
+ sendBlock: (state: EmailLoginState) => EmailRefusal | null
68
+ /** What stops "sign in with the PIN" right now, or null when it may go. */
69
+ verifyBlock: (state: EmailLoginState) => EmailRefusal | null
70
+ /** What stops a password sign-in right now, or null when it may go. */
71
+ signInBlock: (state: EmailLoginState) => EmailRefusal | null
72
+ }
package/src/state.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { EMAIL_ADDRESS_MAX_LENGTH, EMAIL_PIN_LENGTH } from '@owlmeans/auth-email'
2
+ import type { EmailRefusal } from './refusal/types.js'
3
+ import type { EmailLoginAction, EmailLoginState, EmailLoginStateHelper } from './state/types.js'
4
+
5
+ export const createEmailLoginStateHelper = (): EmailLoginStateHelper => {
6
+ const address = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
7
+
8
+ const initial = (email: string = ''): EmailLoginState => ({
9
+ step: 'email', email, pin: '', password: '', reveal: false, challenge: null, notice: null,
10
+ busy: null, refusal: null, options: null, optionsFailed: false,
11
+ })
12
+
13
+ const normalizeEmail = (email: string): string => email.trim().toLowerCase()
14
+
15
+ const validEmail = (email: string): boolean => {
16
+ const value = normalizeEmail(email)
17
+
18
+ return value.length <= EMAIL_ADDRESS_MAX_LENGTH && address.test(value)
19
+ }
20
+
21
+ const pinLength = (state: EmailLoginState): number => state.options?.pinLength ?? EMAIL_PIN_LENGTH
22
+
23
+ const passwordOffered = (state: EmailLoginState): boolean => state.options?.password === true
24
+
25
+ const sendBlock = (state: EmailLoginState): EmailRefusal | null =>
26
+ validEmail(state.email) ? null : { kind: 'email' }
27
+
28
+ const verifyBlock = (state: EmailLoginState): EmailRefusal | null => {
29
+ if (state.challenge == null) {
30
+ // Nothing was mailed for this form yet — the person has to ask for a code first.
31
+ return { kind: 'code' }
32
+ }
33
+
34
+ return new RegExp(`^[0-9]{${pinLength(state)}}$`).test(state.pin) ? null : { kind: 'pin-format' }
35
+ }
36
+
37
+ const signInBlock = (state: EmailLoginState): EmailRefusal | null => {
38
+ if (!validEmail(state.email)) {
39
+ return { kind: 'email' }
40
+ }
41
+ if (!passwordOffered(state)) {
42
+ return { kind: 'password-disabled' }
43
+ }
44
+
45
+ return state.password === '' ? { kind: 'password-empty' } : null
46
+ }
47
+
48
+ const reduce = (state: EmailLoginState, action: EmailLoginAction): EmailLoginState => {
49
+ switch (action.type) {
50
+ case 'options':
51
+ return {
52
+ ...state, options: action.options, optionsFailed: false,
53
+ // The deployment turned passwords off under an open password step: back to the code.
54
+ ...(state.step === 'password' && action.options.password !== true
55
+ ? { step: 'email', password: '', reveal: false } : {}),
56
+ }
57
+ case 'options-failed':
58
+ return { ...state, optionsFailed: true }
59
+ case 'email':
60
+ return state.step === 'done' ? state : { ...state, email: action.value, refusal: null }
61
+ case 'pin':
62
+ return state.step === 'done' ? state : {
63
+ ...state, pin: action.value.replace(/\D/g, '').slice(0, pinLength(state)), refusal: null,
64
+ }
65
+ case 'password':
66
+ return state.step === 'done' ? state : { ...state, password: action.value, refusal: null }
67
+ case 'reveal':
68
+ return { ...state, reveal: action.value }
69
+ case 'choose-password':
70
+ return state.busy != null || !passwordOffered(state) || state.step === 'done' ? state : {
71
+ ...state, step: 'password', pin: '', notice: null, refusal: null,
72
+ }
73
+ case 'choose-pin':
74
+ return state.busy != null || state.step === 'done' ? state : {
75
+ ...state, step: 'email', password: '', reveal: false, refusal: null,
76
+ }
77
+ case 'change-email':
78
+ return state.busy != null || state.step === 'done' ? state : {
79
+ ...state, step: 'email', pin: '', password: '', reveal: false, challenge: null, notice: null, refusal: null,
80
+ }
81
+ case 'start':
82
+ return state.busy != null || state.step === 'done' ? state : { ...state, busy: action.busy, refusal: null }
83
+ case 'sent':
84
+ return { ...state, step: 'pin', challenge: action.challenge, notice: 'sent', pin: '', busy: null, refusal: null }
85
+ case 'device':
86
+ // A password sign-in on a device the account does not know: the password was NOT checked,
87
+ // a PIN was mailed instead, and the password is not kept around for later.
88
+ return {
89
+ ...state, step: 'pin', challenge: action.challenge, notice: 'device', pin: '', password: '',
90
+ reveal: false, busy: null, refusal: null,
91
+ }
92
+ case 'signed-in':
93
+ // Nothing secret stays in the form while the browser is leaving.
94
+ return { ...state, step: 'done', pin: '', password: '', reveal: false, busy: null, refusal: null }
95
+ case 'refused':
96
+ return {
97
+ ...state,
98
+ // Completing a sign-in failed after the answer: the person starts over from the address.
99
+ ...(state.step === 'done' ? { step: 'email', challenge: null, notice: null } : {}),
100
+ busy: null,
101
+ refusal: action.refusal,
102
+ ...(action.refusal.kind === 'code' ? { pin: '' } : {}),
103
+ ...(action.refusal.kind === 'credentials' ? { password: '' } : {}),
104
+ }
105
+ default:
106
+ return state
107
+ }
108
+ }
109
+
110
+ return {
111
+ initial, reduce, validEmail, normalizeEmail, pinLength, passwordOffered, sendBlock, verifyBlock, signInBlock,
112
+ }
113
+ }
114
+
115
+ export const emailLoginStateHelper = createEmailLoginStateHelper()
package/src/step.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { LoginStep } from '@owlmeans/client-auth/login'
2
+ import { EMAIL_OFFER_LOGIN_STEP, EMAIL_OFFER_STEP_PRIORITY } from './consts.js'
3
+ import { emailStorageHelper } from './storage.js'
4
+ import type { EmailStorageHelper } from './storage/types.js'
5
+ import type { EmailLoginClientService } from './types.js'
6
+ import { signedInAccount } from './utils/account.js'
7
+
8
+ /**
9
+ * The post-sign-in "set a password" step.
10
+ *
11
+ * Pending only while BOTH hold: this tab keeps an offer ticket from a fresh PIN sign-in (unexpired,
12
+ * this account's), and the account's password status still says enabled, unset and not declined.
13
+ * A ticket the status no longer allows is forgotten on the spot. Optional: a status read that throws
14
+ * or hangs is "not pending" (the login host fails open), so a broken read never blocks a sign-in.
15
+ *
16
+ * Its priority sits below the marketing-consent step, so a Terms confirmation and the consent
17
+ * choices come first.
18
+ */
19
+ export const emailOfferStep = (
20
+ client: Pick<EmailLoginClientService, 'passwordStatus'>, entrypointAlias: string,
21
+ storage: EmailStorageHelper = emailStorageHelper,
22
+ ): LoginStep => ({
23
+ alias: EMAIL_OFFER_LOGIN_STEP,
24
+ priority: EMAIL_OFFER_STEP_PRIORITY,
25
+ entrypoint: entrypointAlias,
26
+ pending: async ctx => {
27
+ if (storage.offer(signedInAccount(ctx)) == null) {
28
+ return false
29
+ }
30
+ const status = await client.passwordStatus()
31
+ if (status.enabled && !status.set && !status.declined) {
32
+ return true
33
+ }
34
+ storage.dropOffer()
35
+
36
+ return false
37
+ },
38
+ })
@@ -0,0 +1,43 @@
1
+ /** The part of a Web Storage this package uses. */
2
+ export interface EmailStorageArea extends Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {}
3
+
4
+ export interface EmailStorageHelperOptions {
5
+ /** Where the device id lives. Default: `window.localStorage`, or nothing when it is unavailable. */
6
+ local?: () => EmailStorageArea | null
7
+ /** Where the offer ticket waits. Default: `window.sessionStorage`, or nothing when it is unavailable. */
8
+ session?: () => EmailStorageArea | null
9
+ /** `count` random bytes. Default: `crypto.getRandomValues`. */
10
+ random?: (count: number) => Uint8Array
11
+ /** The clock, in milliseconds. Default: `Date.now`. */
12
+ now?: () => number
13
+ }
14
+
15
+ /** The offer ticket as it is kept — with the account it was issued to and when it stops counting. */
16
+ export interface StoredEmailOffer {
17
+ ticket: string
18
+ owner?: string
19
+ expiresAt: number
20
+ }
21
+
22
+ /**
23
+ * What a browser keeps for e-mail sign-in: its device id, and a fresh PIN sign-in's offer ticket.
24
+ *
25
+ * Every storage access tolerates a browser that refuses site data (private modes, blocked
26
+ * storage): the device id then lives for the page alone, and no offer is shown.
27
+ */
28
+ export interface EmailStorageHelper {
29
+ /**
30
+ * This browser's device id — 16 random bytes, base64url (22 characters). Created on first use and
31
+ * kept in `localStorage`; without storage, one id for as long as this page lives.
32
+ */
33
+ deviceId: () => string
34
+ /**
35
+ * The waiting offer ticket, or null — also when it has expired, or was issued to another account
36
+ * than `owner` (when both are known).
37
+ */
38
+ offer: (owner?: string) => string | null
39
+ /** Keep a PIN sign-in's offer ticket for the offer step, until `EMAIL_OFFER_TTL_SECONDS` pass. */
40
+ keepOffer: (ticket: string, owner?: string) => void
41
+ /** Forget the offer ticket — it was used, declined, or cannot be used any more. */
42
+ dropOffer: () => void
43
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { EMAIL_DEVICE_ID_PATTERN, EMAIL_OFFER_TTL_SECONDS } from '@owlmeans/auth-email'
2
+ import { EMAIL_DEVICE_ID_BYTES, EMAIL_DEVICE_STORAGE, EMAIL_OFFER_STORAGE } from './consts.js'
3
+ import type {
4
+ EmailStorageArea, EmailStorageHelper, EmailStorageHelperOptions, StoredEmailOffer,
5
+ } from './storage/types.js'
6
+
7
+ /** A storage area of the page, or null where there is no page or the browser refuses it. */
8
+ const areaOf = (name: 'localStorage' | 'sessionStorage') => (): EmailStorageArea | null => {
9
+ try {
10
+ return typeof window !== 'undefined' ? window[name] ?? null : null
11
+ } catch {
12
+ return null
13
+ }
14
+ }
15
+
16
+ export const createEmailStorageHelper = (opts: EmailStorageHelperOptions = {}): EmailStorageHelper => {
17
+ const local = opts.local ?? areaOf('localStorage')
18
+ const session = opts.session ?? areaOf('sessionStorage')
19
+ const random = opts.random ?? ((count: number) => crypto.getRandomValues(new Uint8Array(count)))
20
+ const now = opts.now ?? (() => Date.now())
21
+ const valid = new RegExp(EMAIL_DEVICE_ID_PATTERN)
22
+
23
+ /** The id of this page when the browser keeps nothing — still one id, not one per request. */
24
+ let ephemeral: string | null = null
25
+
26
+ const base64url = (bytes: Uint8Array): string => {
27
+ let binary = ''
28
+ for (const byte of bytes) {
29
+ binary += String.fromCharCode(byte)
30
+ }
31
+
32
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
33
+ }
34
+
35
+ const read = (area: EmailStorageArea | null, key: string): string | null => {
36
+ try {
37
+ return area?.getItem(key) ?? null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ const write = (area: EmailStorageArea | null, key: string, value: string | null): boolean => {
44
+ if (area == null) {
45
+ return false
46
+ }
47
+ try {
48
+ if (value == null) {
49
+ area.removeItem(key)
50
+ } else {
51
+ area.setItem(key, value)
52
+ }
53
+
54
+ return true
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ const deviceId = (): string => {
61
+ const area = local()
62
+ const stored = read(area, EMAIL_DEVICE_STORAGE)
63
+ if (stored != null && valid.test(stored)) {
64
+ return stored
65
+ }
66
+ if (ephemeral != null) {
67
+ return ephemeral
68
+ }
69
+ const created = base64url(random(EMAIL_DEVICE_ID_BYTES))
70
+ if (!write(area, EMAIL_DEVICE_STORAGE, created)) {
71
+ ephemeral = created
72
+ }
73
+
74
+ return created
75
+ }
76
+
77
+ const offer = (owner?: string): string | null => {
78
+ const raw = read(session(), EMAIL_OFFER_STORAGE)
79
+ if (raw == null) {
80
+ return null
81
+ }
82
+ let stored: Partial<StoredEmailOffer> | null = null
83
+ try {
84
+ stored = JSON.parse(raw) as Partial<StoredEmailOffer>
85
+ } catch {
86
+ return null
87
+ }
88
+ if (typeof stored?.ticket !== 'string' || stored.ticket === ''
89
+ || typeof stored.expiresAt !== 'number' || stored.expiresAt <= now()) {
90
+ return null
91
+ }
92
+ if (owner != null && stored.owner != null && stored.owner !== owner) {
93
+ return null
94
+ }
95
+
96
+ return stored.ticket
97
+ }
98
+
99
+ const keepOffer = (ticket: string, owner?: string): void => {
100
+ const stored: StoredEmailOffer = {
101
+ ticket, ...(owner != null ? { owner } : {}), expiresAt: now() + EMAIL_OFFER_TTL_SECONDS * 1000,
102
+ }
103
+ write(session(), EMAIL_OFFER_STORAGE, JSON.stringify(stored))
104
+ }
105
+
106
+ const dropOffer = (): void => {
107
+ write(session(), EMAIL_OFFER_STORAGE, null)
108
+ }
109
+
110
+ return { deviceId, offer, keepOffer, dropOffer }
111
+ }
112
+
113
+ export const emailStorageHelper = createEmailStorageHelper()
package/src/types.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type { ComponentType } from 'react'
2
+ import type { LazyService } from '@owlmeans/context'
3
+ import type { ClientConfig, ClientContext } from '@owlmeans/client-context'
4
+ import type { AuthMethodMeta } from '@owlmeans/client-auth/manager/plugins'
5
+ import type { LoginScreenProps } from '@owlmeans/client-auth/login'
6
+ import type {
7
+ EmailAuthEntrypoints, EmailAuthOptionsView, EmailChallengeView, EmailPasswordPinRequest,
8
+ EmailPasswordRemoveRequest, EmailPasswordSetRequest, EmailPasswordStatusView, EmailPinRequest,
9
+ EmailSignInRequest, EmailSignInView,
10
+ } from '@owlmeans/auth-email'
11
+
12
+ /**
13
+ * `(key, defaultValue) => string` — this package's own translator, or an application's.
14
+ *
15
+ * Keys are this package's (`login.title`, `error.code`, …, resource `auth-email`); the default is
16
+ * the English text. Placeholders such as `{{email}}` are filled by the component, so a translator
17
+ * returns the sentence as it is.
18
+ */
19
+ export interface Translate { (key: string, defaultValue: string): string }
20
+
21
+ /**
22
+ * How an application dresses the e-mail sign-in card — the same props the chooser card
23
+ * (`@owlmeans/web-panel`'s `LoginScreen`) takes for the same purpose.
24
+ */
25
+ export interface EmailLoginScreenSetup extends Pick<
26
+ LoginScreenProps,
27
+ 'Logo' | 'title' | 'subtitle' | 'footer' | 'className' | 'containerClassName' | 'style' | 'locale' | 'translate'
28
+ > {}
29
+
30
+ export interface EmailLoginScreenProps extends EmailLoginScreenSetup {}
31
+
32
+ export type EmailLoginScreenComponent = ComponentType<EmailLoginScreenProps>
33
+
34
+ export interface EmailLoginAppendOptions {
35
+ /**
36
+ * From `makeEmailAuthProtocols(...)` — the tree the application also binds with `bindAll` (the
37
+ * API routes) and `emailAuthEntrypoints` (the offer screen).
38
+ */
39
+ protocols: EmailAuthEntrypoints
40
+ /** Logo, copy and classes of the e-mail card; merged under the props a host passes itself. */
41
+ screen?: EmailLoginScreenSetup
42
+ /**
43
+ * How the method presents itself on the chooser, merged over `EMAIL_LOGIN_METHOD_META`.
44
+ * `available` is always this package's own (the client service is registered).
45
+ */
46
+ method?: Partial<AuthMethodMeta>
47
+ /** Register the post-sign-in "set a password" step. Default `true`. */
48
+ offer?: boolean
49
+ }
50
+
51
+ export type EmailLoginClientContext = ClientContext<ClientConfig>
52
+
53
+ /**
54
+ * The browser side of e-mail sign-in, registered as `EMAIL_LOGIN_SERVICE` by `appendEmailLogin`.
55
+ *
56
+ * A thin transport over the protocol tree: every call rejects with the error its route answered,
57
+ * and the caller phrases it (`emailRefusalHelper`). Nothing here swallows a failure.
58
+ */
59
+ export interface EmailLoginClientService extends LazyService {
60
+ /** The protocol tree the service calls. */
61
+ protocols: () => EmailAuthEntrypoints
62
+ /** `GET options`, cached after the first success; `fresh` asks again. */
63
+ options: (opts?: { fresh?: boolean }) => Promise<EmailAuthOptionsView>
64
+ /** `POST pin` — mail a sign-in PIN. */
65
+ sendPin: (request: EmailPinRequest) => Promise<EmailChallengeView>
66
+ /** `POST sign-in` — PIN mode or password mode. */
67
+ signIn: (request: EmailSignInRequest) => Promise<EmailSignInView>
68
+ /**
69
+ * Finish a signed-in answer the way every self-contained plugin does: adopt the token through the
70
+ * client auth service, keep the offer ticket (when one came) for the offer step, then land with
71
+ * `loginLandingOf(ctx).landAfterLogin()` — a pending step, a parked target, else home; inside a
72
+ * surrogate window the dispatcher, which hands the session to the opener.
73
+ *
74
+ * @throws when the answer carries no token, or the token is refused.
75
+ */
76
+ complete: (view: EmailSignInView) => Promise<void>
77
+ /** `GET password` — the signed-in account's password state. */
78
+ passwordStatus: () => Promise<EmailPasswordStatusView>
79
+ /** `POST password/pin` — mail a confirmation PIN to the account's own address. */
80
+ passwordPin: (request: EmailPasswordPinRequest) => Promise<EmailChallengeView>
81
+ /** `POST password/set`. */
82
+ setPassword: (request: EmailPasswordSetRequest) => Promise<EmailPasswordStatusView>
83
+ /** `POST password/remove`. */
84
+ removePassword: (request: EmailPasswordRemoveRequest) => Promise<EmailPasswordStatusView>
85
+ /** `POST password/decline` — "Not now" on the offer. */
86
+ declinePassword: () => Promise<EmailPasswordStatusView>
87
+ /** The screen the e-mail route renders; null = the package's own. */
88
+ screen: () => EmailLoginScreenComponent | null
89
+ /** Replace the screen the e-mail route renders (`registerEmailLoginScreen`). */
90
+ registerScreen: (screen: EmailLoginScreenComponent) => void
91
+ }
92
+
93
+ export interface MakeEmailLoginClientOptions {
94
+ alias?: string
95
+ /** The screen the e-mail route renders until another is registered. */
96
+ screen?: EmailLoginScreenComponent
97
+ }
98
+
99
+ /**
100
+ * The entrypoint aliases `usePassword` addresses.
101
+ *
102
+ * By default those of the tree given to `appendEmailLogin`, else the `EMAIL_AUTH_PASSWORD_*` aliases
103
+ * `makeEmailAuthProtocols` declares — an application that mounted the password routes under its own
104
+ * aliases (a facade in front of them, say) passes its own here.
105
+ */
106
+ export interface EmailPasswordAliases {
107
+ status: string
108
+ pin: string
109
+ set: string
110
+ remove: string
111
+ }
@@ -0,0 +1,12 @@
1
+ import type { AuthService } from '@owlmeans/auth-common'
2
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
+ import { DEFAULT_ALIAS as AUTH_SERVICE } from '@owlmeans/client-auth'
4
+
5
+ /** The signed-in account's id, when the client auth service can say — the owner of an offer ticket. */
6
+ export const signedInAccount = <C extends BasicConfig>(ctx: BasicContext<C>): string | undefined => {
7
+ try {
8
+ return ctx.service<AuthService>(AUTH_SERVICE).user().userId
9
+ } catch {
10
+ return undefined
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ import { EMAIL_LOCALE_MAX_LENGTH } from '@owlmeans/auth-email'
2
+
3
+ /**
4
+ * The language a request names for its mail: the base language of the interface's (`de-AT` → `de`),
5
+ * or nothing when there is none to name — the server then writes in English.
6
+ */
7
+ export const wireLocale = (locale?: string): string | undefined => {
8
+ const base = locale?.trim().split(/[-_]/)[0]?.toLowerCase()
9
+
10
+ return base != null && base !== '' && base.length <= EMAIL_LOCALE_MAX_LENGTH ? base : undefined
11
+ }
@@ -0,0 +1,24 @@
1
+ /** One run of a sentence: plain text, or a link. */
2
+ export interface TextPart {
3
+ text: string
4
+ href?: string
5
+ /** The placeholder this part replaced, for a link. */
6
+ key?: string
7
+ }
8
+
9
+ export interface TextLink {
10
+ text: string
11
+ href: string
12
+ }
13
+
14
+ /**
15
+ * Placeholders in an already-translated sentence. Translations keep `{{name}}` untouched when no
16
+ * value is passed (i18next's `skipOnVariables`), and an app's own `translate` has no values at
17
+ * all — so every package string is filled here, the same way whichever translator produced it.
18
+ */
19
+ export interface TextUtils {
20
+ /** Replace every `{{name}}` that `values` names; others stay as they are. */
21
+ fill: (template: string, values: Record<string, string | number>) => string
22
+ /** Split a sentence around the `{{name}}` placeholders `links` names, turning each into a link. */
23
+ parts: (template: string, links: Record<string, TextLink>) => TextPart[]
24
+ }
@@ -0,0 +1,34 @@
1
+ import type { TextLink, TextPart, TextUtils } from './text/types.js'
2
+
3
+ export const createTextUtils = (): TextUtils => {
4
+ const placeholder = /\{\{\s*([\w-]+)\s*\}\}/g
5
+
6
+ const fill = (template: string, values: Record<string, string | number>): string =>
7
+ template.replace(placeholder, (match, name: string) =>
8
+ Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match)
9
+
10
+ const parts = (template: string, links: Record<string, TextLink>): TextPart[] => {
11
+ const result: TextPart[] = []
12
+ let last = 0
13
+ for (const match of template.matchAll(placeholder)) {
14
+ const link = links[match[1]]
15
+ if (link == null) {
16
+ continue
17
+ }
18
+ if (match.index > last) {
19
+ result.push({ text: template.slice(last, match.index) })
20
+ }
21
+ result.push({ text: link.text, href: link.href, key: match[1] })
22
+ last = match.index + match[0].length
23
+ }
24
+ if (last < template.length) {
25
+ result.push({ text: template.slice(last) })
26
+ }
27
+
28
+ return result
29
+ }
30
+
31
+ return { fill, parts }
32
+ }
33
+
34
+ export const textUtils = createTextUtils()