@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.
- package/README.md +116 -2
- package/THIRD-PARTY-NOTICES.txt +24 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/web-auth-email/SKILL.md +119 -0
- package/build/@/components/ui/alert.d.ts +10 -0
- package/build/@/components/ui/alert.d.ts.map +1 -0
- package/build/@/components/ui/alert.js +26 -0
- package/build/@/components/ui/alert.js.map +1 -0
- package/build/@/components/ui/button.d.ts +11 -0
- package/build/@/components/ui/button.d.ts.map +1 -0
- package/build/@/components/ui/button.js +32 -0
- package/build/@/components/ui/button.js.map +1 -0
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/input.d.ts +4 -0
- package/build/@/components/ui/input.d.ts.map +1 -0
- package/build/@/components/ui/input.js +7 -0
- package/build/@/components/ui/input.js.map +1 -0
- package/build/@/components/ui/label.d.ts +5 -0
- package/build/@/components/ui/label.d.ts.map +1 -0
- package/build/@/components/ui/label.js +8 -0
- package/build/@/components/ui/label.js.map +1 -0
- package/build/@/lib/utils.d.ts +3 -0
- package/build/@/lib/utils.d.ts.map +1 -0
- package/build/@/lib/utils.js +6 -0
- package/build/@/lib/utils.js.map +1 -0
- package/build/append.d.ts +23 -0
- package/build/append.d.ts.map +1 -0
- package/build/append.js +49 -0
- package/build/append.js.map +1 -0
- package/build/components/connected.d.ts +8 -0
- package/build/components/connected.d.ts.map +1 -0
- package/build/components/connected.js +16 -0
- package/build/components/connected.js.map +1 -0
- package/build/components/credit.d.ts +5 -0
- package/build/components/credit.d.ts.map +1 -0
- package/build/components/credit.js +16 -0
- package/build/components/credit.js.map +1 -0
- package/build/components/fields.d.ts +23 -0
- package/build/components/fields.d.ts.map +1 -0
- package/build/components/fields.js +39 -0
- package/build/components/fields.js.map +1 -0
- package/build/components/index.d.ts +9 -0
- package/build/components/index.d.ts.map +1 -0
- package/build/components/index.js +8 -0
- package/build/components/index.js.map +1 -0
- package/build/components/offer.d.ts +9 -0
- package/build/components/offer.d.ts.map +1 -0
- package/build/components/offer.js +99 -0
- package/build/components/offer.js.map +1 -0
- package/build/components/panel.d.ts +12 -0
- package/build/components/panel.d.ts.map +1 -0
- package/build/components/panel.js +129 -0
- package/build/components/panel.js.map +1 -0
- package/build/components/route.d.ts +9 -0
- package/build/components/route.d.ts.map +1 -0
- package/build/components/route.js +17 -0
- package/build/components/route.js.map +1 -0
- package/build/components/screen.d.ts +17 -0
- package/build/components/screen.d.ts.map +1 -0
- package/build/components/screen.js +79 -0
- package/build/components/screen.js.map +1 -0
- package/build/components/types.d.ts +81 -0
- package/build/components/types.d.ts.map +1 -0
- package/build/components/types.js +2 -0
- package/build/components/types.js.map +1 -0
- package/build/consts.d.ts +37 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +43 -0
- package/build/consts.js.map +1 -0
- package/build/entrypoints.d.ts +10 -0
- package/build/entrypoints.d.ts.map +1 -0
- package/build/entrypoints.js +14 -0
- package/build/entrypoints.js.map +1 -0
- package/build/hooks/consts.local.d.ts +4 -0
- package/build/hooks/consts.local.d.ts.map +1 -0
- package/build/hooks/consts.local.js +9 -0
- package/build/hooks/consts.local.js.map +1 -0
- package/build/hooks/types.d.ts +78 -0
- package/build/hooks/types.d.ts.map +1 -0
- package/build/hooks/types.js +2 -0
- package/build/hooks/types.js.map +1 -0
- package/build/hooks/use-email-login.d.ts +13 -0
- package/build/hooks/use-email-login.d.ts.map +1 -0
- package/build/hooks/use-email-login.js +208 -0
- package/build/hooks/use-email-login.js.map +1 -0
- package/build/hooks/use-password.d.ts +13 -0
- package/build/hooks/use-password.d.ts.map +1 -0
- package/build/hooks/use-password.js +118 -0
- package/build/hooks/use-password.js.map +1 -0
- package/build/hooks/use-translate.d.ts +7 -0
- package/build/hooks/use-translate.d.ts.map +1 -0
- package/build/hooks/use-translate.js +12 -0
- package/build/hooks/use-translate.js.map +1 -0
- package/build/i18n/auth/be.json +7 -0
- package/build/i18n/auth/de.json +7 -0
- package/build/i18n/auth/en.json +7 -0
- package/build/i18n/auth/es.json +7 -0
- package/build/i18n/auth/fr.json +7 -0
- package/build/i18n/auth/pl.json +7 -0
- package/build/i18n/auth/ru.json +7 -0
- package/build/i18n/auth/uk.json +7 -0
- package/build/i18n/be.json +100 -0
- package/build/i18n/de.json +100 -0
- package/build/i18n/en.json +100 -0
- package/build/i18n/es.json +100 -0
- package/build/i18n/fr.json +100 -0
- package/build/i18n/pl.json +100 -0
- package/build/i18n/ru.json +100 -0
- package/build/i18n/uk.json +100 -0
- package/build/i18n.d.ts +2 -0
- package/build/i18n.d.ts.map +1 -0
- package/build/i18n.js +40 -0
- package/build/i18n.js.map +1 -0
- package/build/index.d.ts +23 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +17 -0
- package/build/index.js.map +1 -0
- package/build/password/types.d.ts +39 -0
- package/build/password/types.d.ts.map +1 -0
- package/build/password/types.js +2 -0
- package/build/password/types.js.map +1 -0
- package/build/password.d.ts +4 -0
- package/build/password.d.ts.map +1 -0
- package/build/password.js +45 -0
- package/build/password.js.map +1 -0
- package/build/plugin.d.ts +10 -0
- package/build/plugin.d.ts.map +1 -0
- package/build/plugin.js +21 -0
- package/build/plugin.js.map +1 -0
- package/build/refusal/consts.local.d.ts +3 -0
- package/build/refusal/consts.local.d.ts.map +1 -0
- package/build/refusal/consts.local.js +3 -0
- package/build/refusal/consts.local.js.map +1 -0
- package/build/refusal/types.d.ts +38 -0
- package/build/refusal/types.d.ts.map +1 -0
- package/build/refusal/types.js +2 -0
- package/build/refusal/types.js.map +1 -0
- package/build/refusal.d.ts +4 -0
- package/build/refusal.d.ts.map +1 -0
- package/build/refusal.js +131 -0
- package/build/refusal.js.map +1 -0
- package/build/service.d.ts +12 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +66 -0
- package/build/service.js.map +1 -0
- package/build/state/types.d.ts +90 -0
- package/build/state/types.d.ts.map +1 -0
- package/build/state/types.js +2 -0
- package/build/state/types.js.map +1 -0
- package/build/state.d.ts +4 -0
- package/build/state.d.ts.map +1 -0
- package/build/state.js +98 -0
- package/build/state.js.map +1 -0
- package/build/step.d.ts +16 -0
- package/build/step.d.ts.map +1 -0
- package/build/step.js +31 -0
- package/build/step.js.map +1 -0
- package/build/storage/types.d.ts +42 -0
- package/build/storage/types.d.ts.map +1 -0
- package/build/storage/types.js +2 -0
- package/build/storage/types.js.map +1 -0
- package/build/storage.d.ts +4 -0
- package/build/storage.d.ts.map +1 -0
- package/build/storage.js +100 -0
- package/build/storage.js.map +1 -0
- package/build/types.d.ts +102 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/account.d.ts +4 -0
- package/build/utils/account.d.ts.map +1 -0
- package/build/utils/account.js +11 -0
- package/build/utils/account.js.map +1 -0
- package/build/utils/locale.d.ts +6 -0
- package/build/utils/locale.d.ts.map +1 -0
- package/build/utils/locale.js +10 -0
- package/build/utils/locale.js.map +1 -0
- package/build/utils/text/types.d.ts +23 -0
- package/build/utils/text/types.d.ts.map +1 -0
- package/build/utils/text/types.js +2 -0
- package/build/utils/text/types.js.map +1 -0
- package/build/utils/text.d.ts +4 -0
- package/build/utils/text.d.ts.map +1 -0
- package/build/utils/text.js +26 -0
- package/build/utils/text.js.map +1 -0
- package/build/utils.d.ts +8 -0
- package/build/utils.d.ts.map +1 -0
- package/build/utils.js +9 -0
- package/build/utils.js.map +1 -0
- package/components.json +21 -0
- package/package.json +71 -4
- package/src/@/components/ui/alert.tsx +70 -0
- package/src/@/components/ui/button.tsx +60 -0
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/input.tsx +22 -0
- package/src/@/components/ui/label.tsx +23 -0
- package/src/@/globals.css +57 -0
- package/src/@/lib/utils.ts +6 -0
- package/src/append.ts +65 -0
- package/src/components/connected.tsx +36 -0
- package/src/components/credit.tsx +30 -0
- package/src/components/fields.tsx +112 -0
- package/src/components/index.ts +8 -0
- package/src/components/offer.tsx +197 -0
- package/src/components/panel.tsx +299 -0
- package/src/components/route.tsx +20 -0
- package/src/components/screen.tsx +253 -0
- package/src/components/types.ts +85 -0
- package/src/consts.ts +53 -0
- package/src/entrypoints.ts +15 -0
- package/src/hooks/consts.local.ts +12 -0
- package/src/hooks/types.ts +79 -0
- package/src/hooks/use-email-login.ts +234 -0
- package/src/hooks/use-password.ts +137 -0
- package/src/hooks/use-translate.ts +14 -0
- package/src/i18n/auth/be.json +7 -0
- package/src/i18n/auth/de.json +7 -0
- package/src/i18n/auth/en.json +7 -0
- package/src/i18n/auth/es.json +7 -0
- package/src/i18n/auth/fr.json +7 -0
- package/src/i18n/auth/pl.json +7 -0
- package/src/i18n/auth/ru.json +7 -0
- package/src/i18n/auth/uk.json +7 -0
- package/src/i18n/be.json +100 -0
- package/src/i18n/de.json +100 -0
- package/src/i18n/en.json +100 -0
- package/src/i18n/es.json +100 -0
- package/src/i18n/fr.json +100 -0
- package/src/i18n/pl.json +100 -0
- package/src/i18n/ru.json +100 -0
- package/src/i18n/uk.json +100 -0
- package/src/i18n.ts +43 -0
- package/src/index.ts +22 -0
- package/src/password/types.ts +43 -0
- package/src/password.ts +65 -0
- package/src/plugin.ts +22 -0
- package/src/refusal/consts.local.ts +2 -0
- package/src/refusal/types.ts +64 -0
- package/src/refusal.ts +145 -0
- package/src/service.ts +95 -0
- package/src/state/types.ts +72 -0
- package/src/state.ts +115 -0
- package/src/step.ts +38 -0
- package/src/storage/types.ts +43 -0
- package/src/storage.ts +113 -0
- package/src/types.ts +111 -0
- package/src/utils/account.ts +12 -0
- package/src/utils/locale.ts +11 -0
- package/src/utils/text/types.ts +24 -0
- package/src/utils/text.ts +34 -0
- package/src/utils.ts +9 -0
- package/tests/helpers.ts +22 -0
- package/tests/i18n.spec.ts +94 -0
- package/tests/package-boundary.spec.ts +35 -0
- package/tests/password.spec.ts +56 -0
- package/tests/refusal.spec.ts +91 -0
- package/tests/state.spec.ts +99 -0
- package/tests/step.spec.ts +82 -0
- package/tests/storage.spec.ts +83 -0
- package/tsconfig.json +21 -0
package/src/consts.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { EMAIL_AUTH_OFFER, EMAIL_LOGIN_METHOD } from '@owlmeans/auth-email'
|
|
2
|
+
import type { AuthMethodMeta } from '@owlmeans/client-auth/manager/plugins'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The library-tier i18n resource this package's own strings live in (`lib : auth-email.*`).
|
|
6
|
+
*
|
|
7
|
+
* An application overrides any of them by registering the same resource at the app tier, in the
|
|
8
|
+
* library namespace — see the README.
|
|
9
|
+
*/
|
|
10
|
+
export const EMAIL_AUTH_I18N = 'auth-email'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The library resource `@owlmeans/client-auth/login` fills with the sign-in screen's copy. This
|
|
14
|
+
* package adds one key to it — `login.method.email`, the chooser button's label.
|
|
15
|
+
*/
|
|
16
|
+
export const AUTH_I18N = 'auth'
|
|
17
|
+
|
|
18
|
+
/** Where the browser keeps its e-mail sign-in device id (`localStorage`). */
|
|
19
|
+
export const EMAIL_DEVICE_STORAGE = 'owlmeans:auth-email:device'
|
|
20
|
+
|
|
21
|
+
/** Where a PIN sign-in's one-use "set a password" offer ticket waits for the offer step (`sessionStorage`). */
|
|
22
|
+
export const EMAIL_OFFER_STORAGE = 'owlmeans:auth-email:offer'
|
|
23
|
+
|
|
24
|
+
/** Random bytes in a device id: 16 bytes are 22 base64url characters (`EMAIL_DEVICE_ID_PATTERN`). */
|
|
25
|
+
export const EMAIL_DEVICE_ID_BYTES = 16
|
|
26
|
+
|
|
27
|
+
/** `LoginStep.alias` of the post-sign-in password offer — the same name as its screen's route. */
|
|
28
|
+
export const EMAIL_OFFER_LOGIN_STEP = EMAIL_AUTH_OFFER
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Below the marketing-consent step (which declares none, so `0`): a Terms confirmation and the
|
|
32
|
+
* consent choices come first, the optional password offer last.
|
|
33
|
+
*/
|
|
34
|
+
export const EMAIL_OFFER_STEP_PRIORITY = -10
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How the e-mail method presents itself on the sign-in chooser. `appendEmailLogin({ method })`
|
|
38
|
+
* merges over it, and `cfg.security.auth.login.overrides.email` over both.
|
|
39
|
+
*/
|
|
40
|
+
export const EMAIL_LOGIN_METHOD_META: Readonly<AuthMethodMeta> = Object.freeze({
|
|
41
|
+
id: EMAIL_LOGIN_METHOD,
|
|
42
|
+
i18nKey: EMAIL_LOGIN_METHOD,
|
|
43
|
+
icon: 'mail',
|
|
44
|
+
order: 20,
|
|
45
|
+
emphasis: 'secondary',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** Google's documents the reCAPTCHA notice links to (the badge itself is hidden). */
|
|
49
|
+
export const RECAPTCHA_PRIVACY_URL = 'https://policies.google.com/privacy'
|
|
50
|
+
export const RECAPTCHA_TERMS_URL = 'https://policies.google.com/terms'
|
|
51
|
+
|
|
52
|
+
/** Where "Powered by OwlMeans" leads, as on the chooser card. */
|
|
53
|
+
export const OWLMEANS_URL = 'https://owlmeans.com'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { handler } from '@owlmeans/client'
|
|
2
|
+
import { bindScreen } from '@owlmeans/client-entrypoint'
|
|
3
|
+
import type { EmailAuthEntrypoints } from '@owlmeans/auth-email'
|
|
4
|
+
import { PasswordOfferScreen } from './components/offer.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The offer screen, bound and ready to spread into an application's entrypoint list.
|
|
8
|
+
*
|
|
9
|
+
* The API routes are NOT bound here: they are ordinary backend routes the application binds with
|
|
10
|
+
* its own `bindAll(protocols)`, beside the server's `serveEmailAuthEntrypoints` — the same split as
|
|
11
|
+
* `marketingConsentEntrypoints`.
|
|
12
|
+
*/
|
|
13
|
+
export const emailAuthEntrypoints = (protocols: EmailAuthEntrypoints) => [
|
|
14
|
+
bindScreen(protocols.offer, handler(PasswordOfferScreen)),
|
|
15
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EMAIL_AUTH_PASSWORD_PIN, EMAIL_AUTH_PASSWORD_REMOVE, EMAIL_AUTH_PASSWORD_SET, EMAIL_AUTH_PASSWORD_STATUS,
|
|
3
|
+
} from '@owlmeans/auth-email'
|
|
4
|
+
import type { EmailPasswordAliases } from '../types.js'
|
|
5
|
+
|
|
6
|
+
/** The aliases `makeEmailAuthProtocols` declares — what an application without the client service mounts. */
|
|
7
|
+
export const DEFAULT_PASSWORD_ALIASES: EmailPasswordAliases = {
|
|
8
|
+
status: EMAIL_AUTH_PASSWORD_STATUS,
|
|
9
|
+
pin: EMAIL_AUTH_PASSWORD_PIN,
|
|
10
|
+
set: EMAIL_AUTH_PASSWORD_SET,
|
|
11
|
+
remove: EMAIL_AUTH_PASSWORD_REMOVE,
|
|
12
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { MouseEvent } from 'react'
|
|
2
|
+
import type { EmailPasswordPurpose, EmailPasswordStatusView } from '@owlmeans/auth-email'
|
|
3
|
+
import type { EmailLoginState } from '../state/types.js'
|
|
4
|
+
import type { EmailRefusal } from '../refusal/types.js'
|
|
5
|
+
import type { EmailPasswordAction } from '../password/types.js'
|
|
6
|
+
|
|
7
|
+
export interface UseEmailLoginOptions {
|
|
8
|
+
/** The interface language; its base language travels with send-PIN and sign-in, for the mail. */
|
|
9
|
+
locale?: string
|
|
10
|
+
/** Pre-fill the address. */
|
|
11
|
+
email?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The "Other sign-in options" link back to the chooser. */
|
|
15
|
+
export interface EmailLoginOtherOptions {
|
|
16
|
+
href: string
|
|
17
|
+
go: (event?: MouseEvent) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The headless e-mail sign-in form — everything `EmailLoginScreen` renders from, for a replacement
|
|
22
|
+
* screen to render its own way (`registerEmailLoginScreen`).
|
|
23
|
+
*
|
|
24
|
+
* Every action is safe to call at any time: a call while another is in flight does nothing, and a
|
|
25
|
+
* call the form cannot make yet sets `state.refusal` (an invalid address, an incomplete code)
|
|
26
|
+
* instead of sending anything.
|
|
27
|
+
*/
|
|
28
|
+
export interface EmailLoginModel {
|
|
29
|
+
state: EmailLoginState
|
|
30
|
+
/** The deployment asked for a captcha — show Google's notice. */
|
|
31
|
+
captcha: boolean
|
|
32
|
+
/** Password sign-in is offered — show "Already set a password? Use it". */
|
|
33
|
+
passwordOffered: boolean
|
|
34
|
+
pinLength: number
|
|
35
|
+
setEmail: (value: string) => void
|
|
36
|
+
setPin: (value: string) => void
|
|
37
|
+
setPassword: (value: string) => void
|
|
38
|
+
setReveal: (value: boolean) => void
|
|
39
|
+
/** Mail a PIN (from the e-mail step) or a new one (from the PIN step). */
|
|
40
|
+
send: () => Promise<void>
|
|
41
|
+
/** Sign in with the typed PIN. */
|
|
42
|
+
verify: () => Promise<void>
|
|
43
|
+
/** Sign in with the password; an unknown device answers with a mailed PIN instead. */
|
|
44
|
+
signIn: () => Promise<void>
|
|
45
|
+
/** What Enter does on the current step. */
|
|
46
|
+
submit: () => Promise<void>
|
|
47
|
+
choosePassword: () => void
|
|
48
|
+
choosePin: () => void
|
|
49
|
+
changeEmail: () => void
|
|
50
|
+
/** Ask for the options again after they failed to load. */
|
|
51
|
+
retryOptions: () => Promise<void>
|
|
52
|
+
/** Null where the chooser is not bound in this application. */
|
|
53
|
+
otherOptions: EmailLoginOtherOptions | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UsePasswordOptions {
|
|
57
|
+
/** The interface language; its base language travels with the confirmation-PIN request. */
|
|
58
|
+
locale?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The I/O half of the password panel. Nothing thrown reaches the caller — it lands in `refusal`. */
|
|
62
|
+
export interface UsePassword {
|
|
63
|
+
status: EmailPasswordStatusView | null
|
|
64
|
+
loading: boolean
|
|
65
|
+
/** A PIN request or a confirmation is in flight. */
|
|
66
|
+
busy: boolean
|
|
67
|
+
/** The purpose a confirmation PIN was mailed for; null while none waits. */
|
|
68
|
+
pending: EmailPasswordPurpose | null
|
|
69
|
+
refusal: EmailRefusal | null
|
|
70
|
+
/** The last action that went through, for a short confirmation. */
|
|
71
|
+
done: EmailPasswordAction | null
|
|
72
|
+
reload: () => Promise<void>
|
|
73
|
+
/** Mail a confirmation PIN to the account's own address. Resolves whether it was sent. */
|
|
74
|
+
sendPin: (purpose: EmailPasswordPurpose) => Promise<boolean>
|
|
75
|
+
/** Confirm an action with the mailed PIN (and the new password for set/change). Resolves whether it went through. */
|
|
76
|
+
confirm: (action: EmailPasswordAction, input: { pin: string, password?: string }) => Promise<boolean>
|
|
77
|
+
/** Abandon a pending confirmation. */
|
|
78
|
+
cancel: () => void
|
|
79
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef, type MouseEvent } from 'react'
|
|
2
|
+
import { useContext, useNavigate } from '@owlmeans/client'
|
|
3
|
+
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
4
|
+
import { DISPATCHER } from '@owlmeans/auth'
|
|
5
|
+
import {
|
|
6
|
+
EMAIL_CAPTCHA_ACTION_PASSWORD, EMAIL_CAPTCHA_ACTION_PIN, EMAIL_LOGIN_SERVICE, EmailAuthError, EmailAuthUnavailable,
|
|
7
|
+
EmailSignInReason,
|
|
8
|
+
} from '@owlmeans/auth-email'
|
|
9
|
+
import type { EmailAuthOptionsView, EmailSignInView } from '@owlmeans/auth-email'
|
|
10
|
+
import { captchaLoader } from '@owlmeans/web-captcha'
|
|
11
|
+
import { useCaptcha, type UseCaptchaResult } from '@owlmeans/web-captcha/react'
|
|
12
|
+
import { logger } from '@owlmeans/log'
|
|
13
|
+
import { emailLoginStateHelper as helper } from '../state.js'
|
|
14
|
+
import { emailRefusalHelper } from '../refusal.js'
|
|
15
|
+
import { emailStorageHelper } from '../storage.js'
|
|
16
|
+
import { wireLocale } from '../utils/locale.js'
|
|
17
|
+
import type { EmailRefusalContext } from '../refusal/types.js'
|
|
18
|
+
import type { EmailLoginClientService } from '../types.js'
|
|
19
|
+
import type { EmailLoginModel, EmailLoginOtherOptions, UseEmailLoginOptions } from './types.js'
|
|
20
|
+
|
|
21
|
+
const log = logger('auth-email')
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The e-mail sign-in form: an address, then a mailed PIN — or, where the deployment offers
|
|
25
|
+
* passwords, the address and a password, which on a device the account does not know turns into a
|
|
26
|
+
* mailed PIN as well. A captcha token is fetched right before each send-PIN and password sign-in
|
|
27
|
+
* when the deployment asks for one; the PIN step needs none.
|
|
28
|
+
*
|
|
29
|
+
* Every transition is `emailLoginStateHelper.reduce`; this hook only performs the calls and
|
|
30
|
+
* dispatches what they answered. A successful answer is finished by the client service
|
|
31
|
+
* (`complete`), which leaves the page.
|
|
32
|
+
*/
|
|
33
|
+
export const useEmailLogin = (opts: UseEmailLoginOptions = {}): EmailLoginModel => {
|
|
34
|
+
const ctx = useContext()
|
|
35
|
+
const nav = useNavigate()
|
|
36
|
+
const client = ctx.service<EmailLoginClientService>(EMAIL_LOGIN_SERVICE)
|
|
37
|
+
const locale = wireLocale(opts.locale)
|
|
38
|
+
|
|
39
|
+
const [state, dispatch] = useReducer(helper.reduce, opts.email, helper.initial)
|
|
40
|
+
// Handlers read the latest form, not the one of the render that created them.
|
|
41
|
+
const current = useRef(state)
|
|
42
|
+
current.current = state
|
|
43
|
+
|
|
44
|
+
const siteKey = state.options?.captcha?.siteKey ?? null
|
|
45
|
+
const pinCaptcha = useCaptcha({ siteKey, action: EMAIL_CAPTCHA_ACTION_PIN })
|
|
46
|
+
const passwordCaptcha = useCaptcha({ siteKey, action: EMAIL_CAPTCHA_ACTION_PASSWORD })
|
|
47
|
+
|
|
48
|
+
const loadOptions = useCallback(async (fresh?: boolean): Promise<EmailAuthOptionsView | null> => {
|
|
49
|
+
try {
|
|
50
|
+
const options = await client.options(fresh === true ? { fresh } : undefined)
|
|
51
|
+
dispatch({ type: 'options', options })
|
|
52
|
+
|
|
53
|
+
return options
|
|
54
|
+
} catch (error) {
|
|
55
|
+
log.warn('E-mail sign-in options unavailable', error)
|
|
56
|
+
dispatch({ type: 'options-failed' })
|
|
57
|
+
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}, [client])
|
|
61
|
+
|
|
62
|
+
useEffect(() => { void loadOptions() }, [loadOptions])
|
|
63
|
+
|
|
64
|
+
const refuse = useCallback((error: unknown, context: EmailRefusalContext) => {
|
|
65
|
+
const refusal = emailRefusalHelper.refusalOf(error, context)
|
|
66
|
+
// The kind and the call only — never the address, the code or the password.
|
|
67
|
+
log.debug('E-mail sign-in refused', { context, kind: refusal.kind })
|
|
68
|
+
dispatch({ type: 'refused', refusal })
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
/** A token for this action — through the hook's own captcha when it is the one loaded. */
|
|
72
|
+
const captchaToken = useCallback(async (
|
|
73
|
+
options: EmailAuthOptionsView, captcha: UseCaptchaResult, action: string,
|
|
74
|
+
): Promise<string | undefined> => {
|
|
75
|
+
if (options.captcha == null || options.captcha.siteKey === '') {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
const token = options.captcha.siteKey === siteKey
|
|
79
|
+
? await captcha.execute()
|
|
80
|
+
: await captchaLoader.token(options.captcha.siteKey, action)
|
|
81
|
+
|
|
82
|
+
return token ?? undefined
|
|
83
|
+
}, [siteKey])
|
|
84
|
+
|
|
85
|
+
/** The options this call runs under: the loaded ones, else a fresh read. */
|
|
86
|
+
const optionsNow = useCallback(async (): Promise<EmailAuthOptionsView> => {
|
|
87
|
+
const options = current.current.options ?? await loadOptions(true)
|
|
88
|
+
if (options == null) {
|
|
89
|
+
throw new EmailAuthUnavailable('options')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return options
|
|
93
|
+
}, [loadOptions])
|
|
94
|
+
|
|
95
|
+
const finish = useCallback(async (view: EmailSignInView): Promise<void> => {
|
|
96
|
+
if (view.token != null && view.token !== '') {
|
|
97
|
+
dispatch({ type: 'signed-in' })
|
|
98
|
+
await client.complete(view)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (view.challenge != null && view.reason === EmailSignInReason.Device) {
|
|
102
|
+
dispatch({ type: 'device', challenge: view.challenge })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
throw new EmailAuthError('answer')
|
|
106
|
+
}, [client])
|
|
107
|
+
|
|
108
|
+
const send = useCallback(async () => {
|
|
109
|
+
const form = current.current
|
|
110
|
+
if (form.busy != null || form.step === 'done') {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const block = helper.sendBlock(form)
|
|
114
|
+
if (block != null) {
|
|
115
|
+
dispatch({ type: 'refused', refusal: block })
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
dispatch({ type: 'start', busy: 'send' })
|
|
119
|
+
try {
|
|
120
|
+
const options = await optionsNow()
|
|
121
|
+
const captcha = await captchaToken(options, pinCaptcha, EMAIL_CAPTCHA_ACTION_PIN)
|
|
122
|
+
const { challenge } = await client.sendPin({
|
|
123
|
+
email: helper.normalizeEmail(form.email),
|
|
124
|
+
...(locale != null ? { locale } : {}),
|
|
125
|
+
...(captcha != null ? { captcha } : {}),
|
|
126
|
+
})
|
|
127
|
+
dispatch({ type: 'sent', challenge })
|
|
128
|
+
} catch (error) {
|
|
129
|
+
refuse(error, 'send')
|
|
130
|
+
}
|
|
131
|
+
}, [client, locale, optionsNow, captchaToken, pinCaptcha, refuse])
|
|
132
|
+
|
|
133
|
+
const verify = useCallback(async () => {
|
|
134
|
+
const form = current.current
|
|
135
|
+
if (form.busy != null || form.step !== 'pin') {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const block = helper.verifyBlock(form)
|
|
139
|
+
if (block != null) {
|
|
140
|
+
dispatch({ type: 'refused', refusal: block })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
dispatch({ type: 'start', busy: 'sign-in' })
|
|
144
|
+
try {
|
|
145
|
+
await finish(await client.signIn({
|
|
146
|
+
email: helper.normalizeEmail(form.email),
|
|
147
|
+
device: emailStorageHelper.deviceId(),
|
|
148
|
+
challenge: form.challenge!,
|
|
149
|
+
pin: form.pin,
|
|
150
|
+
...(locale != null ? { locale } : {}),
|
|
151
|
+
}))
|
|
152
|
+
} catch (error) {
|
|
153
|
+
refuse(error, 'pin')
|
|
154
|
+
}
|
|
155
|
+
}, [client, locale, finish, refuse])
|
|
156
|
+
|
|
157
|
+
const signIn = useCallback(async () => {
|
|
158
|
+
const form = current.current
|
|
159
|
+
if (form.busy != null || form.step !== 'password') {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const block = helper.signInBlock(form)
|
|
163
|
+
if (block != null) {
|
|
164
|
+
dispatch({ type: 'refused', refusal: block })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
dispatch({ type: 'start', busy: 'sign-in' })
|
|
168
|
+
try {
|
|
169
|
+
const options = await optionsNow()
|
|
170
|
+
const captcha = await captchaToken(options, passwordCaptcha, EMAIL_CAPTCHA_ACTION_PASSWORD)
|
|
171
|
+
await finish(await client.signIn({
|
|
172
|
+
email: helper.normalizeEmail(form.email),
|
|
173
|
+
device: emailStorageHelper.deviceId(),
|
|
174
|
+
password: form.password,
|
|
175
|
+
...(locale != null ? { locale } : {}),
|
|
176
|
+
...(captcha != null ? { captcha } : {}),
|
|
177
|
+
}))
|
|
178
|
+
} catch (error) {
|
|
179
|
+
refuse(error, 'password')
|
|
180
|
+
}
|
|
181
|
+
}, [client, locale, optionsNow, captchaToken, passwordCaptcha, finish, refuse])
|
|
182
|
+
|
|
183
|
+
const submit = useCallback(async () => {
|
|
184
|
+
switch (current.current.step) {
|
|
185
|
+
case 'email':
|
|
186
|
+
return await send()
|
|
187
|
+
case 'pin':
|
|
188
|
+
return await verify()
|
|
189
|
+
case 'password':
|
|
190
|
+
return await signIn()
|
|
191
|
+
default:
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
}, [send, verify, signIn])
|
|
195
|
+
|
|
196
|
+
const retryOptions = useCallback(async () => { await loadOptions(true) }, [loadOptions])
|
|
197
|
+
|
|
198
|
+
const otherOptions = useMemo((): EmailLoginOtherOptions | null => {
|
|
199
|
+
let href: string
|
|
200
|
+
try {
|
|
201
|
+
href = ctx.entrypoint<ClientEntrypoint>(DISPATCHER).path()
|
|
202
|
+
} catch {
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
href,
|
|
208
|
+
go: (event?: MouseEvent) => {
|
|
209
|
+
event?.preventDefault()
|
|
210
|
+
void nav.go(DISPATCHER)
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}, [ctx, nav])
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
state,
|
|
217
|
+
captcha: state.options?.captcha != null,
|
|
218
|
+
passwordOffered: helper.passwordOffered(state),
|
|
219
|
+
pinLength: helper.pinLength(state),
|
|
220
|
+
setEmail: useCallback((value: string) => dispatch({ type: 'email', value }), []),
|
|
221
|
+
setPin: useCallback((value: string) => dispatch({ type: 'pin', value }), []),
|
|
222
|
+
setPassword: useCallback((value: string) => dispatch({ type: 'password', value }), []),
|
|
223
|
+
setReveal: useCallback((value: boolean) => dispatch({ type: 'reveal', value }), []),
|
|
224
|
+
send,
|
|
225
|
+
verify,
|
|
226
|
+
signIn,
|
|
227
|
+
submit,
|
|
228
|
+
choosePassword: useCallback(() => dispatch({ type: 'choose-password' }), []),
|
|
229
|
+
choosePin: useCallback(() => dispatch({ type: 'choose-pin' }), []),
|
|
230
|
+
changeEmail: useCallback(() => dispatch({ type: 'change-email' }), []),
|
|
231
|
+
retryOptions,
|
|
232
|
+
otherOptions,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useContext } from '@owlmeans/client'
|
|
3
|
+
import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
|
|
4
|
+
import { EMAIL_LOGIN_SERVICE } from '@owlmeans/auth-email'
|
|
5
|
+
import type {
|
|
6
|
+
EmailChallengeView, EmailPasswordPurpose, EmailPasswordStatusView,
|
|
7
|
+
} from '@owlmeans/auth-email'
|
|
8
|
+
import { logger } from '@owlmeans/log'
|
|
9
|
+
import { emailRefusalHelper } from '../refusal.js'
|
|
10
|
+
import { emailStorageHelper } from '../storage.js'
|
|
11
|
+
import { wireLocale } from '../utils/locale.js'
|
|
12
|
+
import type { EmailRefusal, EmailRefusalContext } from '../refusal/types.js'
|
|
13
|
+
import type { EmailPasswordAction } from '../password/types.js'
|
|
14
|
+
import type { EmailLoginClientService, EmailPasswordAliases } from '../types.js'
|
|
15
|
+
import { DEFAULT_PASSWORD_ALIASES } from './consts.local.js'
|
|
16
|
+
import type { UsePassword, UsePasswordOptions } from './types.js'
|
|
17
|
+
|
|
18
|
+
const log = logger('auth-email')
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The I/O half of the password panel: the account's password state, a confirmation PIN, and the
|
|
22
|
+
* set / change / remove calls confirmed by it.
|
|
23
|
+
*
|
|
24
|
+
* The routes are addressed by alias: `aliases`, else those of the tree `appendEmailLogin` was given,
|
|
25
|
+
* else the `EMAIL_AUTH_PASSWORD_*` defaults. The mailed challenge never leaves this hook. The
|
|
26
|
+
* current browser's device id goes with every confirmation, so it stays remembered while the
|
|
27
|
+
* account's other devices are forgotten.
|
|
28
|
+
*/
|
|
29
|
+
export const usePassword = (aliases?: EmailPasswordAliases, opts: UsePasswordOptions = {}): UsePassword => {
|
|
30
|
+
const ctx = useContext()
|
|
31
|
+
const locale = wireLocale(opts.locale)
|
|
32
|
+
|
|
33
|
+
const defaults = useMemo((): EmailPasswordAliases => {
|
|
34
|
+
if (!ctx.hasService(EMAIL_LOGIN_SERVICE)) {
|
|
35
|
+
return DEFAULT_PASSWORD_ALIASES
|
|
36
|
+
}
|
|
37
|
+
const password = ctx.service<EmailLoginClientService>(EMAIL_LOGIN_SERVICE).protocols().password
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
status: password.status.alias, pin: password.pin.alias, set: password.set.alias, remove: password.remove.alias,
|
|
41
|
+
}
|
|
42
|
+
}, [ctx])
|
|
43
|
+
const statusAlias = aliases?.status ?? defaults.status
|
|
44
|
+
const pinAlias = aliases?.pin ?? defaults.pin
|
|
45
|
+
const setAlias = aliases?.set ?? defaults.set
|
|
46
|
+
const removeAlias = aliases?.remove ?? defaults.remove
|
|
47
|
+
|
|
48
|
+
const [status, setStatus] = useState<EmailPasswordStatusView | null>(null)
|
|
49
|
+
const [loading, setLoading] = useState(true)
|
|
50
|
+
const [busy, setBusy] = useState(false)
|
|
51
|
+
const [pending, setPending] = useState<EmailPasswordPurpose | null>(null)
|
|
52
|
+
const [refusal, setRefusal] = useState<EmailRefusal | null>(null)
|
|
53
|
+
const [done, setDone] = useState<EmailPasswordAction | null>(null)
|
|
54
|
+
const challenge = useRef<string | null>(null)
|
|
55
|
+
|
|
56
|
+
const refuse = useCallback((error: unknown, context: EmailRefusalContext) => {
|
|
57
|
+
const next = emailRefusalHelper.refusalOf(error, context)
|
|
58
|
+
log.debug('Password action refused', { context, kind: next.kind })
|
|
59
|
+
setRefusal(next)
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
const reload = useCallback(async () => {
|
|
63
|
+
setLoading(true)
|
|
64
|
+
setRefusal(null)
|
|
65
|
+
try {
|
|
66
|
+
setStatus(await ctx.entrypoint<ClientEntrypoint<EmailPasswordStatusView>>(statusAlias).call())
|
|
67
|
+
} catch (error) {
|
|
68
|
+
refuse(error, 'status')
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false)
|
|
71
|
+
}
|
|
72
|
+
}, [ctx, statusAlias, refuse])
|
|
73
|
+
|
|
74
|
+
useEffect(() => { void reload() }, [reload])
|
|
75
|
+
|
|
76
|
+
const sendPin = useCallback(async (purpose: EmailPasswordPurpose): Promise<boolean> => {
|
|
77
|
+
setBusy(true)
|
|
78
|
+
setRefusal(null)
|
|
79
|
+
setDone(null)
|
|
80
|
+
try {
|
|
81
|
+
const view = await ctx.entrypoint<ClientEntrypoint<EmailChallengeView>>(pinAlias).call({
|
|
82
|
+
body: { purpose, ...(locale != null ? { locale } : {}) },
|
|
83
|
+
})
|
|
84
|
+
challenge.current = view.challenge
|
|
85
|
+
setPending(purpose)
|
|
86
|
+
|
|
87
|
+
return true
|
|
88
|
+
} catch (error) {
|
|
89
|
+
refuse(error, 'confirm')
|
|
90
|
+
|
|
91
|
+
return false
|
|
92
|
+
} finally {
|
|
93
|
+
setBusy(false)
|
|
94
|
+
}
|
|
95
|
+
}, [ctx, pinAlias, locale, refuse])
|
|
96
|
+
|
|
97
|
+
const confirm = useCallback(async (
|
|
98
|
+
action: EmailPasswordAction, input: { pin: string, password?: string },
|
|
99
|
+
): Promise<boolean> => {
|
|
100
|
+
if (challenge.current == null) {
|
|
101
|
+
setRefusal({ kind: 'code' })
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
setBusy(true)
|
|
105
|
+
setRefusal(null)
|
|
106
|
+
try {
|
|
107
|
+
const device = emailStorageHelper.deviceId()
|
|
108
|
+
const next = action === 'remove'
|
|
109
|
+
? await ctx.entrypoint<ClientEntrypoint<EmailPasswordStatusView>>(removeAlias).call({
|
|
110
|
+
body: { challenge: challenge.current, pin: input.pin, device },
|
|
111
|
+
})
|
|
112
|
+
: await ctx.entrypoint<ClientEntrypoint<EmailPasswordStatusView>>(setAlias).call({
|
|
113
|
+
body: { password: input.password ?? '', challenge: challenge.current, pin: input.pin, device },
|
|
114
|
+
})
|
|
115
|
+
challenge.current = null
|
|
116
|
+
setStatus(next)
|
|
117
|
+
setPending(null)
|
|
118
|
+
setDone(action)
|
|
119
|
+
|
|
120
|
+
return true
|
|
121
|
+
} catch (error) {
|
|
122
|
+
refuse(error, action === 'remove' ? 'remove' : 'set')
|
|
123
|
+
|
|
124
|
+
return false
|
|
125
|
+
} finally {
|
|
126
|
+
setBusy(false)
|
|
127
|
+
}
|
|
128
|
+
}, [ctx, setAlias, removeAlias, refuse])
|
|
129
|
+
|
|
130
|
+
const cancel = useCallback(() => {
|
|
131
|
+
challenge.current = null
|
|
132
|
+
setPending(null)
|
|
133
|
+
setRefusal(null)
|
|
134
|
+
}, [])
|
|
135
|
+
|
|
136
|
+
return { status, loading, busy, pending, refusal, done, reload, sendPin, confirm, cancel }
|
|
137
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useI18nLib } from '@owlmeans/client-i18n'
|
|
3
|
+
import { EMAIL_AUTH_I18N } from '../consts.js'
|
|
4
|
+
import type { Translate } from '../types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* This package's own translator (`lib : auth-email`), in the `(key, defaultValue)` shape every
|
|
8
|
+
* component here takes as its `translate` prop. Needs the application's i18n provider.
|
|
9
|
+
*/
|
|
10
|
+
export const useEmailAuthTranslate = (): Translate => {
|
|
11
|
+
const t = useI18nLib(EMAIL_AUTH_I18N)
|
|
12
|
+
|
|
13
|
+
return useCallback((key: string, defaultValue: string) => t(key, { defaultValue }), [t])
|
|
14
|
+
}
|