mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
Registration says an address already has an account before any code, and offers sign-in and a reset
The first submit carries no code, so IAM answers a taken address or a refused password there. A taken address shows Sign in and Reset password, both opening on that address for the same request. Recovery prefills from login_hint and returns to the same sign-in. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -22,9 +22,13 @@ const HOSTS = [
|
||||
|
||||
type Host = (typeof HOSTS)[number]
|
||||
|
||||
/** `/v1/iam/auth/application` and `/v1/iam/auth/methods` for an app offering everything. */
|
||||
/**
|
||||
* `/v1/iam/auth/application` and `/v1/iam/auth/methods` for an app offering
|
||||
* everything, and a directory where every address already has an account.
|
||||
*/
|
||||
function iam(path: string, h: Host, enableSignUp: boolean): unknown {
|
||||
if (path === '/v1/iam/auth/methods') return { status: 'ok', data: { web3Chains: ['evm'] } }
|
||||
if (path === '/v1/iam/signup') return { status: 'error', msg: 'email already exists' }
|
||||
if (path === '/v1/iam/auth/application') {
|
||||
const provider = (key: string, type: string) => ({
|
||||
name: `provider-${key}`,
|
||||
@@ -151,4 +155,39 @@ for (const h of HOSTS) {
|
||||
await expect(page.getByText('No account?')).toHaveCount(0)
|
||||
await expect(page.locator('a[href^="/signup"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test(`${h.host}: an address that already has an account hears it before any code, with sign-in and a reset filled in`, async ({ context, page }, info) => {
|
||||
await serve(context, h)
|
||||
const sends: string[] = []
|
||||
page.on('request', (r) => r.url().includes('/verification-codes') && sends.push(r.url()))
|
||||
await page.goto(`https://${h.host}/signup?${REQUEST}`)
|
||||
await page.getByLabel('Email').fill('[email protected]')
|
||||
await page.getByLabel('Password', { exact: true }).fill('correct horse battery staple')
|
||||
await page.getByRole('button', { name: 'Create account' }).click()
|
||||
|
||||
await expect(page.getByRole('status')).toHaveText('[email protected] already has an account.')
|
||||
await expect(page.getByLabel('Code')).toHaveCount(0)
|
||||
expect(sends).toEqual([])
|
||||
await page.screenshot({ path: info.outputPath(`${h.host}-taken-${page.viewportSize()!.width}.png`), fullPage: true })
|
||||
const hinted = [...REQUEST, ['login_hint', '[email protected]']]
|
||||
const reset = (await page.getByRole('link', { name: 'Reset password' }).getAttribute('href'))!
|
||||
|
||||
// Sign in opens on the address, for the same request.
|
||||
await page.locator('.hanzo-id-form').getByRole('link', { name: 'Sign in' }).click()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Sign in')
|
||||
await expect(page.getByLabel('Email or username')).toHaveValue('[email protected]')
|
||||
let at = new URL(page.url())
|
||||
expect(at.pathname).toBe('/login')
|
||||
expect([...at.searchParams]).toEqual(hinted)
|
||||
|
||||
// Reset opens on the address too, and leads back to the same sign-in.
|
||||
await page.goto(`https://${h.host}${reset}`)
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(/^Get back into your .+ account$/)
|
||||
await expect(page.getByLabel('Email')).toHaveValue('[email protected]')
|
||||
await page.getByRole('link', { name: 'Back to sign in' }).click()
|
||||
await expect(page.getByLabel('Email or username')).toHaveValue('[email protected]')
|
||||
at = new URL(page.url())
|
||||
expect(at.pathname).toBe('/login')
|
||||
expect([...at.searchParams]).toEqual(hinted)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Brand } from '@hanzo/id-shared'
|
||||
import { ForgotForm, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandFooter } from '../components/BrandFooter'
|
||||
import { hintFrom, signinHref } from '../route'
|
||||
|
||||
export function Forgot({ client, brand }: { client: AuthClient; brand: Brand }) {
|
||||
// Sign-in for the request that led here, so recovery ends at the app that asked.
|
||||
const signin = signinHref(window.location.pathname, window.location.search)
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-forgot">
|
||||
<main>
|
||||
@@ -10,9 +13,9 @@ export function Forgot({ client, brand }: { client: AuthClient; brand: Brand })
|
||||
address on the account and send a code that signs you back in. The new
|
||||
password comes after, from inside the account. */}
|
||||
<h1>Get back into your {brand.name} account</h1>
|
||||
<ForgotForm client={client} />
|
||||
<ForgotForm client={client} identifier={hintFrom(window.location.search)} signinHref={signin} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/login">Back to sign in</a>
|
||||
<a href={signin}>Back to sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
<BrandFooter brand={brand} org={client.org} />
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type BrowserAccount,
|
||||
type LoginResponse, attachParkedWallet } from '@hanzo/id-auth'
|
||||
import { BrandFooter } from '../components/BrandFooter'
|
||||
import { clientIdFrom, signupHref } from '../route'
|
||||
import { clientIdFrom, hintFrom, signupHref } from '../route'
|
||||
|
||||
export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
@@ -66,9 +66,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
|
||||
// here only after declining to answer from a session, and forwards login_hint
|
||||
// with it: the account an application named, which starts the form filled in.
|
||||
const choosing = Boolean(redirectUri) && (sp.get('prompt') ?? '').split(' ').includes('select_account')
|
||||
const hint = sp.get('login_hint') ?? ''
|
||||
// A subject (a UUID, or owner/name) names an account but is nothing a person types.
|
||||
const loginHint = hint && !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(hint) && !hint.includes('/') ? hint : undefined
|
||||
const loginHint = hintFrom(window.location.search)
|
||||
const [phase, setPhase] = useState<'federate' | 'choose' | 'form' | 'register'>(
|
||||
providerHint ? 'federate' : wantsSignup ? 'register' : choosing ? 'choose' : 'form',
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { clientIdFrom, signinHref, signupHref } from './route'
|
||||
import { clientIdFrom, hintFrom, signinHref, signupHref } from './route'
|
||||
|
||||
test('the OAuth query shape wins — it is the one carrying a redirect_uri', () => {
|
||||
assert.equal(clientIdFrom('?client_id=hanzo-chat', '/signup'), 'hanzo-chat')
|
||||
@@ -124,3 +124,10 @@ test('the two links are inverses: sign-in to registration and back is the same r
|
||||
}
|
||||
assert.equal(parts(signinHref('/signup/hanzo-chat', SEARCH)).path, '/login/hanzo-chat')
|
||||
})
|
||||
|
||||
test('login_hint starts the field with an address, never with a subject', () => {
|
||||
assert.equal(hintFrom('?login_hint=ada%40example.com&client_id=hanzo-app'), '[email protected]')
|
||||
assert.equal(hintFrom('?login_hint=3f2504e0-4f89-11d3-9a0c-0305e82c3301'), undefined)
|
||||
assert.equal(hintFrom('?login_hint=hanzo%2Fada'), undefined)
|
||||
assert.equal(hintFrom('?client_id=hanzo-app'), undefined)
|
||||
})
|
||||
|
||||
@@ -72,3 +72,15 @@ export function signinHref(pathname: string, search: string): string {
|
||||
const rest = q.toString()
|
||||
return `/login${seg ? `/${seg}` : ''}${rest ? `?${rest}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The address a page starts its identifier field with, from `login_hint`.
|
||||
*
|
||||
* IAM forwards the hint an application named, and registration sends the address
|
||||
* it found already taken. A subject (a UUID, or owner/name) names an account but
|
||||
* is nothing a person types, so it starts the field empty.
|
||||
*/
|
||||
export function hintFrom(search: string): string | undefined {
|
||||
const hint = new URLSearchParams(search).get('login_hint') ?? ''
|
||||
return hint && !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(hint) && !hint.includes('/') ? hint : undefined
|
||||
}
|
||||
|
||||
@@ -151,3 +151,28 @@ test('a send that fails does not advance to the code step', async () => {
|
||||
await waitFor(() => assert.match(document.body.textContent!, /cannot be delivered/))
|
||||
assert.equal(field('Code'), null, 'the code step opened for a code nobody sent')
|
||||
})
|
||||
|
||||
// Registration hands over the address it found taken, so recovery starts filled
|
||||
// in and ends at sign-in for the same request.
|
||||
test('the address arrives filled in, and the new password leads to the same sign-in', async () => {
|
||||
const { fetchImpl } = iam()
|
||||
render(
|
||||
<ForgotForm
|
||||
client={createAuthClient({ org: org(), fetchImpl })}
|
||||
identifier="[email protected]"
|
||||
signinHref="/login?client_id=hanzo-console&login_hint=ada%40example.com"
|
||||
/>,
|
||||
)
|
||||
|
||||
const email = document.querySelector('input[type="email"]') as HTMLInputElement
|
||||
assert.equal(email.value, '[email protected]')
|
||||
fireEvent.submit(document.querySelector('form')!)
|
||||
await waitFor(() => assert.match(document.body.textContent!, /6-digit code to ada@example\.com/))
|
||||
fireEvent.change(document.querySelector('input[autocomplete="one-time-code"]')!, { target: { value: '424242' } })
|
||||
fireEvent.change(document.querySelector('input[type="password"]')!, { target: { value: 'correct horse battery staple' } })
|
||||
fireEvent.submit(document.querySelector('form')!)
|
||||
|
||||
await waitFor(() => assert.match(document.body.textContent!, /new password is set/))
|
||||
const signin = [...document.querySelectorAll('a')].find((a) => a.textContent?.includes('Sign in'))!
|
||||
assert.equal(signin.getAttribute('href'), '/login?client_id=hanzo-console&login_hint=ada%40example.com')
|
||||
})
|
||||
|
||||
@@ -6,6 +6,10 @@ import { Submit } from './Submit'
|
||||
|
||||
export interface ForgotFormProps {
|
||||
readonly client: AuthClient
|
||||
/** What the address field starts with — the address registration found taken. */
|
||||
readonly identifier?: string
|
||||
/** Where the new password is used: sign-in for the request this page was handed. */
|
||||
readonly signinHref?: string
|
||||
/** Called once the code is on its way. */
|
||||
readonly onSent?: () => void
|
||||
/** Called once the new password is set — the person can now sign in with it. */
|
||||
@@ -27,7 +31,7 @@ export interface ForgotFormProps {
|
||||
*/
|
||||
export function ForgotForm(props: ForgotFormProps) {
|
||||
const { client } = props
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [identifier, setIdentifier] = useState(props.identifier ?? '')
|
||||
const [code, setCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -100,7 +104,7 @@ export function ForgotForm(props: ForgotFormProps) {
|
||||
// rather than after the fifteen-minute window a run of wrong guesses opened.
|
||||
return (
|
||||
<p className="hanzo-id-info">
|
||||
Your new password is set. <a href="/login">Sign in with it.</a>
|
||||
Your new password is set. <a href={props.signinHref ?? '/login'}>Sign in with it.</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,14 @@ function org(): Org {
|
||||
|
||||
/**
|
||||
* An IAM double: the descriptor, the code send, the create and the sign-in after
|
||||
* it. `code` is the descriptor's switch for "a code can be delivered here".
|
||||
* it. `code` is the descriptor's switch for "a code can be delivered here", and
|
||||
* the create asks for the code under the same switch, as IAM does. `taken` says
|
||||
* when an address that already has an account is reported: on the first submit
|
||||
* (`first`), or only once a code is brought (`code`, the order IAM used before).
|
||||
*/
|
||||
function iam(opts: { code: boolean; send?: unknown }) {
|
||||
function iam(opts: { code: boolean; send?: unknown; taken?: 'first' | 'code' }) {
|
||||
const calls: { method: string; url: string; body: string }[] = []
|
||||
let created = 0
|
||||
const json = (payload: unknown) =>
|
||||
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
@@ -40,7 +44,13 @@ function iam(opts: { code: boolean; send?: unknown }) {
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method !== 'GET') calls.push({ method, url, body: String(init?.body ?? '') })
|
||||
if (url.includes('/verification-codes')) return json(opts.send ?? { status: 'ok' })
|
||||
if (url.includes('/v1/iam/signup')) return json({ status: 'ok', data: { id: 'sub-1', owner: 'ada', name: 'ada' } })
|
||||
if (url.includes('/v1/iam/signup')) {
|
||||
const code = 'code' in (JSON.parse(String(init?.body)) as Record<string, unknown>)
|
||||
if (opts.taken === 'first' || (opts.taken === 'code' && code)) return json({ status: 'error', msg: 'email already exists' })
|
||||
if (opts.code && !code) return json({ status: 'error', msg: 'the code sent to the email address is required' })
|
||||
created++
|
||||
return json({ status: 'ok', data: { id: 'sub-1', owner: 'ada', name: 'ada' } })
|
||||
}
|
||||
if (url.includes('/v1/iam/login')) return json({ status: 'ok', data: 'AUTHCODE' })
|
||||
return json({
|
||||
status: 'ok',
|
||||
@@ -55,7 +65,7 @@ function iam(opts: { code: boolean; send?: unknown }) {
|
||||
},
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
return { calls, fetchImpl }
|
||||
return { calls, fetchImpl, created: () => created }
|
||||
}
|
||||
|
||||
/** The input a visible label names — wrapped, or associated by `for`. */
|
||||
@@ -80,7 +90,7 @@ function fill() {
|
||||
// THE PATH. The code goes to the address first, and the account is created only
|
||||
// with it — so it is created with the address proven.
|
||||
test('the address receives a code, and the account is created with it', async () => {
|
||||
const { calls, fetchImpl } = iam({ code: true })
|
||||
const { calls, fetchImpl, created } = iam({ code: true })
|
||||
render(<SignupForm client={createAuthClient({ org: org(), fetchImpl })} redirectUri="https://console.hanzo.ai/callback" />)
|
||||
|
||||
fill()
|
||||
@@ -92,13 +102,13 @@ test('the address receives a code, and the account is created with it', async ()
|
||||
assert.match(send.body, /dest=ada%40example\.com/)
|
||||
assert.match(send.body, /type=email/)
|
||||
assert.match(send.body, /applicationId=admin%2Fhanzo-console/)
|
||||
assert.equal(calls.filter((c) => c.url.includes('/v1/iam/signup')).length, 0, 'nothing is created before the code')
|
||||
assert.equal(created(), 0, 'nothing is created before the code')
|
||||
|
||||
fireEvent.change(field('Code')!, { target: { value: '424242' } })
|
||||
submit()
|
||||
|
||||
await waitFor(() => assert.ok(calls.some((c) => c.url.includes('/v1/iam/signup'))))
|
||||
const body = JSON.parse(calls.find((c) => c.url.includes('/v1/iam/signup'))!.body) as Record<string, unknown>
|
||||
await waitFor(() => assert.equal(created(), 1))
|
||||
const body = JSON.parse(calls.filter((c) => c.url.includes('/v1/iam/signup')).at(-1)!.body) as Record<string, unknown>
|
||||
assert.equal(body.email, '[email protected]', 'the address the code went to')
|
||||
assert.equal(body.code, '424242')
|
||||
assert.equal(body.password, 'correct horse battery staple')
|
||||
@@ -122,7 +132,7 @@ test('an application that cannot deliver a code registers without one', async ()
|
||||
|
||||
// A send IAM refused is its own sentence on screen, and nothing is created.
|
||||
test('a refused send is shown and creates nothing', async () => {
|
||||
const { calls, fetchImpl } = iam({ code: true, send: { status: 'error', msg: 'please wait before requesting another code' } })
|
||||
const { fetchImpl, created } = iam({ code: true, send: { status: 'error', msg: 'please wait before requesting another code' } })
|
||||
render(<SignupForm client={createAuthClient({ org: org(), fetchImpl })} />)
|
||||
|
||||
fill()
|
||||
@@ -130,7 +140,7 @@ test('a refused send is shown and creates nothing', async () => {
|
||||
|
||||
await waitFor(() => assert.match(document.body.textContent!, /please wait before requesting another code/))
|
||||
assert.equal(field('Code'), null)
|
||||
assert.equal(calls.filter((c) => c.url.includes('/v1/iam/signup')).length, 0)
|
||||
assert.equal(created(), 0)
|
||||
})
|
||||
|
||||
// Back from the code step keeps what was typed, and a changed address gets its own
|
||||
@@ -154,3 +164,68 @@ test('changing the address sends the new one its own code', async () => {
|
||||
assert.equal(sends.length, 2)
|
||||
assert.match(sends[1]!.body, /dest=grace%40example\.com/)
|
||||
})
|
||||
|
||||
// An address that already has an account is a person who needs back in, not an
|
||||
// error to read, and they hear it on the first submit: no code goes to an address
|
||||
// that already has an account. Sign-in and reset both open on that address, for
|
||||
// the same request.
|
||||
test('an address that already has an account is answered before any code is sent', async () => {
|
||||
const { calls, fetchImpl, created } = iam({ code: true, taken: 'first' })
|
||||
render(
|
||||
<SignupForm
|
||||
client={createAuthClient({ org: org(), fetchImpl })}
|
||||
signinHref="/login?client_id=hanzo-console&state=s1"
|
||||
forgotHref="/forget?client_id=hanzo-console&state=s1"
|
||||
/>,
|
||||
)
|
||||
|
||||
fill()
|
||||
submit()
|
||||
|
||||
await waitFor(() => assert.match(document.body.textContent!, /ada@example\.com already has an account/))
|
||||
assert.equal(field('Code'), null, 'no code is asked for')
|
||||
assert.equal(calls.filter((c) => c.url.includes('/verification-codes')).length, 0, 'no code is sent')
|
||||
assert.equal(created(), 0)
|
||||
assert.doesNotMatch(document.body.textContent!, /email already exists/)
|
||||
const link = (text: string) => [...document.querySelectorAll('a')].find((a) => a.textContent?.trim() === text)!
|
||||
assert.equal(link('Sign in').getAttribute('href'), '/login?client_id=hanzo-console&state=s1&login_hint=ada%40example.com')
|
||||
assert.equal(link('Reset password').getAttribute('href'), '/forget?client_id=hanzo-console&state=s1&login_hint=ada%40example.com')
|
||||
|
||||
fireEvent.click([...document.querySelectorAll('button')].find((b) => b.textContent?.includes('different email'))!)
|
||||
await waitFor(() => assert.ok(field('Email')))
|
||||
assert.equal(field('Email')!.value, '[email protected]')
|
||||
})
|
||||
|
||||
// An IAM that reports the address only once a code is brought still lands on the
|
||||
// same ways back in, after the code.
|
||||
test('an address reported taken after the code gets the same ways back in', async () => {
|
||||
const { fetchImpl } = iam({ code: true, taken: 'code' })
|
||||
render(<SignupForm client={createAuthClient({ org: org(), fetchImpl })} signinHref="/login" />)
|
||||
|
||||
fill()
|
||||
submit()
|
||||
await waitFor(() => assert.ok(field('Code')))
|
||||
fireEvent.change(field('Code')!, { target: { value: '424242' } })
|
||||
submit()
|
||||
|
||||
await waitFor(() => assert.match(document.body.textContent!, /ada@example\.com already has an account/))
|
||||
const signin = [...document.querySelectorAll('a')].find((a) => a.textContent?.trim() === 'Sign in')!
|
||||
assert.equal(signin.getAttribute('href'), '/login?login_hint=ada%40example.com')
|
||||
})
|
||||
|
||||
// A password the org refuses is refused on the first submit too, before a code is
|
||||
// spent on an account that could not be made with it.
|
||||
test('a refused password is answered before any code is sent', async () => {
|
||||
const { fetchImpl } = iam({ code: true })
|
||||
const refusing = (async (input: RequestInfo | URL, init?: RequestInit) =>
|
||||
input.toString().includes('/v1/iam/signup')
|
||||
? new Response(JSON.stringify({ status: 'error', msg: 'the password must contain at least one digit' }), { status: 200 })
|
||||
: fetchImpl(input, init)) as typeof fetch
|
||||
render(<SignupForm client={createAuthClient({ org: org(), fetchImpl: refusing })} />)
|
||||
|
||||
fill()
|
||||
submit()
|
||||
|
||||
await waitFor(() => assert.match(document.body.textContent!, /at least one digit/))
|
||||
assert.equal(field('Code'), null)
|
||||
})
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface SignupFormProps {
|
||||
* page's query.
|
||||
*/
|
||||
readonly signinHref?: string
|
||||
/**
|
||||
* Where a person whose address already has an account resets its password.
|
||||
* Defaults to `/forget` with this page's query.
|
||||
*/
|
||||
readonly forgotHref?: string
|
||||
/**
|
||||
* The moments a host may want to count, handed out rather than measured here:
|
||||
* this package is the flow, and what watches it is the page's business.
|
||||
@@ -41,6 +46,18 @@ export interface SignupFormProps {
|
||||
readonly onCompleted?: (subject?: string) => void
|
||||
}
|
||||
|
||||
/** IAM's refusals the form acts on (`internal/oidc/signup.go`). */
|
||||
const TAKEN = 'email already exists'
|
||||
const CODE_REQUIRED = 'the code sent to the email address is required'
|
||||
|
||||
/** `href` with `login_hint` set to the address, so the page it opens starts filled in. */
|
||||
function hinted(href: string, address: string): string {
|
||||
const i = href.indexOf('?')
|
||||
const q = new URLSearchParams(i < 0 ? '' : href.slice(i + 1))
|
||||
q.set('login_hint', address)
|
||||
return `${i < 0 ? href : href.slice(0, i)}?${q}`
|
||||
}
|
||||
|
||||
export function SignupForm(props: SignupFormProps) {
|
||||
const { client } = props
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -49,6 +66,9 @@ export function SignupForm(props: SignupFormProps) {
|
||||
// The address a code went to. The stage IS this state: null asks for the
|
||||
// account, an address asks for the code that proves it.
|
||||
const [sentTo, setSentTo] = useState<string | null>(null)
|
||||
// The address IAM refused because an account already has it. Set, the form
|
||||
// gives way to the ways back into that account.
|
||||
const [taken, setTaken] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const errorId = useId()
|
||||
@@ -71,21 +91,9 @@ export function SignupForm(props: SignupFormProps) {
|
||||
const application = app?.application ?? client.org.appName
|
||||
const organization = app?.organization ?? client.org.orgId
|
||||
|
||||
// The address is proven before the account exists, where IAM can deliver a
|
||||
// code at all: the person who receives it is then certainly the person
|
||||
// choosing this password, and IAM records the address proven. Where it
|
||||
// cannot, the account is still made and the address stays unproven.
|
||||
if (sentTo === null && app?.enableCodeSignin) {
|
||||
const sent = await client.sendCode({ dest: email, channel: 'email', application: app.id })
|
||||
if (!sent.ok) {
|
||||
setError(sent.error ?? 'the code could not be sent')
|
||||
return
|
||||
}
|
||||
setCode('')
|
||||
setSentTo(email)
|
||||
return
|
||||
}
|
||||
|
||||
// The first submit carries no code. IAM checks the password policy and
|
||||
// whether the address is taken before it asks for one, so a taken address
|
||||
// or a refused password is answered here, before any code is sent.
|
||||
const session = await client.signup({
|
||||
email: sentTo ?? email,
|
||||
password,
|
||||
@@ -99,6 +107,24 @@ export function SignupForm(props: SignupFormProps) {
|
||||
codeChallengeMethod: props.codeChallengeMethod,
|
||||
nonce: props.nonce,
|
||||
})
|
||||
// The address is proven before the account exists, where IAM can deliver a
|
||||
// code at all: the person who receives it is then certainly the person
|
||||
// choosing this password, and IAM records the address proven. Where it
|
||||
// cannot, IAM asks for no code and the account is made unproven.
|
||||
if (sentTo === null && app && session.error === CODE_REQUIRED) {
|
||||
const sent = await client.sendCode({ dest: email, channel: 'email', application: app.id })
|
||||
if (!sent.ok) {
|
||||
setError(sent.error ?? 'the code could not be sent')
|
||||
return
|
||||
}
|
||||
setCode('')
|
||||
setSentTo(email)
|
||||
return
|
||||
}
|
||||
if (session.error === TAKEN) {
|
||||
setTaken(sentTo ?? email)
|
||||
return
|
||||
}
|
||||
if (session.error) {
|
||||
setError(session.error)
|
||||
return
|
||||
@@ -126,6 +152,32 @@ export function SignupForm(props: SignupFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (taken !== null) {
|
||||
return (
|
||||
<div className="hanzo-id-form">
|
||||
<p className="hanzo-id-info" role="status">
|
||||
{taken} already has an account.
|
||||
</p>
|
||||
<a className="hanzo-id-btn" href={hinted(props.signinHref ?? `/login${window.location.search}`, taken)}>
|
||||
Sign in
|
||||
</a>
|
||||
<a className="hanzo-id-btn ghost" href={hinted(props.forgotHref ?? `/forget${window.location.search}`, taken)}>
|
||||
Reset password
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-linkbtn"
|
||||
onClick={() => {
|
||||
setTaken(null)
|
||||
setSentTo(null)
|
||||
}}
|
||||
>
|
||||
Use a different email
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (sentTo !== null) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="hanzo-id-form" aria-busy={busy}>
|
||||
|
||||
Reference in New Issue
Block a user