mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
the sign-in order runs through the credential form
The wallet and the phone ranked third and fourth on the login screen, above the email field, because they were entries in a strip and the strip led the page. Somebody arriving to sign up for a small business met two specialist ways in before the one they were going to use. They are still offered, they just stop outranking the field: Google and GitHub lead, the form follows, the wallet and the phone trail. PROVIDER_ORDER carries the whole arrangement now rather than the buttons' half of it, so the form has a named place in the one list and the column can be read in one line. That is why the form is a child of the component that draws the column — entries fall on both sides of it, and a component cannot place entries around a sibling it does not contain. The alternative was a second list on the page saying which entries lead and which trail, and two lists of one arrangement drift. The rule travels with the form and appears only when something sits above it, so an app with no providers configured no longer gets a rule separating its form from nothing. It keeps the 24px of air it had when the page's gap owned it. The form no longer waits on the two descriptor reads. It was safe to return nothing until IAM answered while the form was a sibling; as a child that would be a blank screen wherever IAM is slow. Signup and device approval draw the same column from the same list. Device approval had the form above the strip under a comment claiming that matched the login page, which stopped being true when the one-click entries moved to the front.
This commit is contained in:
@@ -762,13 +762,18 @@ select.hanzo-id-input option {
|
||||
border-left: 1px solid var(--white-10);
|
||||
}
|
||||
|
||||
/* Labeled divider between social row and email form. */
|
||||
/* Labeled rule between the one-click entries and the credential form.
|
||||
The 14px keeps the 24px of air it already had: the rule used to be a child of
|
||||
`main` (gap 24px) and now sits in the column with the entries (gap 10px), and
|
||||
without this it would read as another row in an even stack rather than as the
|
||||
boundary between two groups. 10 + 14 = 24, both sides, unchanged. */
|
||||
.hanzo-id-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
margin: 14px 0;
|
||||
}
|
||||
.hanzo-id-divider::before,
|
||||
.hanzo-id-divider::after {
|
||||
|
||||
@@ -189,21 +189,24 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
|
||||
return (
|
||||
<Shell brand={brand} org={client.org}>
|
||||
<h1>Sign in to approve your device</h1>
|
||||
{/* Same order as the login page: the credential leads, the strip follows.
|
||||
One sign-in surface, not two that drift. */}
|
||||
<LoginForm
|
||||
client={client}
|
||||
onAuthenticated={() => setPhase({ s: 'confirm' })}
|
||||
kind={kind}
|
||||
onKind={setKind}
|
||||
/>
|
||||
{/* Same column as the login page, from the same order — one sign-in
|
||||
surface, not two that drift. This page had the form ABOVE the strip
|
||||
and a comment claiming that matched login, which stopped being true
|
||||
when the one-click entries moved to the front. */}
|
||||
<SocialButtons
|
||||
client={client}
|
||||
intent="signin"
|
||||
postLoginRedirect={returnTo}
|
||||
kind={kind}
|
||||
onKind={setKind}
|
||||
/>
|
||||
>
|
||||
<LoginForm
|
||||
client={client}
|
||||
onAuthenticated={() => setPhase({ s: 'confirm' })}
|
||||
kind={kind}
|
||||
onKind={setKind}
|
||||
/>
|
||||
</SocialButtons>
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a>
|
||||
</p>
|
||||
|
||||
@@ -209,13 +209,17 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
is 104px wide, so the pair read as the loudest thing on a page whose
|
||||
job is one field and one button. */}
|
||||
<h1>Login or Signup</h1>
|
||||
{/* THE PROVIDERS LEAD. Most people arrive already signed into one of
|
||||
them and finish in one click, where the credential is two fields and a
|
||||
recall — so the shortest way in is first and the rule marks where it
|
||||
ends. 0.2.54 read it the other way and put the credential first; this
|
||||
is the arrangement the design asks for. The rule MOVED with the strip
|
||||
rather than staying put: a separator that holds its place while the
|
||||
things it separates swap ends up marking the wrong boundary. */}
|
||||
{/* THE ONE-CLICK ENTRIES LEAD. Most people arrive already signed into
|
||||
Google or GitHub and finish in one press, where the credential is two
|
||||
fields and a recall — so the shortest way in is first and the rule
|
||||
marks where it ends. 0.2.54 read it the other way and put the
|
||||
credential first; this is the arrangement the design asks for.
|
||||
|
||||
The column runs THROUGH the form, which is why the form is a child
|
||||
here: the wallet and the phone follow it. They used to precede it,
|
||||
because they were in the strip and the strip led the page, so somebody
|
||||
with an email address and a small business met two specialist entries
|
||||
before the field they came for. PROVIDER_ORDER is where that reads. */}
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
@@ -223,19 +227,20 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
postLoginRedirect={redirectUri}
|
||||
kind={kind}
|
||||
onKind={setKind}
|
||||
/>
|
||||
<LoginForm
|
||||
client={client}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
onMfaRequired={setMfa}
|
||||
kind={kind}
|
||||
onKind={setKind}
|
||||
/>
|
||||
>
|
||||
<LoginForm
|
||||
client={client}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
onMfaRequired={setMfa}
|
||||
kind={kind}
|
||||
onKind={setKind}
|
||||
/>
|
||||
</SocialButtons>
|
||||
<p className="hanzo-id-footer-links">
|
||||
{/* Carry the OIDC request across. These are full page loads, so a bare
|
||||
href drops the client_id, redirect_uri, state and PKCE challenge the
|
||||
|
||||
@@ -90,43 +90,44 @@ export function Signup({ client, brand }: { client: AuthClient; brand: BrandCont
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signup"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<SignupForm
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
onSubmitted={() => {
|
||||
analytics.capture(EVENTS.SIGNUP_SUBMITTED)
|
||||
// Send it on an ordinary request, now. Creating the account is a
|
||||
// full round trip and the browser leaves for the app the moment it
|
||||
// answers, so a step left on the flush timer only ever leaves on the
|
||||
// unload beacon — the send that is lost when a tab is closed or the
|
||||
// network stalls, and the one nothing can read back to check. The
|
||||
// arrival is still buffered at this point, so it rides out here too,
|
||||
// and the two steps the funnel is measured on stop depending on a
|
||||
// page that is already going away.
|
||||
analytics.flush()
|
||||
}}
|
||||
onCompleted={(subject) => {
|
||||
// The visitor now has a name, so bind it BEFORE counting the
|
||||
// completion: `identify` stamps the subject on this event and on
|
||||
// everything after it, and emits the row that joins the anonymous
|
||||
// arrival to the account it produced. The steps already queued keep
|
||||
// the anonymous id they happened under, which is what they were.
|
||||
//
|
||||
// The subject alone — an opaque IAM id, no traits. The address is on
|
||||
// the form a few lines above and has no business on this wire.
|
||||
if (subject) analytics.identify(subject)
|
||||
analytics.capture(EVENTS.SIGNUP_COMPLETED)
|
||||
// The browser leaves for the app on the next line, so the whole
|
||||
// buffer goes now rather than waiting on the flush timer.
|
||||
analytics.flush(true)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SignupForm
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
nonce={nonce}
|
||||
onSubmitted={() => {
|
||||
analytics.capture(EVENTS.SIGNUP_SUBMITTED)
|
||||
// Send it on an ordinary request, now. Creating the account is a
|
||||
// full round trip and the browser leaves for the app the moment it
|
||||
// answers, so a step left on the flush timer only ever leaves on the
|
||||
// unload beacon — the send that is lost when a tab is closed or the
|
||||
// network stalls, and the one nothing can read back to check. The
|
||||
// arrival is still buffered at this point, so it rides out here too,
|
||||
// and the two steps the funnel is measured on stop depending on a
|
||||
// page that is already going away.
|
||||
analytics.flush()
|
||||
}}
|
||||
onCompleted={(subject) => {
|
||||
// The visitor now has a name, so bind it BEFORE counting the
|
||||
// completion: `identify` stamps the subject on this event and on
|
||||
// everything after it, and emits the row that joins the anonymous
|
||||
// arrival to the account it produced. The steps already queued keep
|
||||
// the anonymous id they happened under, which is what they were.
|
||||
//
|
||||
// The subject alone — an opaque IAM id, no traits. The address is on
|
||||
// the form a few lines above and has no business on this wire.
|
||||
if (subject) analytics.identify(subject)
|
||||
analytics.capture(EVENTS.SIGNUP_COMPLETED)
|
||||
// The browser leaves for the app on the next line, so the whole
|
||||
// buffer goes now rather than waiting on the flush timer.
|
||||
analytics.flush(true)
|
||||
}}
|
||||
/>
|
||||
</SocialButtons>
|
||||
<p className="hanzo-id-footer-links">
|
||||
Already have an account? <a href={`/login${window.location.search}`}>Sign in</a>
|
||||
</p>
|
||||
|
||||
+21
-11
@@ -63,7 +63,7 @@ export function authorizeRequest(search: string, clientId: string): OAuthAuthori
|
||||
}
|
||||
|
||||
/**
|
||||
* The order federated providers are offered in, most-used first.
|
||||
* The order the ways in are offered in, most-used first.
|
||||
*
|
||||
* Order is PROVIDER POLICY, so it lives here beside the other provider policy
|
||||
* rather than in the component that happens to paint the buttons — and being a
|
||||
@@ -71,17 +71,27 @@ export function authorizeRequest(search: string, clientId: string): OAuthAuthori
|
||||
* actually hold still. It had drifted before: an unexported constant inside the
|
||||
* component, covered by nothing.
|
||||
*
|
||||
* Google leads because it is the account most people arrive already signed into,
|
||||
* and the wallet trails because it is the specialist entry. A provider absent
|
||||
* from this list does not render at all, which is deliberate: a name here is the
|
||||
* statement that the portal knows how to draw and finish that provider's flow.
|
||||
* Google and GitHub lead because they are the accounts most people arrive already
|
||||
* signed into and they finish in one click. A provider absent from this list does
|
||||
* not render at all, which is deliberate: a name here is the statement that the
|
||||
* portal knows how to draw and finish that provider's flow.
|
||||
*
|
||||
* `form` is the CREDENTIAL FORM's place in the column, and it is why this list is
|
||||
* the whole arrangement rather than the buttons' half of it. The wallet and the
|
||||
* phone used to sit above the form because they were in the strip and the strip
|
||||
* led the page — so somebody signing up for a ceramics studio met two specialist
|
||||
* entries before the email field they were going to use. They are still offered,
|
||||
* they just stop outranking the field: `web3` and `phone` follow the form now.
|
||||
* Splitting the column at the form is the only reason a component draws entries on
|
||||
* both sides of it, and this is the one place that split is stated.
|
||||
*
|
||||
* `web3` and `phone` are not federated providers either — the wallet is a
|
||||
* capability of the IAM binary and the phone selects which identifier the form
|
||||
* asks for. They live in this order anyway because the order is the COLUMN's, not
|
||||
* the provider list's: a person reading a column of ways to sign in does not care
|
||||
* which of them is an OAuth hop.
|
||||
*/
|
||||
// `phone` is last and is NOT a federated provider — it selects which identifier
|
||||
// the credential form asks for. It lives in this order anyway because the order
|
||||
// is the STRIP's, not the provider list's: the wallet is already here on the same
|
||||
// terms (a capability, not a provider row), and a person reading a column of ways
|
||||
// to sign in does not care which of them is an OAuth hop.
|
||||
export const PROVIDER_ORDER = ['google', 'github', 'gitlab', 'web3', 'phone'] as const
|
||||
export const PROVIDER_ORDER = ['google', 'github', 'gitlab', 'form', 'web3', 'phone'] as const
|
||||
|
||||
/**
|
||||
* Resolve a `provider_hint` from the authorize query to one of the app's
|
||||
|
||||
@@ -52,6 +52,13 @@ function drawn(): (string | undefined)[] {
|
||||
return [...document.querySelectorAll('[data-provider]')].map((b) => (b as HTMLElement).dataset.provider)
|
||||
}
|
||||
|
||||
/** The whole column in DOM order — entries, rule and the credential slot alike. */
|
||||
function column(): string[] {
|
||||
return [...document.querySelectorAll('[data-provider], .hanzo-id-divider, form')].map((e) =>
|
||||
(e as HTMLElement).dataset.provider ?? (e.tagName === 'FORM' ? 'form' : 'rule'),
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolve when the strip has rendered (both descriptor reads have landed). */
|
||||
async function settled(): Promise<void> {
|
||||
await waitFor(() => assert.ok(document.querySelector('.hanzo-id-social')))
|
||||
@@ -149,25 +156,67 @@ test('phone is the last entry drawn', async () => {
|
||||
assert.deepEqual(drawn(), ['google', 'github', 'web3', 'phone'])
|
||||
})
|
||||
|
||||
// The rule sits on the side facing the credential form, and the strip leads the
|
||||
// page — so it TRAILS the buttons. There is no prop for which side, which is why
|
||||
// this is worth a test: the only thing holding the rule on the correct side is
|
||||
// where it appears in the JSX, and moving the strip is exactly when someone edits
|
||||
// that JSX. It also renders only with buttons, so a lone password form on an app
|
||||
// with no providers never gets a rule separating it from nothing.
|
||||
test('the rule trails the strip, and only exists when the strip does', async () => {
|
||||
render(<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google] }) })} />)
|
||||
// THE ARRANGEMENT, and the reason the form is a child rather than a sibling.
|
||||
//
|
||||
// The wallet and the phone used to be part of a strip that led the page, so they
|
||||
// ranked ABOVE the email field — a small business signing up met two specialist
|
||||
// entries before the one they were going to use. They are still offered; the order
|
||||
// simply runs through the form now. Nothing else can hold this still: PROVIDER_ORDER
|
||||
// is a list of names, and where the form falls among them is only visible here.
|
||||
test('the one-click entries lead, the form follows, the specialist entries trail', async () => {
|
||||
render(
|
||||
<SocialButtons
|
||||
client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google, github], chains: ['evm'] }) })}
|
||||
kind="email"
|
||||
onKind={() => {}}
|
||||
>
|
||||
<form />
|
||||
</SocialButtons>,
|
||||
)
|
||||
await settled()
|
||||
|
||||
const nodes = [...document.querySelectorAll('.hanzo-id-social, .hanzo-id-divider')].map((e) => e.className)
|
||||
assert.deepEqual(nodes, ['hanzo-id-social', 'hanzo-id-divider'], 'buttons first, rule after')
|
||||
assert.deepEqual(column(), ['google', 'github', 'rule', 'form', 'web3', 'phone'])
|
||||
})
|
||||
|
||||
// A second, independent mount: the empty case is a different component life, not
|
||||
// a re-render of this one.
|
||||
// The rule separates the one-click entries from the form, so it travels WITH the
|
||||
// form and appears only when something sits above it. There is no prop for which
|
||||
// side: the page has one arrangement, and a flag here would be a second way to
|
||||
// answer a settled question.
|
||||
test('the rule sits above the form, and only when something is above it', async () => {
|
||||
render(
|
||||
<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google] }) })}>
|
||||
<form />
|
||||
</SocialButtons>,
|
||||
)
|
||||
await settled()
|
||||
assert.deepEqual(column(), ['google', 'rule', 'form'])
|
||||
|
||||
// An app with no providers configured: the form is the whole way in, and a rule
|
||||
// over it would separate it from nothing. Second mount, not a re-render — the
|
||||
// empty case is a different component life.
|
||||
cleanup()
|
||||
render(<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({}) })} />)
|
||||
await waitFor(() => assert.equal(document.querySelector('[data-provider]'), null))
|
||||
assert.equal(document.querySelector('.hanzo-id-divider'), null, 'no buttons, no rule')
|
||||
render(
|
||||
<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({}) })}>
|
||||
<form />
|
||||
</SocialButtons>,
|
||||
)
|
||||
await waitFor(() => assert.ok(document.querySelector('form')))
|
||||
assert.equal(document.querySelector('.hanzo-id-divider'), null, 'nothing above it, no rule')
|
||||
})
|
||||
|
||||
// The form must not wait on the two descriptor reads. It is why the page exists,
|
||||
// and rendering it only once IAM has answered twice is a blank screen wherever IAM
|
||||
// is slow — the component used to return null until then, which was safe when the
|
||||
// form was a sibling and is not now that it is a child.
|
||||
test('the credential form renders before the descriptors land', async () => {
|
||||
const never = (() => new Promise<Response>(() => {})) as unknown as typeof fetch
|
||||
render(
|
||||
<SocialButtons client={createAuthClient({ org: org(), fetchImpl: never })}>
|
||||
<form data-credential="true" />
|
||||
</SocialButtons>,
|
||||
)
|
||||
await waitFor(() => assert.ok(document.querySelector('[data-credential]')))
|
||||
assert.equal(document.querySelector('[data-provider]'), null, 'nothing resolved yet, so no entries')
|
||||
})
|
||||
|
||||
// A chain nobody can sign is a dead end of the same kind as a dead OAuth button.
|
||||
|
||||
+143
-112
@@ -1,5 +1,5 @@
|
||||
import { Fragment, useEffect, useId, useRef, useState } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import type { ComponentType, ReactNode, SVGProps } from 'react'
|
||||
import type { Chain } from '@hanzo/id-connect'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { AppProvider } from '../types'
|
||||
@@ -11,10 +11,16 @@ import { Alert } from './Alert'
|
||||
import { Divider } from './Divider'
|
||||
|
||||
/**
|
||||
* Social + multi-chain wallet sign-in buttons.
|
||||
* The column of ways in, in `PROVIDER_ORDER`.
|
||||
*
|
||||
* It draws the entries and places the page's credential form among them, because
|
||||
* the order runs THROUGH the form — Google and GitHub above it, the wallet and the
|
||||
* phone below — and a component cannot put entries on both sides of a sibling.
|
||||
* That is the whole reason the form arrives as a child; everything else here is
|
||||
* about which entries are offered at all.
|
||||
*
|
||||
* Two sign-in shapes, decomplected — and they are answered by two DIFFERENT
|
||||
* questions, which is the whole shape of this component:
|
||||
* questions, which is the shape of the rest of this component:
|
||||
*
|
||||
* - OAuth (github/google/gitlab) is per-APPLICATION config, so it comes from
|
||||
* `/v1/iam/get-app-login`: which providers this app links, and IAM has already
|
||||
@@ -88,6 +94,20 @@ export interface SocialButtonsProps {
|
||||
* caller can drop to the interactive form instead of a blank redirect state).
|
||||
*/
|
||||
readonly onAutoStartResolved?: (started: boolean) => void
|
||||
/**
|
||||
* The credential form, placed at `form`'s slot in `PROVIDER_ORDER`.
|
||||
*
|
||||
* It is a child rather than a sibling because the order splits AROUND it —
|
||||
* Google and GitHub above, the wallet and the phone below — and a component
|
||||
* cannot draw entries on both sides of something it does not contain. The
|
||||
* alternative was a second list on the page saying which entries lead and
|
||||
* which trail, and two lists of one arrangement drift.
|
||||
*
|
||||
* The page decides what goes in the slot; this component only decides where.
|
||||
* `Login` puts the form and the door beside it there. Absent, the slot is not
|
||||
* drawn and neither is the rule above it.
|
||||
*/
|
||||
readonly children?: ReactNode
|
||||
}
|
||||
|
||||
interface ProviderMeta {
|
||||
@@ -131,6 +151,7 @@ export function SocialButtons({
|
||||
onKind,
|
||||
autoStart,
|
||||
onAutoStartResolved,
|
||||
children,
|
||||
}: SocialButtonsProps) {
|
||||
const [resolved, setResolved] = useState<Resolved | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -249,22 +270,32 @@ export function SocialButtons({
|
||||
// In autoStart mode the component is headless — it exists only to run the hop
|
||||
// above; the caller renders its own "signing you in" state. Render nothing.
|
||||
if (autoStart) return null
|
||||
if (resolved === null) return null // resolving — render nothing rather than flicker
|
||||
// ONE order for the whole strip, wallet included — PROVIDER_ORDER is where that
|
||||
// decision lives (google leads, the wallet trails). The wallet's slot is filled
|
||||
// by the capability, every other slot by the app's own provider list.
|
||||
// ONE order for the whole column — PROVIDER_ORDER is where that decision lives.
|
||||
// Each slot is filled by a different fact: the federated entries by the app's
|
||||
// provider list, the wallet by the binary's capability, the phone by there
|
||||
// being a form to switch, the form by the page handing one over.
|
||||
const ordered = PROVIDER_ORDER.filter((k) =>
|
||||
k === 'web3'
|
||||
? resolved.wallet.length > 0
|
||||
: // Phone is drawn only where a credential form is there to switch. The
|
||||
// entry SELECTS an identifier rather than starting a flow of its own, so
|
||||
// on a surface with no form (a bare provider strip) it would be a button
|
||||
// that changes nothing.
|
||||
k === 'phone'
|
||||
? onKind !== undefined
|
||||
: k in resolved.providers,
|
||||
// The form does not wait on the two descriptor reads. It is why the page
|
||||
// exists, and a page that renders nothing until IAM answers twice is a blank
|
||||
// screen wherever IAM is slow.
|
||||
k === 'form'
|
||||
? children !== undefined
|
||||
: resolved === null
|
||||
? false // still resolving — an entry we cannot finish is worse than none
|
||||
: k === 'web3'
|
||||
? resolved.wallet.length > 0
|
||||
: // Phone is drawn only where a credential form is there to switch. The
|
||||
// entry SELECTS an identifier rather than starting a flow of its own,
|
||||
// so on a surface with no form it would change nothing.
|
||||
k === 'phone'
|
||||
? onKind !== undefined
|
||||
: k in resolved.providers,
|
||||
)
|
||||
if (ordered.length === 0) return null
|
||||
// The rule separates what is above the form from the form. So it sits with the
|
||||
// form, and only when something is up there to separate — an app with no
|
||||
// providers configured gets its credential form and no rule dangling over it.
|
||||
const ruled = ordered.indexOf('form') > 0
|
||||
|
||||
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
|
||||
|
||||
@@ -310,113 +341,113 @@ export function SocialButtons({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hanzo-id-social">
|
||||
{ordered.map((k) => {
|
||||
if (k === 'web3') {
|
||||
// ONE chain-agnostic entry. It connects straight when a single wallet
|
||||
// is detected, else expands into the chooser below — so the page always
|
||||
// shows exactly one "Connect Wallet" button, with every offered family
|
||||
// reachable from it.
|
||||
return (
|
||||
<Fragment key="web3">
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="web3"
|
||||
data-wallet-connect="true"
|
||||
aria-expanded={walletMenu}
|
||||
disabled={busyChain !== null}
|
||||
onClick={onConnectWallet}
|
||||
>
|
||||
<WalletIcon />
|
||||
{/* "{verb} with Wallet", not "Connect Wallet": every other entry
|
||||
in this strip reads "Continue with X", and one row breaking
|
||||
the pattern reads as a different KIND of action rather than
|
||||
the same action with a different credential. It follows the
|
||||
strip's verb, so the signup surface says "Sign up with
|
||||
Wallet" exactly as it does for Google. */}
|
||||
<span>
|
||||
{busyChain !== null && !walletMenu ? 'Connecting…' : `${verb} with Wallet`}
|
||||
</span>
|
||||
</button>
|
||||
{walletMenu ? (
|
||||
<div
|
||||
className="hanzo-id-wallet-chains"
|
||||
role="group"
|
||||
aria-label="Choose a wallet network"
|
||||
>
|
||||
{resolved.wallet.map((chain) => (
|
||||
<button
|
||||
key={`web3-${chain}`}
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="web3"
|
||||
data-chain={chain}
|
||||
disabled={busyChain !== null}
|
||||
onClick={() => startWallet(chain)}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>{busyChain === chain ? 'Connecting…' : WALLET_CHAIN_LABELS[chain]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
if (k === 'phone') {
|
||||
// Names the OTHER identifier, always, so one control switches both
|
||||
// ways — it replaced a two-way text link and a one-way button would
|
||||
// strand somebody who picked phone by mistake.
|
||||
//
|
||||
// It carries the strip's verb like every sibling, and it is honest
|
||||
// about what it does: it selects how you are IDENTIFIED, then the
|
||||
// form asks for the same credential it already would have. No code is
|
||||
// promised, because none is sent — `enableCodeSignin` is what would
|
||||
// offer that, and IAM answers it false wherever it cannot deliver.
|
||||
const toPhone = kind !== 'phone'
|
||||
return (
|
||||
<div className="hanzo-id-social">
|
||||
{ordered.map((k) => {
|
||||
if (k === 'form') {
|
||||
// The credential form, and the rule that says the ways above it end
|
||||
// here. Both belong to this slot: the rule marks the boundary between
|
||||
// the one-click entries and the field, so it moves with the field.
|
||||
return (
|
||||
<Fragment key="form">
|
||||
{ruled ? <Divider /> : null}
|
||||
{children}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
if (k === 'web3') {
|
||||
// ONE chain-agnostic entry. It connects straight when a single wallet
|
||||
// is detected, else expands into the chooser below — so the page always
|
||||
// shows exactly one "Connect Wallet" button, with every offered family
|
||||
// reachable from it.
|
||||
return (
|
||||
<Fragment key="web3">
|
||||
<button
|
||||
key="phone"
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="phone"
|
||||
data-identifier-kind={kind ?? 'email'}
|
||||
onClick={() => onKind?.(toPhone ? 'phone' : 'email')}
|
||||
data-provider="web3"
|
||||
data-wallet-connect="true"
|
||||
aria-expanded={walletMenu}
|
||||
disabled={busyChain !== null}
|
||||
onClick={onConnectWallet}
|
||||
>
|
||||
<PhoneIcon />
|
||||
<span>{toPhone ? `${verb} with Phone` : `${verb} with Email`}</span>
|
||||
<WalletIcon />
|
||||
{/* "{verb} with Wallet", not "Connect Wallet": every other entry
|
||||
in this strip reads "Continue with X", and one row breaking
|
||||
the pattern reads as a different KIND of action rather than
|
||||
the same action with a different credential. It follows the
|
||||
strip's verb, so the signup surface says "Sign up with
|
||||
Wallet" exactly as it does for Google. */}
|
||||
<span>
|
||||
{busyChain !== null && !walletMenu ? 'Connecting…' : `${verb} with Wallet`}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
const provider = resolved.providers[k]!
|
||||
const meta = PROVIDER_META[k]!
|
||||
const { Icon } = meta
|
||||
{walletMenu ? (
|
||||
<div
|
||||
className="hanzo-id-wallet-chains"
|
||||
role="group"
|
||||
aria-label="Choose a wallet network"
|
||||
>
|
||||
{resolved!.wallet.map((chain) => (
|
||||
<button
|
||||
key={`web3-${chain}`}
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider="web3"
|
||||
data-chain={chain}
|
||||
disabled={busyChain !== null}
|
||||
onClick={() => startWallet(chain)}
|
||||
>
|
||||
<WalletIcon />
|
||||
<span>{busyChain === chain ? 'Connecting…' : WALLET_CHAIN_LABELS[chain]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
if (k === 'phone') {
|
||||
// Names the OTHER identifier, always, so one control switches both
|
||||
// ways — it replaced a two-way text link and a one-way button would
|
||||
// strand somebody who picked phone by mistake.
|
||||
//
|
||||
// It carries the strip's verb like every sibling, and it is honest
|
||||
// about what it does: it selects how you are IDENTIFIED, then the
|
||||
// form asks for the same credential it already would have. No code is
|
||||
// promised, because none is sent — `enableCodeSignin` is what would
|
||||
// offer that, and IAM answers it false wherever it cannot deliver.
|
||||
const toPhone = kind !== 'phone'
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
key="phone"
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider={k}
|
||||
onClick={() => startOAuth(provider)}
|
||||
data-provider="phone"
|
||||
data-identifier-kind={kind ?? 'email'}
|
||||
onClick={() => onKind?.(toPhone ? 'phone' : 'email')}
|
||||
>
|
||||
<Icon />
|
||||
<span>{verb} with {meta.label}</span>
|
||||
<PhoneIcon />
|
||||
<span>{toPhone ? `${verb} with Phone` : `${verb} with Email`}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<Alert id={errorId} message={error} />
|
||||
</div>
|
||||
{/* The "or" separator belongs WITH the social block and renders only when
|
||||
there are buttons, so it never dangles beside a lone password form on an
|
||||
app with no providers configured. It TRAILS the block, because the strip
|
||||
leads the page: a rule separates what is above it from what is below it,
|
||||
and one that stays put while the things it separates swap ends up marking
|
||||
the boundary between the strip and the page footer instead. There is no
|
||||
prop for which side — the page has one arrangement, so the rule has one
|
||||
place, and a flag here would be a second way to answer a settled question. */}
|
||||
<Divider />
|
||||
</>
|
||||
}
|
||||
const provider = resolved!.providers[k]!
|
||||
const meta = PROVIDER_META[k]!
|
||||
const { Icon } = meta
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="hanzo-id-btn ghost"
|
||||
data-provider={k}
|
||||
onClick={() => startOAuth(provider)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{verb} with {meta.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<Alert id={errorId} message={error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user