@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
@@ -0,0 +1,299 @@
1
+ import { useId, useState, type FC, type FormEvent } from 'react'
2
+ import { Loader2 } from 'lucide-react'
3
+ import { EMAIL_PASSWORD_MAX_LENGTH, EMAIL_PIN_LENGTH } from '@owlmeans/auth-email'
4
+ import { Button } from '../@/components/ui/button.js'
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../@/components/ui/card.js'
6
+ import { Label } from '../@/components/ui/label.js'
7
+ import { cn } from '../@/lib/utils.js'
8
+ import { passwordPanelHelper } from '../password.js'
9
+ import { textUtils } from '../utils/text.js'
10
+ import type { EmailPasswordAction } from '../password/types.js'
11
+ import type { EmailRefusal } from '../refusal/types.js'
12
+ import type { Translate } from '../types.js'
13
+ import { PasswordInput, PinInput, RefusalAlert, SubmitButton } from './fields.js'
14
+ import type { PasswordPanelProps } from './types.js'
15
+
16
+ const english: Translate = (_key, defaultValue) => defaultValue
17
+
18
+ /** A record's moment in the reader's language, or null when it has none worth showing. */
19
+ const dateOf = (value: string | undefined, locale?: string): string | null => {
20
+ if (value == null) {
21
+ return null
22
+ }
23
+ const date = new Date(value)
24
+ if (Number.isNaN(date.getTime())) {
25
+ return null
26
+ }
27
+ try {
28
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date)
29
+ } catch {
30
+ return date.toISOString().slice(0, 10)
31
+ }
32
+ }
33
+
34
+ /**
35
+ * The account's password: off on this deployment (a short notice), none yet (Set), or one (Change,
36
+ * Remove). Every action is confirmed by a PIN mailed to the account's own address: "Send code",
37
+ * then the code (and the new password, twice, for set and change), then Confirm.
38
+ *
39
+ * Presentational by contract — it performs no I/O, renders what it is given and reports what was
40
+ * pressed. `ConnectedPasswordPanel` is this panel wired to `usePassword`.
41
+ */
42
+ export const PasswordPanel: FC<PasswordPanelProps> = ({
43
+ status, loading = false, busy = false, pending = null, refusal = null, done = null,
44
+ onSendPin, onConfirm, onCancel, onReload, title, description, translate, locale, className,
45
+ }) => {
46
+ const t = translate ?? english
47
+ const id = useId()
48
+ const [action, setAction] = useState<EmailPasswordAction | null>(null)
49
+ const [pin, setPin] = useState('')
50
+ const [password, setPassword] = useState('')
51
+ const [repeat, setRepeat] = useState('')
52
+ const [reveal, setReveal] = useState(false)
53
+ const [local, setLocal] = useState<EmailRefusal | null>(null)
54
+
55
+ const state = passwordPanelHelper.stateOf(status, loading)
56
+ const actions = passwordPanelHelper.actionsOf(status)
57
+ const sent = action != null && pending === passwordPanelHelper.purposeOf(action)
58
+ const withPassword = action != null && passwordPanelHelper.needsPassword(action)
59
+ const shown = local ?? refusal
60
+
61
+ const reset = (): void => {
62
+ setAction(null)
63
+ setPin('')
64
+ setPassword('')
65
+ setRepeat('')
66
+ setReveal(false)
67
+ setLocal(null)
68
+ }
69
+
70
+ const open = (next: EmailPasswordAction): void => {
71
+ reset()
72
+ setAction(next)
73
+ if (pending != null) {
74
+ onCancel()
75
+ }
76
+ }
77
+
78
+ const close = (): void => {
79
+ reset()
80
+ onCancel()
81
+ }
82
+
83
+ const send = async (): Promise<void> => {
84
+ if (action == null || busy) {
85
+ return
86
+ }
87
+ setLocal(null)
88
+ setPin('')
89
+ await onSendPin(passwordPanelHelper.purposeOf(action))
90
+ }
91
+
92
+ const submit = async (event: FormEvent): Promise<void> => {
93
+ event.preventDefault()
94
+ if (action == null || busy) {
95
+ return
96
+ }
97
+ if (!sent) {
98
+ await send()
99
+ return
100
+ }
101
+ const block = passwordPanelHelper.confirmBlock(action, { pin, password, repeat })
102
+ if (block != null) {
103
+ setLocal(block)
104
+ return
105
+ }
106
+ setLocal(null)
107
+ if (await onConfirm(action, withPassword ? { pin, password } : { pin })) {
108
+ reset()
109
+ }
110
+ }
111
+
112
+ const updated = state === 'set' ? dateOf(status?.updatedAt, locale) : null
113
+ const control = 'h-11 text-base'
114
+
115
+ const summary = (() => {
116
+ switch (state) {
117
+ case 'loading':
118
+ return <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
119
+ <Loader2 className="size-4 animate-spin" aria-hidden />
120
+ {t('password.loading', 'Loading…')}
121
+ </p>
122
+ case 'unreadable':
123
+ return <div className="flex flex-col items-start gap-1 text-sm">
124
+ <p className="text-destructive">{t('password.unreadable', "We couldn't load your password settings.")}</p>
125
+ {onReload != null && <Button
126
+ type="button" variant="link" data-email-password-retry className="h-11 px-0"
127
+ onClick={() => void onReload()}
128
+ >
129
+ {t('password.retry', 'Try again')}
130
+ </Button>}
131
+ </div>
132
+ case 'off':
133
+ return <p data-email-password-notice className="text-sm text-muted-foreground">
134
+ {t('password.off', 'Password sign-in is not available here. Sign in with a code sent to your email.')}
135
+ </p>
136
+ case 'unset':
137
+ return <p className="text-sm">
138
+ {t('password.unset', "You don't have a password yet. You sign in with a code sent to your email.")}
139
+ </p>
140
+ default:
141
+ return <p className="text-sm">
142
+ {t('password.set', 'You have a password.')}
143
+ {updated != null && <> {textUtils.fill(t('password.updated', 'Last changed {{date}}.'), { date: updated })}</>}
144
+ </p>
145
+ }
146
+ })()
147
+
148
+ return <Card data-email-password-panel data-state={state} className={className}>
149
+ <CardHeader>
150
+ <CardTitle>{title ?? t('password.title', 'Password')}</CardTitle>
151
+ <CardDescription>
152
+ {description ?? t('password.description', 'Sign in with your email and a password instead of a code.')}
153
+ </CardDescription>
154
+ </CardHeader>
155
+
156
+ <CardContent className="flex flex-col gap-4">
157
+ {summary}
158
+
159
+ {done != null && action == null && <p role="status" data-email-password-success={done} className="text-sm">
160
+ {done === 'remove'
161
+ ? t('password.done.remove', 'Your password has been removed.')
162
+ : done === 'change'
163
+ ? t('password.done.change', 'Your password has been changed.')
164
+ : t('password.done.set', 'Your password has been set.')}
165
+ {' '}
166
+ {t('password.devices', 'Other devices will need a code the next time they sign in.')}
167
+ </p>}
168
+
169
+ {action == null && actions.length > 0 && <div className="flex flex-col gap-2 sm:flex-row">
170
+ {actions.map(next => <Button
171
+ key={next}
172
+ type="button"
173
+ variant="outline"
174
+ data-email-password-action={next}
175
+ className={cn(control, next === 'remove' && 'text-destructive hover:text-destructive')}
176
+ onClick={() => open(next)}
177
+ >
178
+ {next === 'remove'
179
+ ? t('password.action.remove', 'Remove password')
180
+ : next === 'change'
181
+ ? t('password.action.change', 'Change password')
182
+ : t('password.action.set', 'Set a password')}
183
+ </Button>)}
184
+ </div>}
185
+
186
+ {action != null && <form
187
+ noValidate
188
+ data-email-password-form={action}
189
+ data-sent={sent ? 'true' : undefined}
190
+ className="flex flex-col gap-4"
191
+ onSubmit={event => void submit(event)}
192
+ >
193
+ <p className="text-sm text-muted-foreground">
194
+ {action === 'remove'
195
+ ? t('password.explain.remove', "Without a password you sign in with a code sent to your email. We'll email you a code to confirm.")
196
+ : action === 'change'
197
+ ? t('password.explain.change', "To change your password, confirm it's you with a code we'll email you.")
198
+ : t('password.explain.set', "To set a password, confirm it's you with a code we'll email you.")}
199
+ </p>
200
+
201
+ {sent && <>
202
+ <p role="status" data-email-password-sent className="text-sm">
203
+ {t('password.sent', 'We sent a code to your email.')}
204
+ </p>
205
+ <div className="flex flex-col gap-2">
206
+ <Label htmlFor={`${id}-pin`}>{t('password.pin-label', 'Code from the email')}</Label>
207
+ <PinInput
208
+ id={`${id}-pin`}
209
+ name="pin"
210
+ data-email-password-pin
211
+ length={EMAIL_PIN_LENGTH}
212
+ value={pin}
213
+ readOnly={busy}
214
+ autoFocus
215
+ aria-invalid={shown?.kind === 'code' || shown?.kind === 'pin-format' || undefined}
216
+ className="max-w-60"
217
+ onChange={event => { setPin(event.target.value.replace(/\D/g, '').slice(0, EMAIL_PIN_LENGTH)); setLocal(null) }}
218
+ />
219
+ </div>
220
+ {withPassword && <>
221
+ <div className="flex flex-col gap-2">
222
+ <Label htmlFor={`${id}-new`}>{t('password.new-label', 'New password')}</Label>
223
+ <PasswordInput
224
+ id={`${id}-new`}
225
+ name="new-password"
226
+ data-email-password-new
227
+ autoComplete="new-password"
228
+ maxLength={EMAIL_PASSWORD_MAX_LENGTH}
229
+ value={password}
230
+ readOnly={busy}
231
+ aria-invalid={shown?.kind === 'weak' || shown?.kind === 'password-empty' || undefined}
232
+ reveal={reveal}
233
+ onReveal={setReveal}
234
+ showLabel={t('login.show-password', 'Show password')}
235
+ hideLabel={t('login.hide-password', 'Hide password')}
236
+ toggleProps={{ 'data-email-password-toggle': '' }}
237
+ onChange={event => { setPassword(event.target.value); setLocal(null) }}
238
+ />
239
+ </div>
240
+ <div className="flex flex-col gap-2">
241
+ <Label htmlFor={`${id}-repeat`}>{t('password.repeat-label', 'Repeat the password')}</Label>
242
+ <PasswordInput
243
+ id={`${id}-repeat`}
244
+ name="repeat-password"
245
+ data-email-password-repeat
246
+ autoComplete="new-password"
247
+ maxLength={EMAIL_PASSWORD_MAX_LENGTH}
248
+ value={repeat}
249
+ readOnly={busy}
250
+ aria-invalid={shown?.kind === 'mismatch' || undefined}
251
+ reveal={reveal}
252
+ onReveal={setReveal}
253
+ showLabel={t('login.show-password', 'Show password')}
254
+ hideLabel={t('login.hide-password', 'Hide password')}
255
+ onChange={event => { setRepeat(event.target.value); setLocal(null) }}
256
+ />
257
+ </div>
258
+ </>}
259
+ </>}
260
+
261
+ <RefusalAlert refusal={shown} t={t} locale={locale} marker="data-email-password-error" className="text-left" />
262
+
263
+ <div className="flex flex-col gap-2 sm:flex-row">
264
+ {sent
265
+ ? <SubmitButton
266
+ data-email-password-confirm
267
+ busy={busy}
268
+ blocked={passwordPanelHelper.confirmBlock(action, { pin, password, repeat }) != null}
269
+ className="sm:w-auto"
270
+ >
271
+ {busy ? t('password.confirming', 'Saving…') : t('password.confirm', 'Confirm')}
272
+ </SubmitButton>
273
+ : <SubmitButton data-email-password-send busy={busy} className="sm:w-auto">
274
+ {busy ? t('password.sending', 'Sending…') : t('password.send', 'Send code')}
275
+ </SubmitButton>}
276
+ {sent && <Button
277
+ type="button"
278
+ variant="link"
279
+ data-email-password-resend
280
+ aria-disabled={busy || undefined}
281
+ className={control}
282
+ onClick={() => void send()}
283
+ >
284
+ {t('password.resend', 'Send a new code')}
285
+ </Button>}
286
+ <Button
287
+ type="button"
288
+ variant="ghost"
289
+ data-email-password-cancel
290
+ className={control}
291
+ onClick={close}
292
+ >
293
+ {t('password.cancel', 'Cancel')}
294
+ </Button>
295
+ </div>
296
+ </form>}
297
+ </CardContent>
298
+ </Card>
299
+ }
@@ -0,0 +1,20 @@
1
+ import type { FC } from 'react'
2
+ import { useContext } from '@owlmeans/client'
3
+ import type { AuthenticationRendererProps } from '@owlmeans/client-auth/manager'
4
+ import { EMAIL_LOGIN_SERVICE } from '@owlmeans/auth-email'
5
+ import type { EmailLoginClientService } from '../types.js'
6
+ import { LocalizedEmailLoginScreen } from './screen.js'
7
+
8
+ /**
9
+ * What the e-mail method's authentication route renders: the screen registered for this context
10
+ * (`appendEmailLogin({ screen })`, or a replacement from `registerEmailLoginScreen`), else the
11
+ * package's own.
12
+ */
13
+ export const EmailLoginRoute: FC<Partial<AuthenticationRendererProps>> = () => {
14
+ const ctx = useContext()
15
+ const Screen = ctx.hasService(EMAIL_LOGIN_SERVICE)
16
+ ? ctx.service<EmailLoginClientService>(EMAIL_LOGIN_SERVICE).screen() ?? LocalizedEmailLoginScreen
17
+ : LocalizedEmailLoginScreen
18
+
19
+ return <Screen />
20
+ }
@@ -0,0 +1,253 @@
1
+ import { isValidElement, useId, type FC, type FormEvent } from 'react'
2
+ import { Loader2 } from 'lucide-react'
3
+ import { useLanguage } from '@owlmeans/client-i18n'
4
+ import { EMAIL_ADDRESS_MAX_LENGTH } from '@owlmeans/auth-email'
5
+ import { Button } from '../@/components/ui/button.js'
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../@/components/ui/card.js'
7
+ import { Input } from '../@/components/ui/input.js'
8
+ import { Label } from '../@/components/ui/label.js'
9
+ import { cn } from '../@/lib/utils.js'
10
+ import { useEmailLogin } from '../hooks/use-email-login.js'
11
+ import { useEmailAuthTranslate } from '../hooks/use-translate.js'
12
+ import { emailLoginStateHelper } from '../state.js'
13
+ import { textUtils } from '../utils/text.js'
14
+ import type { EmailLoginScreenProps, Translate } from '../types.js'
15
+ import { CaptchaNotice, PasswordInput, PinInput, RefusalAlert, SubmitButton } from './fields.js'
16
+ import { EmailLoginCredit } from './credit.js'
17
+
18
+ const english: Translate = (_key, defaultValue) => defaultValue
19
+
20
+ /**
21
+ * The e-mail sign-in card — the chooser card's twin (`@owlmeans/web-panel`'s `LoginScreen`): the
22
+ * same centred card, logo, title, subtitle and footer slot, the same inline viewport height.
23
+ *
24
+ * Three steps share it: the address ("Receive authentication PIN code"), the mailed PIN, and — only
25
+ * where the deployment offers passwords — the address with a password. Enter submits the step on
26
+ * screen. Nothing on it is ever natively `disabled`.
27
+ *
28
+ * Pure with respect to i18n: `translate` is a prop (English without it), `locale` too.
29
+ * `LocalizedEmailLoginScreen` binds both to the application's own.
30
+ */
31
+ export const EmailLoginScreen: FC<EmailLoginScreenProps> = props => {
32
+ const t = props.translate ?? english
33
+ const model = useEmailLogin({ locale: props.locale })
34
+ const { state } = model
35
+ const id = useId()
36
+ const busy = state.busy != null
37
+ const Logo = props.Logo
38
+
39
+ const onSubmit = (event: FormEvent): void => {
40
+ event.preventDefault()
41
+ void model.submit()
42
+ }
43
+
44
+ const subtitle = props.subtitle ?? (state.step === 'password'
45
+ ? t('login.password-subtitle', 'Sign in with your email and password.')
46
+ : state.step === 'pin'
47
+ ? t('login.pin-subtitle', 'Check your inbox for the code.')
48
+ : t('login.subtitle', "We'll email you a code to sign in. New here? The same code creates your account."))
49
+
50
+ const emailField = (autoFocus: boolean) => <div className="flex flex-col gap-2 text-left">
51
+ <Label htmlFor={`${id}-email`}>{t('login.email-label', 'Email')}</Label>
52
+ <Input
53
+ id={`${id}-email`}
54
+ name="email"
55
+ data-email-login-email
56
+ type="email"
57
+ inputMode="email"
58
+ autoComplete="username"
59
+ autoCapitalize="none"
60
+ autoCorrect="off"
61
+ spellCheck={false}
62
+ maxLength={EMAIL_ADDRESS_MAX_LENGTH}
63
+ placeholder={t('login.email-placeholder', 'you@example.com')}
64
+ value={state.email}
65
+ readOnly={busy}
66
+ autoFocus={autoFocus}
67
+ aria-invalid={state.refusal?.kind === 'email' || undefined}
68
+ className="h-11 text-base"
69
+ onChange={event => model.setEmail(event.target.value)}
70
+ />
71
+ </div>
72
+
73
+ const link = 'h-11 px-2 text-sm'
74
+ // A tertiary button: no fill and no visible border until hovered, the primary text colour kept.
75
+ const tertiary = 'h-11 px-3 text-sm text-primary border border-transparent hover:border-border hover:text-primary'
76
+
77
+ const step = (() => {
78
+ switch (state.step) {
79
+ case 'pin':
80
+ return <form noValidate onSubmit={onSubmit} data-email-login-form="pin" className="flex flex-col gap-4">
81
+ <p role="status" data-email-login-notice={state.notice ?? 'sent'} className="text-center text-sm">
82
+ {state.notice === 'device'
83
+ ? t('login.device', "We don't recognise this device. Enter the code we sent to your email.")
84
+ : textUtils.fill(t('login.sent', 'We sent a sign-in code to {{email}}.'), {
85
+ email: emailLoginStateHelper.normalizeEmail(state.email),
86
+ })}
87
+ </p>
88
+ <div className="flex flex-col gap-2 text-left">
89
+ <Label htmlFor={`${id}-pin`}>{t('login.pin-label', 'Code from the email')}</Label>
90
+ <PinInput
91
+ id={`${id}-pin`}
92
+ name="pin"
93
+ data-email-login-pin
94
+ length={model.pinLength}
95
+ value={state.pin}
96
+ readOnly={busy}
97
+ autoFocus
98
+ aria-invalid={state.refusal?.kind === 'code' || state.refusal?.kind === 'pin-format' || undefined}
99
+ onChange={event => model.setPin(event.target.value)}
100
+ />
101
+ </div>
102
+ <SubmitButton
103
+ data-email-login-submit
104
+ data-mode="pin"
105
+ busy={state.busy === 'sign-in'}
106
+ blocked={emailLoginStateHelper.verifyBlock(state) != null}
107
+ >
108
+ {state.busy === 'sign-in' ? t('login.verifying', 'Signing in…') : t('login.verify', 'Sign in')}
109
+ </SubmitButton>
110
+ <div className="flex flex-col items-center justify-center gap-1 sm:flex-row sm:gap-4">
111
+ <Button
112
+ type="button"
113
+ variant="link"
114
+ data-email-login-resend
115
+ aria-busy={state.busy === 'send' || undefined}
116
+ className={link}
117
+ onClick={() => void model.send()}
118
+ >
119
+ {state.busy === 'send' ? t('login.sending', 'Sending…') : t('login.resend', 'Send a new code')}
120
+ </Button>
121
+ <Button type="button" variant="link" data-email-login-change className={link} onClick={model.changeEmail}>
122
+ {t('login.change-email', 'Change email')}
123
+ </Button>
124
+ </div>
125
+ </form>
126
+
127
+ case 'password':
128
+ return <form noValidate onSubmit={onSubmit} data-email-login-form="password" className="flex flex-col gap-4">
129
+ {emailField(false)}
130
+ <div className="flex flex-col gap-2 text-left">
131
+ <Label htmlFor={`${id}-password`}>{t('login.password-label', 'Password')}</Label>
132
+ <PasswordInput
133
+ id={`${id}-password`}
134
+ name="password"
135
+ data-email-login-password
136
+ autoComplete="current-password"
137
+ value={state.password}
138
+ readOnly={busy}
139
+ autoFocus
140
+ aria-invalid={state.refusal?.kind === 'credentials' || state.refusal?.kind === 'password-empty'
141
+ || undefined}
142
+ reveal={state.reveal}
143
+ onReveal={model.setReveal}
144
+ showLabel={t('login.show-password', 'Show password')}
145
+ hideLabel={t('login.hide-password', 'Hide password')}
146
+ toggleProps={{ 'data-email-login-password-toggle': '' }}
147
+ onChange={event => model.setPassword(event.target.value)}
148
+ />
149
+ </div>
150
+ <SubmitButton
151
+ data-email-login-submit
152
+ data-mode="password"
153
+ busy={state.busy === 'sign-in'}
154
+ blocked={emailLoginStateHelper.signInBlock(state) != null}
155
+ >
156
+ {state.busy === 'sign-in' ? t('login.verifying', 'Signing in…') : t('login.password-submit', 'Sign in')}
157
+ </SubmitButton>
158
+ <Button type="button" variant="link" data-email-login-use-pin className={link} onClick={model.choosePin}>
159
+ {t('login.use-pin', 'Sign in with a code instead')}
160
+ </Button>
161
+ </form>
162
+
163
+ case 'done':
164
+ return <p role="status" data-email-login-done className="flex items-center justify-center gap-2 text-sm">
165
+ <Loader2 className="size-4 animate-spin" aria-hidden />
166
+ {t('login.done', 'Signing you in…')}
167
+ </p>
168
+
169
+ default:
170
+ return <form noValidate onSubmit={onSubmit} data-email-login-form="email" className="flex flex-col gap-4">
171
+ {emailField(true)}
172
+ <SubmitButton
173
+ data-email-login-send
174
+ busy={state.busy === 'send'}
175
+ blocked={emailLoginStateHelper.sendBlock(state) != null}
176
+ >
177
+ {state.busy === 'send' ? t('login.sending', 'Sending…') : t('login.send', 'Receive authentication PIN code')}
178
+ </SubmitButton>
179
+ {model.passwordOffered && <Button
180
+ type="button"
181
+ variant="ghost"
182
+ data-email-login-use-password
183
+ className={tertiary}
184
+ onClick={model.choosePassword}
185
+ >
186
+ {t('login.use-password', 'Already set a password? Use it')}
187
+ </Button>}
188
+ </form>
189
+ }
190
+ })()
191
+
192
+ const card = <Card data-email-login-card className={cn('w-full max-w-sm', props.containerClassName)}>
193
+ <CardHeader className="items-center gap-2 text-center">
194
+ {Logo != null && <div className="flex justify-center pb-2">
195
+ {isValidElement(Logo) ? Logo : typeof Logo === 'function' ? <Logo className="h-10 w-auto" /> : Logo}
196
+ </div>}
197
+ <CardTitle className="text-xl">{props.title ?? t('login.title', 'Sign in with email')}</CardTitle>
198
+ <CardDescription>{subtitle}</CardDescription>
199
+ </CardHeader>
200
+
201
+ <CardContent className="flex flex-col gap-4">
202
+ {step}
203
+
204
+ <RefusalAlert refusal={state.refusal} t={t} locale={props.locale} marker="data-email-login-error" />
205
+
206
+ {state.optionsFailed && state.options == null && state.refusal == null
207
+ && <div data-email-login-options-error className="flex flex-col items-center gap-1 text-center text-sm">
208
+ <p role="alert" className="text-destructive">
209
+ {t('login.options-failed', "We couldn't load the sign-in settings.")}
210
+ </p>
211
+ <Button type="button" variant="link" data-email-login-retry className={link} onClick={() => void model.retryOptions()}>
212
+ {t('login.retry', 'Try again')}
213
+ </Button>
214
+ </div>}
215
+
216
+ {model.captcha && state.step !== 'pin' && state.step !== 'done' && <CaptchaNotice t={t} />}
217
+
218
+ {model.otherOptions != null && state.step !== 'done' && <a
219
+ href={model.otherOptions.href}
220
+ data-email-login-other
221
+ className="inline-flex h-11 items-center justify-center text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
222
+ onClick={model.otherOptions.go}
223
+ >
224
+ {t('login.other', 'Other sign-in options')}
225
+ </a>}
226
+ </CardContent>
227
+
228
+ <div className="px-6">
229
+ {props.footer ?? <EmailLoginCredit t={t} />}
230
+ </div>
231
+ </Card>
232
+
233
+ // The viewport height is INLINE, like the chooser's: this screen is rendered straight out of the
234
+ // authentication route into a chain that carries no height, and a utility class new to the
235
+ // consumer's stylesheet is exactly what a stale `@source` scan drops. `props.style` still wins.
236
+ return <div
237
+ data-email-login-screen
238
+ data-step={state.step}
239
+ data-busy={state.busy ?? undefined}
240
+ style={{ minHeight: '100dvh', ...props.style }}
241
+ className={cn('flex w-full items-center justify-center p-4', props.className)}
242
+ >
243
+ {card}
244
+ </div>
245
+ }
246
+
247
+ /** {@link EmailLoginScreen} bound to the application's own translations and language. */
248
+ export const LocalizedEmailLoginScreen: FC<EmailLoginScreenProps> = props => {
249
+ const t = useEmailAuthTranslate()
250
+ const [lng] = useLanguage()
251
+
252
+ return <EmailLoginScreen {...props} translate={props.translate ?? t} locale={props.locale ?? lng} />
253
+ }
@@ -0,0 +1,85 @@
1
+ import type { ComponentProps, ReactNode } from 'react'
2
+ import type { EmailPasswordPurpose, EmailPasswordStatusView } from '@owlmeans/auth-email'
3
+ import type { EmailRefusal } from '../refusal/types.js'
4
+ import type { EmailPasswordAction } from '../password/types.js'
5
+ import type { EmailPasswordAliases, Translate } from '../types.js'
6
+
7
+ /** A password field with a show/hide toggle; every other prop goes to the `<input>`. */
8
+ export interface PasswordInputProps extends Omit<ComponentProps<'input'>, 'type'> {
9
+ reveal: boolean
10
+ onReveal: (value: boolean) => void
11
+ /** The toggle's accessible name while the password is hidden. */
12
+ showLabel: string
13
+ /** The toggle's accessible name while the password is shown. */
14
+ hideLabel: string
15
+ /** `data-*` hooks for the toggle button. */
16
+ toggleProps?: Record<`data-${string}`, string>
17
+ }
18
+
19
+ /** A PIN field: digits only, the one-time-code autofill, as many characters as the PIN has. */
20
+ export interface PinInputProps extends Omit<ComponentProps<'input'>, 'type' | 'inputMode' | 'autoComplete' | 'maxLength'> {
21
+ length: number
22
+ }
23
+
24
+ /** A primary submit button that is never natively `disabled` — see the screen. */
25
+ export interface SubmitButtonProps extends ComponentProps<'button'> {
26
+ busy: boolean
27
+ /** The action cannot go yet; pressing it says why (`aria-disabled`, never `disabled`). */
28
+ blocked?: boolean
29
+ }
30
+
31
+ export interface RefusalAlertProps {
32
+ refusal: EmailRefusal | null
33
+ t: Translate
34
+ locale?: string
35
+ /** The `data-*` attribute that marks this alert for tests (`data-email-login-error`, …). */
36
+ marker: `data-${string}`
37
+ className?: string
38
+ }
39
+
40
+ export interface CaptchaNoticeProps {
41
+ t: Translate
42
+ className?: string
43
+ }
44
+
45
+ export interface EmailLoginCreditProps {
46
+ t: Translate
47
+ className?: string
48
+ }
49
+
50
+ /**
51
+ * The password surface, as a function of what it is given — no I/O: it renders what it is handed
52
+ * and reports what was pressed. `ConnectedPasswordPanel` is this panel wired to `usePassword`.
53
+ */
54
+ export interface PasswordPanelProps {
55
+ status: EmailPasswordStatusView | null
56
+ loading?: boolean
57
+ /** A PIN request or a confirmation is in flight. */
58
+ busy?: boolean
59
+ /** The purpose a confirmation PIN was mailed for; the panel shows the PIN step while it is set. */
60
+ pending?: EmailPasswordPurpose | null
61
+ refusal?: EmailRefusal | null
62
+ /** The last action that went through. */
63
+ done?: EmailPasswordAction | null
64
+ /** Mail a confirmation PIN. Resolves whether it was sent. */
65
+ onSendPin: (purpose: EmailPasswordPurpose) => Promise<boolean>
66
+ /** Confirm the open action. Resolves whether it went through; the panel closes the action then. */
67
+ onConfirm: (action: EmailPasswordAction, input: { pin: string, password?: string }) => Promise<boolean>
68
+ /** The open action was abandoned. */
69
+ onCancel: () => void
70
+ /** Read the status again after it could not be read. */
71
+ onReload?: () => Promise<void> | void
72
+ /** Already-translated header copy; the package's own by default. */
73
+ title?: ReactNode
74
+ description?: ReactNode
75
+ translate?: Translate
76
+ locale?: string
77
+ className?: string
78
+ }
79
+
80
+ export interface ConnectedPasswordPanelProps extends Pick<PasswordPanelProps, 'title' | 'description' | 'className'> {
81
+ /** Where the password routes are mounted; see `EmailPasswordAliases`. */
82
+ aliases?: EmailPasswordAliases
83
+ /** Overrides the package's own translator. */
84
+ translate?: Translate
85
+ }