@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/append.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { createElement, type FC } from 'react'
2
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
+ import type { ClientConfig, ClientContext } from '@owlmeans/client-context'
4
+ import { authPluginHelper } from '@owlmeans/client-auth/manager/plugins'
5
+ import { ensureLoginService } from '@owlmeans/client-auth/login'
6
+ import { EMAIL_LOGIN_SERVICE } from '@owlmeans/auth-email'
7
+ import { LocalizedEmailLoginScreen } from './components/screen.js'
8
+ import { makeEmailLoginPlugin } from './plugin.js'
9
+ import { appendEmailLoginClient } from './service.js'
10
+ import { emailOfferStep } from './step.js'
11
+ import type {
12
+ EmailLoginAppendOptions, EmailLoginClientService, EmailLoginScreenComponent, EmailLoginScreenProps,
13
+ EmailLoginScreenSetup,
14
+ } from './types.js'
15
+ import './i18n.js'
16
+
17
+ /** The default screen, dressed with the application's setup; a host's own props still win. */
18
+ const screenOf = (setup?: EmailLoginScreenSetup): EmailLoginScreenComponent => {
19
+ if (setup == null || Object.keys(setup).length < 1) {
20
+ return LocalizedEmailLoginScreen
21
+ }
22
+ const Configured: FC<EmailLoginScreenProps> = props =>
23
+ createElement(LocalizedEmailLoginScreen, { ...setup, ...props })
24
+
25
+ return Configured
26
+ }
27
+
28
+ /**
29
+ * Wire e-mail sign-in into a web app — the whole of it beyond binding the protocol tree (`bindAll`)
30
+ * and `emailAuthEntrypoints(protocols)` in the application's entrypoint list:
31
+ *
32
+ * - the client service `EMAIL_LOGIN_SERVICE` over `protocols`;
33
+ * - the `email` authentication plugin — "Continue with email" on the chooser, its card at
34
+ * `/authentication/login/email` (`opts.screen` dresses it, `opts.method` the button);
35
+ * - the post-sign-in "set a password" step, below marketing consent (`opts.offer !== false`);
36
+ * - the `auth-email` strings and `login.method.email` in eight languages.
37
+ *
38
+ * Explicit by design: importing this package registers no plugin.
39
+ */
40
+ export const appendEmailLogin = <C extends ClientConfig, T extends ClientContext<C>>(
41
+ context: T, opts: EmailLoginAppendOptions,
42
+ ): T => {
43
+ appendEmailLoginClient(context, opts.protocols, { screen: screenOf(opts.screen) })
44
+ authPluginHelper.registerAuthPlugin(makeEmailLoginPlugin(opts.method))
45
+
46
+ if (opts.offer !== false) {
47
+ const client = context.service<EmailLoginClientService>(EMAIL_LOGIN_SERVICE)
48
+ ensureLoginService(context).registerStep(emailOfferStep(client, opts.protocols.offer.alias))
49
+ }
50
+
51
+ return context
52
+ }
53
+
54
+ /**
55
+ * Replace the screen the e-mail route renders — a whole new card built on `useEmailLogin()`, for an
56
+ * application whose sign-in looks nothing like the chooser. Call it after `appendEmailLogin`.
57
+ */
58
+ export const registerEmailLoginScreen = <C extends BasicConfig>(
59
+ context: BasicContext<C>, screen: EmailLoginScreenComponent,
60
+ ): void => {
61
+ if (!context.hasService(EMAIL_LOGIN_SERVICE)) {
62
+ throw new SyntaxError('auth-email: call appendEmailLogin before registerEmailLoginScreen')
63
+ }
64
+ context.service<EmailLoginClientService>(EMAIL_LOGIN_SERVICE).registerScreen(screen)
65
+ }
@@ -0,0 +1,36 @@
1
+ import type { FC } from 'react'
2
+ import { useLanguage } from '@owlmeans/client-i18n'
3
+ import { usePassword } from '../hooks/use-password.js'
4
+ import { useEmailAuthTranslate } from '../hooks/use-translate.js'
5
+ import { PasswordPanel } from './panel.js'
6
+ import type { ConnectedPasswordPanelProps } from './types.js'
7
+
8
+ /**
9
+ * The password panel wired to the password routes of the current context — the whole feature in
10
+ * one element, and the only piece of it that talks to a server.
11
+ */
12
+ export const ConnectedPasswordPanel: FC<ConnectedPasswordPanelProps> = ({
13
+ aliases, translate, title, description, className,
14
+ }) => {
15
+ const own = useEmailAuthTranslate()
16
+ const [lng] = useLanguage()
17
+ const model = usePassword(aliases, { locale: lng })
18
+
19
+ return <PasswordPanel
20
+ status={model.status}
21
+ loading={model.loading}
22
+ busy={model.busy}
23
+ pending={model.pending}
24
+ refusal={model.refusal}
25
+ done={model.done}
26
+ onSendPin={model.sendPin}
27
+ onConfirm={model.confirm}
28
+ onCancel={model.cancel}
29
+ onReload={model.reload}
30
+ translate={translate ?? own}
31
+ locale={lng}
32
+ title={title}
33
+ description={description}
34
+ className={className}
35
+ />
36
+ }
@@ -0,0 +1,30 @@
1
+ import type { FC } from 'react'
2
+ import { useContext } from '@owlmeans/client'
3
+ import { resolveCredit } from '@owlmeans/client-auth/login'
4
+ import type { CommonConfig } from '@owlmeans/config'
5
+ import { cn } from '../@/lib/utils.js'
6
+ import { OWLMEANS_URL } from '../consts.js'
7
+ import type { EmailLoginCreditProps } from './types.js'
8
+
9
+ /** The chooser card's bottom line, composed from the same configuration (`login.credit`, `brand`). */
10
+ export const EmailLoginCredit: FC<EmailLoginCreditProps> = ({ t, className }) => {
11
+ const ctx = useContext()
12
+ const cfg = ctx.cfg as CommonConfig
13
+ const credit = resolveCredit(cfg.security?.auth?.login?.credit, cfg.brand, ctx.cfg.service)
14
+ if (!credit.poweredBy && credit.line == null) {
15
+ return null
16
+ }
17
+
18
+ return <p data-email-login-credit className={cn('text-center text-xs text-muted-foreground', className)}>
19
+ {credit.poweredBy && <a
20
+ href={OWLMEANS_URL}
21
+ target="_blank"
22
+ rel="noopener noreferrer"
23
+ className="underline-offset-4 hover:text-foreground hover:underline"
24
+ >
25
+ {t('login.powered', 'Powered by OwlMeans')}
26
+ </a>}
27
+ {credit.poweredBy && credit.line != null && <span aria-hidden="true"> · </span>}
28
+ {credit.line != null && <span>{credit.line}</span>}
29
+ </p>
30
+ }
@@ -0,0 +1,112 @@
1
+ import type { FC } from 'react'
2
+ import { Eye, EyeOff, Loader2 } from 'lucide-react'
3
+ import { Button } from '../@/components/ui/button.js'
4
+ import { Input } from '../@/components/ui/input.js'
5
+ import { cn } from '../@/lib/utils.js'
6
+ import { RECAPTCHA_PRIVACY_URL, RECAPTCHA_TERMS_URL } from '../consts.js'
7
+ import { emailRefusalHelper } from '../refusal.js'
8
+ import { textUtils } from '../utils/text.js'
9
+ import type {
10
+ CaptchaNoticeProps, PasswordInputProps, PinInputProps, RefusalAlertProps, SubmitButtonProps,
11
+ } from './types.js'
12
+
13
+ /**
14
+ * A password field with its show/hide toggle inside the box. The toggle is a real button with an
15
+ * accessible name that says what it will do, and a 44px target.
16
+ */
17
+ export const PasswordInput: FC<PasswordInputProps> = ({
18
+ reveal, onReveal, showLabel, hideLabel, toggleProps, className, ...props
19
+ }) => (
20
+ <div className="relative">
21
+ <Input
22
+ {...props}
23
+ type={reveal ? 'text' : 'password'}
24
+ autoCapitalize="none"
25
+ autoCorrect="off"
26
+ spellCheck={false}
27
+ className={cn('h-11 pr-12 text-base', className)}
28
+ />
29
+ <Button
30
+ type="button"
31
+ variant="ghost"
32
+ {...toggleProps}
33
+ aria-label={reveal ? hideLabel : showLabel}
34
+ aria-pressed={reveal}
35
+ title={reveal ? hideLabel : showLabel}
36
+ className="absolute inset-y-0 right-0 h-11 w-11 rounded-l-none text-muted-foreground hover:text-foreground"
37
+ onClick={() => onReveal(!reveal)}
38
+ >
39
+ {reveal ? <EyeOff aria-hidden /> : <Eye aria-hidden />}
40
+ </Button>
41
+ </div>
42
+ )
43
+
44
+ /** The PIN field: numeric keypad on a phone, the mailed code offered by the keyboard's autofill. */
45
+ export const PinInput: FC<PinInputProps> = ({ length, className, ...props }) => (
46
+ <Input
47
+ {...props}
48
+ type="text"
49
+ inputMode="numeric"
50
+ autoComplete="one-time-code"
51
+ pattern="[0-9]*"
52
+ maxLength={length}
53
+ className={cn('h-12 text-center font-mono text-2xl tracking-[0.4em]', className)}
54
+ />
55
+ )
56
+
57
+ /**
58
+ * The step's primary action. Never the HTML `disabled` attribute: a disabled button swallows the
59
+ * press and tells nobody anything, so a blocked or busy action carries `aria-disabled` and its
60
+ * handler explains (or ignores the second press).
61
+ */
62
+ export const SubmitButton: FC<SubmitButtonProps> = ({ busy, blocked = false, className, children, ...props }) => (
63
+ <Button
64
+ type="submit"
65
+ {...props}
66
+ aria-disabled={busy || blocked || undefined}
67
+ aria-busy={busy || undefined}
68
+ data-blocked={blocked ? 'true' : undefined}
69
+ className={cn('h-11 w-full gap-2 text-base', (busy || blocked) && 'opacity-60', className)}
70
+ >
71
+ {busy && <Loader2 className="animate-spin" aria-hidden />}
72
+ {children}
73
+ </Button>
74
+ )
75
+
76
+ /** A refusal in the reader's language, announced as it appears. */
77
+ export const RefusalAlert: FC<RefusalAlertProps> = ({ refusal, t, locale, marker, className }) => refusal == null
78
+ ? null
79
+ : <p
80
+ role="alert"
81
+ {...{ [marker]: '' }}
82
+ data-refusal={refusal.kind}
83
+ className={cn('text-center text-sm text-destructive', className)}
84
+ >
85
+ {emailRefusalHelper.messageOf(refusal, t, locale)}
86
+ </p>
87
+
88
+ /**
89
+ * Google's notice. The loader hides the reCAPTCHA badge, and a page that hides it has to say this
90
+ * itself, with both links.
91
+ */
92
+ export const CaptchaNotice: FC<CaptchaNoticeProps> = ({ t, className }) => {
93
+ const parts = textUtils.parts(
94
+ t('captcha.notice', 'This site is protected by reCAPTCHA and the Google {{privacy}} and {{terms}} apply.'),
95
+ {
96
+ privacy: { text: t('captcha.privacy', 'Privacy Policy'), href: RECAPTCHA_PRIVACY_URL },
97
+ terms: { text: t('captcha.terms', 'Terms of Service'), href: RECAPTCHA_TERMS_URL },
98
+ },
99
+ )
100
+
101
+ return <p data-email-login-captcha className={cn('text-center text-xs text-muted-foreground', className)}>
102
+ {parts.map((part, index) => part.href != null
103
+ ? <a
104
+ key={index}
105
+ href={part.href}
106
+ target="_blank"
107
+ rel="noopener noreferrer"
108
+ className="underline underline-offset-2 hover:text-foreground"
109
+ >{part.text}</a>
110
+ : <span key={index}>{part.text}</span>)}
111
+ </p>
112
+ }
@@ -0,0 +1,8 @@
1
+ export * from './screen.js'
2
+ export * from './route.js'
3
+ export * from './offer.js'
4
+ export * from './panel.js'
5
+ export * from './connected.js'
6
+ export * from './fields.js'
7
+ export * from './credit.js'
8
+ export type * from './types.js'
@@ -0,0 +1,197 @@
1
+ import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from 'react'
2
+ import type { RoutedComponent } from '@owlmeans/client'
3
+ import { useContext } from '@owlmeans/client'
4
+ import { useLanguage } from '@owlmeans/client-i18n'
5
+ import { useContinueLogin } from '@owlmeans/client-auth/login'
6
+ import {
7
+ EMAIL_LOGIN_SERVICE, EMAIL_PASSWORD_MAX_LENGTH, EMAIL_PASSWORD_MIN_LENGTH,
8
+ } from '@owlmeans/auth-email'
9
+ import { logger } from '@owlmeans/log'
10
+ import { Button } from '../@/components/ui/button.js'
11
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../@/components/ui/card.js'
12
+ import { Label } from '../@/components/ui/label.js'
13
+ import { EMAIL_OFFER_LOGIN_STEP } from '../consts.js'
14
+ import { useEmailAuthTranslate } from '../hooks/use-translate.js'
15
+ import { passwordPanelHelper } from '../password.js'
16
+ import { emailRefusalHelper } from '../refusal.js'
17
+ import { emailStorageHelper } from '../storage.js'
18
+ import { textUtils } from '../utils/text.js'
19
+ import { signedInAccount } from '../utils/account.js'
20
+ import type { EmailRefusal } from '../refusal/types.js'
21
+ import type { EmailLoginClientService } from '../types.js'
22
+ import { PasswordInput, RefusalAlert, SubmitButton } from './fields.js'
23
+
24
+ const log = logger('auth-email')
25
+
26
+ /**
27
+ * The post-sign-in offer: "Set a password for faster sign-in", right after a PIN sign-in of a
28
+ * person who has none and never declined one. The fresh sign-in's one-use ticket confirms it — no
29
+ * second PIN. "Not now" declines (the step is not offered again); both answers forget the ticket
30
+ * and move the sign-in on to the next step or the landing.
31
+ */
32
+ export const PasswordOfferScreen: RoutedComponent = () => {
33
+ const t = useEmailAuthTranslate()
34
+ const [locale] = useLanguage()
35
+ const ctx = useContext()
36
+ const continueLogin = useContinueLogin()
37
+ const id = useId()
38
+
39
+ const client = ctx.service<EmailLoginClientService>(EMAIL_LOGIN_SERVICE)
40
+ const ticket = useMemo(() => emailStorageHelper.offer(signedInAccount(ctx)), [ctx])
41
+
42
+ const [password, setPassword] = useState('')
43
+ const [repeat, setRepeat] = useState('')
44
+ const [reveal, setReveal] = useState(false)
45
+ const [busy, setBusy] = useState<'save' | 'skip' | null>(null)
46
+ const [refusal, setRefusal] = useState<EmailRefusal | null>(null)
47
+ const advanced = useRef(false)
48
+
49
+ const advance = async (): Promise<void> => {
50
+ if (advanced.current) {
51
+ return
52
+ }
53
+ advanced.current = true
54
+ emailStorageHelper.dropOffer()
55
+ await continueLogin({ after: EMAIL_OFFER_LOGIN_STEP })
56
+ }
57
+
58
+ // Nothing to offer on this visit (a reload after the answer, an expired ticket): move on.
59
+ useEffect(() => {
60
+ if (ticket == null) {
61
+ void advance()
62
+ }
63
+ }, [ticket])
64
+
65
+ const save = async (event?: FormEvent): Promise<void> => {
66
+ event?.preventDefault()
67
+ if (busy != null || ticket == null) {
68
+ return
69
+ }
70
+ const block = passwordPanelHelper.passwordBlock(password, repeat)
71
+ if (block != null) {
72
+ setRefusal(block)
73
+ return
74
+ }
75
+ setBusy('save')
76
+ setRefusal(null)
77
+ try {
78
+ await client.setPassword({ password, offer: ticket, device: emailStorageHelper.deviceId() })
79
+ await advance()
80
+ } catch (error) {
81
+ const next = emailRefusalHelper.refusalOf(error, 'offer')
82
+ log.debug('Password offer refused', { kind: next.kind })
83
+ if (next.kind === 'offer-expired') {
84
+ emailStorageHelper.dropOffer()
85
+ }
86
+ setRefusal(next)
87
+ setBusy(null)
88
+ }
89
+ }
90
+
91
+ const skip = async (): Promise<void> => {
92
+ if (busy != null) {
93
+ return
94
+ }
95
+ setBusy('skip')
96
+ try {
97
+ await client.declinePassword()
98
+ } catch (error) {
99
+ // Declining is a courtesy to the next sign-in; failing it must not hold this one up.
100
+ log.debug('Password offer decline failed', { kind: emailRefusalHelper.refusalOf(error, 'offer').kind })
101
+ }
102
+ await advance()
103
+ }
104
+
105
+ const mismatch = refusal?.kind === 'mismatch'
106
+
107
+ return <div
108
+ data-email-offer-screen
109
+ style={{ minHeight: '100dvh' }}
110
+ className="flex w-full items-center justify-center p-4"
111
+ >
112
+ <Card
113
+ data-email-offer
114
+ data-state={ticket == null ? 'leaving' : busy ?? 'ready'}
115
+ className="w-full max-w-sm"
116
+ >
117
+ <CardHeader className="items-center gap-2 text-center">
118
+ <CardTitle className="text-xl">{t('offer.title', 'Set a password for faster sign-in')}</CardTitle>
119
+ <CardDescription>
120
+ {t('offer.description', 'Next time, sign in with your email and password instead of waiting for a code.')}
121
+ </CardDescription>
122
+ </CardHeader>
123
+ <CardContent>
124
+ <form noValidate onSubmit={event => void save(event)} className="flex flex-col gap-4">
125
+ <div className="flex flex-col gap-2 text-left">
126
+ <Label htmlFor={`${id}-password`}>{t('offer.password-label', 'New password')}</Label>
127
+ <PasswordInput
128
+ id={`${id}-password`}
129
+ name="new-password"
130
+ data-email-offer-password
131
+ autoComplete="new-password"
132
+ autoFocus
133
+ maxLength={EMAIL_PASSWORD_MAX_LENGTH}
134
+ value={password}
135
+ readOnly={busy != null}
136
+ aria-invalid={refusal?.kind === 'weak' || refusal?.kind === 'password-empty' || undefined}
137
+ aria-describedby={`${id}-hint`}
138
+ reveal={reveal}
139
+ onReveal={setReveal}
140
+ showLabel={t('login.show-password', 'Show password')}
141
+ hideLabel={t('login.hide-password', 'Hide password')}
142
+ toggleProps={{ 'data-email-offer-toggle': '' }}
143
+ onChange={event => { setPassword(event.target.value); setRefusal(null) }}
144
+ />
145
+ <p id={`${id}-hint`} className="text-xs text-muted-foreground">
146
+ {textUtils.fill(t('offer.hint', '{{min}} to {{max}} characters.'), {
147
+ min: EMAIL_PASSWORD_MIN_LENGTH, max: EMAIL_PASSWORD_MAX_LENGTH,
148
+ })}
149
+ </p>
150
+ </div>
151
+ <div className="flex flex-col gap-2 text-left">
152
+ <Label htmlFor={`${id}-repeat`}>{t('offer.repeat-label', 'Repeat the password')}</Label>
153
+ <PasswordInput
154
+ id={`${id}-repeat`}
155
+ name="repeat-password"
156
+ data-email-offer-repeat
157
+ autoComplete="new-password"
158
+ maxLength={EMAIL_PASSWORD_MAX_LENGTH}
159
+ value={repeat}
160
+ readOnly={busy != null}
161
+ aria-invalid={mismatch || undefined}
162
+ reveal={reveal}
163
+ onReveal={setReveal}
164
+ showLabel={t('login.show-password', 'Show password')}
165
+ hideLabel={t('login.hide-password', 'Hide password')}
166
+ onChange={event => { setRepeat(event.target.value); setRefusal(null) }}
167
+ />
168
+ </div>
169
+
170
+ <RefusalAlert refusal={refusal} t={t} locale={locale} marker="data-email-offer-error" />
171
+
172
+ <SubmitButton
173
+ data-email-offer-save
174
+ busy={busy === 'save'}
175
+ blocked={ticket == null || passwordPanelHelper.passwordBlock(password, repeat) != null}
176
+ >
177
+ {busy === 'save' ? t('offer.saving', 'Saving…') : t('offer.save', 'Save password')}
178
+ </SubmitButton>
179
+ <Button
180
+ type="button"
181
+ variant="outline"
182
+ data-email-offer-skip
183
+ aria-disabled={busy != null || undefined}
184
+ aria-busy={busy === 'skip' || undefined}
185
+ className="h-11 w-full text-base"
186
+ onClick={() => void skip()}
187
+ >
188
+ {t('offer.skip', 'Not now')}
189
+ </Button>
190
+ <p className="text-center text-xs text-muted-foreground">
191
+ {t('offer.later', 'You can always set a password later in your account settings.')}
192
+ </p>
193
+ </form>
194
+ </CardContent>
195
+ </Card>
196
+ </div>
197
+ }