@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/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,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
|
+
}
|