Sign in opens on the email field, with every other way in under it

The page now reads, on every brand: Sign in, Email or username, Password,
Continue, Send me a code instead, then "or", then Google, GitHub, Wallet and
Phone, each drawn only when the application can complete it, then Forgot
password? and the footer. PROVIDER_ORDER puts `form` first and the rule
follows the form, so login, signup and device approval share the one column.

The "New here?" / "Create account" block is gone, along with the sentences
that pointed at it (the refusal note and the unlinked-wallet message). /signup
stays: hanzo.app's signup=true and hanzo.chat's /signup/<client> still land
there. Server sign-up rules are unchanged.

apps/web/e2e drives the built export in Chromium on hanzo.id and lux.id at
1440 and 390 and asserts that order and the absence of any registration
control; hanzo.yml runs it as a fourth gate.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-23 16:28:40 -07:00
co-authored by Hanzo Dev
parent 1261a5ab88
commit d2bfa1d46a
18 changed files with 314 additions and 166 deletions
+1
View File
@@ -9,6 +9,7 @@ out/
.env.production.local
# Vite + pnpm monorepo
apps/*/dist
apps/*/test-results
pkgs/*/dist
**/tsconfig.tsbuildinfo
**/node_modules
+109
View File
@@ -0,0 +1,109 @@
/**
* The sign-in page as a person meets it: the built export, in Chromium, on the
* identity host it answers — hanzo.id and lux.id, one layout for both.
*
* Every request the page makes to its own host is answered here: files from
* `dist/` (the SPA fallback included, as the edge's staticFiles does) and `/v1`
* from an IAM double for an application that offers every way in. The brand
* marks load from their CDN; anything else leaving the page is refused.
*/
import { existsSync } from 'node:fs'
import { extname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, test, type BrowserContext } from '@playwright/test'
const DIST = fileURLToPath(new URL('../dist/', import.meta.url))
const HOSTS = [
{ host: 'hanzo.id', app: 'hanzo-console', org: 'hanzo', title: 'Hanzo ID' },
{ host: 'lux.id', app: 'lux-cloud', org: 'lux', title: 'Lux ID' },
] as const
type Host = (typeof HOSTS)[number]
/** `/v1/iam/auth/application` and `/v1/iam/auth/methods` for an app offering everything. */
function iam(path: string, h: Host): unknown {
if (path === '/v1/iam/auth/methods') return { status: 'ok', data: { web3Chains: ['evm'] } }
if (path === '/v1/iam/auth/application') {
const provider = (key: string, type: string) => ({
name: `provider-${key}`,
canSignIn: true,
provider: { name: `provider-${key}`, type, clientId: `${key}-client-id` },
})
return {
status: 'ok',
data: {
owner: 'admin',
name: h.app,
organization: h.org,
enablePassword: true,
enableCodeSignin: true,
providers: [provider('github', 'GitHub'), provider('google', 'Google')],
},
}
}
return { status: 'ok', data: {} }
}
async function serve(context: BrowserContext, h: Host) {
if (!existsSync(join(DIST, 'index.html'))) throw new Error('no dist/ — run `pnpm --filter @hanzo/id-web build` first')
await context.route('**/*', (route) => {
const url = new URL(route.request().url())
if (url.hostname === 'cdn.jsdelivr.net') return route.continue()
if (url.hostname !== h.host) return route.abort()
if (url.pathname.startsWith('/v1/')) return route.fulfill({ json: iam(url.pathname, h) })
const file = join(DIST, url.pathname)
return route.fulfill({ path: extname(url.pathname) && existsSync(file) ? file : join(DIST, 'index.html') })
})
}
/** What a person reads down the page, in document order. */
const COLUMN = [
'h1',
'.hanzo-id-field > span',
'.hanzo-id-field > label',
'button:not(.hanzo-id-revealbtn)',
'.hanzo-id-divider',
'.hanzo-id-footer-links a',
'.hanzo-id-brand-footer',
].join(', ')
for (const h of HOSTS) {
test(`${h.host}: email first, then "or", then every other way in, and no registration`, async ({ context, page }, info) => {
await serve(context, h)
await page.goto(`https://${h.host}/login`)
await expect(page.locator('[data-provider="phone"]')).toBeVisible()
await expect(page).toHaveTitle(h.title)
const column = await page.locator(COLUMN).evaluateAll((nodes) =>
nodes.map((n) => (n.matches('.hanzo-id-brand-footer') ? 'footer' : (n.textContent ?? '').trim())),
)
expect(column).toEqual([
'Sign in',
'Email or username',
'Password',
'Continue',
'Send me a code instead',
'or',
'Continue with Google',
'Continue with GitHub',
'Continue with Wallet',
'Continue with Phone',
'Forgot password?',
'footer',
])
await expect(page.getByText('Create account')).toHaveCount(0)
await expect(page.getByText('New here?')).toHaveCount(0)
await expect(page.locator('a[href^="/signup"]')).toHaveCount(0)
// Laid out in that order too, not just written in it.
const tops = await page
.locator('input[autocomplete=username], .hanzo-id-divider, [data-provider=google], [data-provider=phone], .hanzo-id-brand-footer')
.evaluateAll((nodes) => nodes.map((n) => n.getBoundingClientRect().top))
expect(tops).toEqual([...tops].sort((a, b) => a - b))
// No sideways scroll at either width.
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.screenshot({ path: info.outputPath(`${h.host}-${page.viewportSize()!.width}.png`), fullPage: true })
})
}
+3 -1
View File
@@ -8,7 +8,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5174",
"tc": "tsc --noEmit"
"tc": "tsc --noEmit",
"e2e": "playwright test"
},
"dependencies": {
"@crossmarkio/sdk": "catalog:",
@@ -37,6 +38,7 @@
"viem": "catalog:"
},
"devDependencies": {
"@playwright/test": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from '@playwright/test'
// The built export, driven in Chromium. Each spec answers the page's requests on
// its identity host from `dist/` — the bytes the site lane publishes — so
// `pnpm --filter @hanzo/id-web build` comes first.
export default defineConfig({
testDir: 'e2e',
outputDir: 'test-results',
fullyParallel: true,
forbidOnly: true,
reporter: 'list',
use: { browserName: 'chromium' },
projects: [
{ name: 'desktop', use: { viewport: { width: 1440, height: 900 } } },
{ name: 'phone', use: { viewport: { width: 390, height: 844 } } },
],
})
+5 -6
View File
@@ -587,7 +587,7 @@ select.hanzo-id-input option {
font-size: var(--text-sm);
text-align: center;
}
/* "Forgot password?" / "Create account" / "Sign in" / "Back to sign in" are the
/* "Forgot password?" / "Sign in" / "Back to sign in" are the
only route changes on these pages that are not buttons, and they measured 17px
tall — the line box of their own text. Vertical padding on an INLINE box is
hit-tested but does not enter line-box height, so this buys the 44px target
@@ -769,11 +769,10 @@ select.hanzo-id-input option {
border-left: 1px solid var(--white-10);
}
/* 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. */
/* Labeled rule between the credential form and the entries under it.
The 14px gives it 24px of air inside the column (gap 10px), so it reads as
the boundary between two groups rather than as another row in an even stack.
10 + 14 = 24, both sides. */
.hanzo-id-divider {
display: flex;
align-items: center;
+1 -3
View File
@@ -189,9 +189,7 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
<Shell brand={brand} org={client.org}>
<h1>Sign in to approve your device</h1>
{/* 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. */}
surface, not two that drift: the form, then "or", then the entries. */}
<SocialButtons
client={client}
intent="signin"
+56 -23
View File
@@ -127,33 +127,66 @@ test('prompt=login gets a screen, never a silent mint', async () => {
assert.equal(document.querySelectorAll('input[type=password]').length, 1)
})
// ── The other door ───────────────────────────────────────────────────────────
// The heading offers two things and only one of them was a control: registration
// was 13px of text at the foot of the page, on the same line as the link for
// people who forgot a password. A visitor whose only business here is that door
// had to read past every way of signing in to find it.
test('registration is a control, and it is not in the footnote row', async () => {
land()
await mount([])
// ── The column ───────────────────────────────────────────────────────────────
// The page opens on the credential form, then "or", then every other way in —
// each drawn only when the application can complete it. There is no registration
// control: an app that wants registration says so with `signup=true` (below).
const create = [...document.querySelectorAll('a')].find((a) => a.textContent?.trim() === 'Create account')
assert.ok(create, 'the page must offer registration')
assert.ok(
create.className.split(' ').includes('hanzo-id-btn'),
`registration must be a control, not a sentence; it rendered as "${create.className}"`,
/** An IAM double for an application that offers everything the column can draw. */
function offersAll(): typeof fetch {
const json = (data: unknown) =>
new Response(JSON.stringify({ status: 'ok', data }), { status: 200, headers: { 'Content-Type': 'application/json' } })
const provider = (key: string, type: string) => ({
name: `provider-${key}`,
canSignIn: true,
provider: { name: `provider-${key}`, type, clientId: `${key}-real-client-id` },
})
return (async (input: RequestInfo | URL) => {
const path = new URL(input.toString()).pathname
if (path === '/v1/iam/auth/methods') return json({ web3Chains: ['evm'] })
if (path === '/v1/iam/auth/application')
return json({
owner: 'admin',
name: 'hanzo-console',
organization: 'hanzo',
enablePassword: true,
enableCodeSignin: true,
providers: [provider('github', 'GitHub'), provider('google', 'Google')],
})
return json({})
}) as unknown as typeof fetch
}
/** What a person reads down the page, in document order. */
function column(): string[] {
const nodes = document.querySelectorAll(
'h1, .hanzo-id-field > span, .hanzo-id-field > label, button:not(.hanzo-id-revealbtn), .hanzo-id-divider, .hanzo-id-footer-links a',
)
return [...nodes].map((n) => n.textContent?.trim() ?? '')
}
// Carrying the whole OIDC request is what lets registration return the new
// account to the app that asked for it.
const to = new URL(create.getAttribute('href')!, 'https://hanzo.id')
assert.equal(to.pathname, '/signup')
for (const k of ['client_id', 'redirect_uri', 'state', 'code_challenge']) {
assert.ok(to.searchParams.get(k), `${k} must survive the hop to /signup`)
}
test('the credential form leads, then "or", then the other ways in, and nothing offers registration', async () => {
land()
render(<Login client={createAuthClient({ org: ORG, fetchImpl: offersAll() })} brand={BRAND} />)
await waitFor(() => assert.ok(document.querySelector('[data-provider="phone"]')))
// What is left at the foot is the one link that belongs there.
const foot = document.querySelector('.hanzo-id-footer-links')!
assert.equal(foot.textContent?.trim(), 'Forgot password?')
assert.deepEqual(column(), [
'Sign in',
'Email or username',
'Password',
'Continue',
'Send me a code instead',
'or',
'Continue with Google',
'Continue with GitHub',
'Continue with Wallet',
'Continue with Phone',
'Forgot password?',
])
const text = document.body.textContent ?? ''
assert.equal(text.includes('Create account'), false, 'no registration control')
assert.equal(text.includes('New here?'), false, 'no registration prompt')
assert.equal(document.querySelector('a[href^="/signup"]'), null, 'nothing links to /signup')
})
// ── The registration hint ────────────────────────────────────────────────────
+16 -47
View File
@@ -59,11 +59,10 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
// The page therefore renders what it is for: a credential form.
//
// A caller that sent the user here to REGISTER should get registration.
// hanzo.app's "Get started" forwards `signup=true` and this page ignored it,
// so every net-new customer met a sign-in form with empty credentials and had
// to notice the small "Create account" link to get past it — the signup funnel
// never reached signup. `screen_hint=signup` is the OIDC-standard spelling of
// the same request, so both are honored.
// hanzo.app's "Get started" forwards `signup=true`; `screen_hint=signup` is
// the OIDC-standard spelling of the same request, so both are honored. The
// sign-in form below offers no registration control of its own, so this hint
// (and hanzo.chat's direct `/signup/<client>` link) is how an app reaches it.
const wantsSignup = sp.get('signup') === 'true' || sp.get('screen_hint') === 'signup'
// prompt=select_account asks the person which account to use. IAM forwards it
// here only after declining to answer from a session, and forwards login_hint
@@ -270,33 +269,18 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
return (
<div className="hanzo-id-page hanzo-id-login">
<main>
{/* The form below SIGNS IN, and the heading says so. "Login or Signup"
promised a page that would tell a person which they were, and the
form cannot: IAM answers one sentence whether the password is wrong or
the account does not exist. The providers finish a first-time identity
too, and the button under the form is the door for everyone else. */}
{/* It names the ID, because a white-label portal that says only "Login or
Signup" does not say WHOSE — and this page is reached from another
product's button, so the first thing to confirm is that you landed on
the right identity. `idBrandLabel` per org: "Hanzo ID", "Lux ID",
"Zoo Labs ID" (which is the host it answers on, zoolabs.id). */}
{/* No brand in the heading: the mark sits top-left on every page now and
says whose sign-in this is. Naming it again here put "Lux ID" directly
under the LUX wordmark — the same fact twice, and on lux the wordmark
is 104px wide, so the pair read as the loudest thing on a page whose
job is one field and one button. */}
{/* The form SIGNS IN, and the heading says so. IAM answers one sentence
whether the password is wrong or the account does not exist, so the
page cannot promise to tell a person which they are. A first-time
identity arrives through a provider under the rule, which IAM
provisions wherever the application allows sign-up.
No brand in the heading: the mark top-left says whose sign-in this is. */}
<h1>Sign in</h1>
{/* 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. */}
{/* THE CREDENTIAL LEADS: email or username, password, Continue, the code
switch. Then "or", then Google, GitHub, the wallet and the phone —
each drawn only when this application can complete it. The form is a
child so PROVIDER_ORDER places it and the rule with everything else;
that list is the one arrangement for every brand. */}
<SocialButtons
client={client}
clientIdOverride={clientIdOverride}
@@ -321,25 +305,10 @@ export function Login({ client, brand }: { client: AuthClient; brand: Brand }) {
/>
{refused ? (
<p className="hanzo-id-note" role="status">
That didn&apos;t match. New here? Create an account below. Forgot your password?{' '}
That didn&apos;t match. Forgot your password?{' '}
<a href={`/forget${window.location.search}`}>Reset it</a>.
</p>
) : null}
{/* The heading offers two things and only one of them was a control.
Registration was 13px of text at the foot of the page, beside the
link for people who forgot a password — so a first-time visitor,
whose ONLY business here is this door, had to read past every way of
signing in to find it. It is a button of the same size and surface as
the ways in, directly under the credential it is the alternative to.
Carry the OIDC request across: this is a full page load, so a bare
href drops the client_id, redirect_uri, state and PKCE challenge the
app sent, and registration then has nothing to return the new account
to. `Signup` reads exactly these params. */}
<p className="hanzo-id-note">New here?</p>
<a className="hanzo-id-btn ghost" href={`/signup${window.location.search}`}>
Create account
</a>
</SocialButtons>
<p className="hanzo-id-footer-links">
<a href={`/forget${window.location.search}`}>Forgot password?</a>
+1 -1
View File
@@ -9,5 +9,5 @@
},
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
"include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
}
+12 -1
View File
@@ -22,7 +22,7 @@
# from package.json, so the pnpm this uses is the SAME pnpm the Dockerfile
# activates — stated once, in package.json, rather than pinned again here.
#
# Three gates, because there are three different ways this repo breaks and one
# Four gates, because there are four different ways this repo breaks and one
# combined gate would report all of them as the same failure.
test:
- name: install
@@ -48,6 +48,17 @@ test:
run: |
set -e
pnpm test
- name: e2e
# The sign-in page as a person meets it: the built export in Chromium on
# hanzo.id and lux.id, at 1440 and 390 (apps/web/e2e). Browsers go where the
# job may write, and --with-deps because the fleet's runner image
# (catthehacker act-24.04) ships none of Chromium's shared libraries.
run: |
set -e
pnpm --filter @hanzo/id-web build
export PLAYWRIGHT_BROWSERS_PATH="$RUNNER_TEMP/ms-playwright"
pnpm --filter @hanzo/id-web exec playwright install --with-deps chromium
pnpm e2e
images:
- name: id
+1
View File
@@ -9,6 +9,7 @@
"dev": "pnpm --filter @hanzo/id-web dev",
"tc": "pnpm -r tc",
"test": "vitest run",
"e2e": "pnpm --filter @hanzo/id-web e2e",
"test:watch": "vitest",
"clean": "bash scripts/clean.sh"
},
+5 -15
View File
@@ -13,22 +13,12 @@ import { authorizeRequest, matchProviderHint, PROVIDER_ORDER } from './social.ts
const PORTAL = 'hanzo-console'
test('Google is offered above GitHub, and the wallet last', () => {
test('the credential form leads, then Google, GitHub, the wallet, and phone last', () => {
// Order is a deliberate product decision, not an accident of how the buttons
// were typed out. It lived as an unexported constant inside the component and
// had already drifted once with nothing to catch it, which is the whole reason
// it is a value in this module now.
const at = (k: string) => PROVIDER_ORDER.indexOf(k as (typeof PROVIDER_ORDER)[number])
assert.ok(at('google') < at('github'), 'Google leads')
assert.ok(at('github') < at('gitlab'))
// The federated providers lead, then the two entries that are not federation at
// all: the wallet (a capability of the binary) and phone (which selects an
// identifier for the form above). Phone is last because it is the only entry
// that starts nothing — it changes what the field asks for and hands the person
// straight back to the credential they were already going to give.
assert.ok(at('gitlab') < at('web3'), 'the federated providers lead')
assert.ok(at('web3') < at('phone'), 'the wallet trails them')
assert.equal(PROVIDER_ORDER[PROVIDER_ORDER.length - 1], 'phone', 'phone is last')
// were typed out, so the whole list is pinned rather than a few pairs of it.
// The form opens the page; everything else is under the "or" that follows it.
// Phone is last because it starts nothing — it re-labels the field at the top.
assert.deepEqual([...PROVIDER_ORDER], ['form', 'google', 'github', 'gitlab', 'web3', 'phone'])
})
test('every ordered provider is one the hint matcher can also resolve', () => {
+19 -22
View File
@@ -63,35 +63,32 @@ export function authorizeRequest(search: string, clientId: string): OAuthAuthori
}
/**
* The order the ways in are offered in, most-used first.
* The sign-in column, top to bottom.
*
* 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
* value in a pure module, it is the only part of the button strip a test can
* actually hold still. It had drifted before: an unexported constant inside the
* component, covered by nothing.
* value in a pure module, it is the only part of the column a test can actually
* hold still.
*
* 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` — the credential form, email or username first — LEADS. It is the one
* way in every account has, and the page opens on it: the field, the password,
* Continue, the code switch under it. A rule reading "or" follows the form, and
* every other way in sits under that rule. There is no second arrangement for
* any brand or page; login, signup and device approval all read this list.
*
* `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.
* Google and GitHub head the entries under the rule because they are the
* accounts most people arrive already signed into. 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.
*
* `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.
* `web3` and `phone` are not federated providers — 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 because it starts nothing; it re-labels
* the field at the top.
*/
export const PROVIDER_ORDER = ['google', 'github', 'gitlab', 'form', 'web3', 'phone'] as const
export const PROVIDER_ORDER = ['form', 'google', 'github', 'gitlab', 'web3', 'phone'] as const
/**
* Resolve a `provider_hint` from the authorize query to one of the app's
+1 -2
View File
@@ -20,8 +20,7 @@ export interface LoginFormProps {
/**
* Called when the credential is refused, with the refusal's own words. IAM
* answers one sentence for a wrong password and for no such account, so the
* page that owns the other doors — create an account, reset a password — is
* the one that can point at them.
* page that owns the reset link is the one that can point at it.
*/
readonly onRefused?: (message: string) => void
/**
+15 -18
View File
@@ -156,17 +156,14 @@ test('phone is the last entry drawn', async () => {
assert.deepEqual(drawn(), ['google', 'github', 'web3', 'phone'])
})
// 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 () => {
// THE ARRANGEMENT. The credential form opens the column, the rule follows it, and
// every other way in sits under the rule. Nothing else can hold this still:
// PROVIDER_ORDER is a list of names, and where the form and the rule fall among
// the drawn entries is only visible here.
test('the form leads, the rule follows it, and every other way in is under the rule', async () => {
render(
<SocialButtons
client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google, github], chains: ['evm'] }) })}
client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [github, google], chains: ['evm'] }) })}
kind="email"
onKind={() => {}}
>
@@ -175,24 +172,24 @@ test('the one-click entries lead, the form follows, the specialist entries trail
)
await settled()
assert.deepEqual(column(), ['google', 'github', 'rule', 'form', 'web3', 'phone'])
assert.deepEqual(column(), ['form', 'rule', 'google', 'github', 'web3', 'phone'])
})
// 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 () => {
// The rule separates the form from the entries under it, so it travels WITH the
// form and appears only when something follows. 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 under the form, and only when something is under it', async () => {
render(
<SocialButtons client={createAuthClient({ org: org(), fetchImpl: iam({ providers: [google] }) })}>
<form />
</SocialButtons>,
)
await settled()
assert.deepEqual(column(), ['google', 'rule', 'form'])
assert.deepEqual(column(), ['form', 'rule', 'google'])
// 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
// under it would separate it from nothing. Second mount, not a re-render — the
// empty case is a different component life.
cleanup()
render(
@@ -201,7 +198,7 @@ test('the rule sits above the form, and only when something is above it', async
</SocialButtons>,
)
await waitFor(() => assert.ok(document.querySelector('form')))
assert.equal(document.querySelector('.hanzo-id-divider'), null, 'nothing above it, no rule')
assert.equal(document.querySelector('.hanzo-id-divider'), null, 'nothing under it, no rule')
})
// The form must not wait on the two descriptor reads. It is why the page exists,
+20 -27
View File
@@ -13,11 +13,10 @@ import { Divider } from './Divider'
/**
* 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.
* It draws the page's credential form, then a rule reading "or", then the
* entries. The form arrives as a child so the one order places it and the rule
* together with everything else; the rest of this component is about which
* entries are offered at all.
*
* Two sign-in shapes, decomplected — and they are answered by two DIFFERENT
* questions, which is the shape of the rest of this component:
@@ -95,17 +94,12 @@ export interface SocialButtonsProps {
*/
readonly onAutoStartResolved?: (started: boolean) => void
/**
* The credential form, placed at `form`'s slot in `PROVIDER_ORDER`.
* The credential form, placed at `form`'s slot in `PROVIDER_ORDER` — first.
*
* 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.
* It is a child rather than a sibling so the rule under it is drawn only when
* an entry follows, from the same resolved list that draws the entries. The
* page decides what goes in the slot; this component only decides where.
* Absent, the slot is not drawn and neither is the rule.
*/
readonly children?: ReactNode
}
@@ -292,10 +286,10 @@ export function SocialButtons({
: 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
// The rule separates the form from the entries under it. So it sits with the
// form, and only when an entry follows — an app with no providers configured
// gets its credential form and no rule dangling under it.
const ruled = ordered.length > 1
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
@@ -319,13 +313,12 @@ export function SocialButtons({
})
if (res.unlinked) {
// A wallet nobody holds is not a dead end. Somebody who already has an
// account — by email or a provider — was met with IAM's refusal and a
// "Create account" button, and made a second account to get in. The
// wallet is parked; the sign-in below attaches it, and from then on
// the wallet alone signs them in.
// account — by email or a provider — would otherwise make a second one to
// get in. The wallet is parked; the next sign-in on this page attaches
// it, and from then on the wallet alone signs them in.
parkWallet(chain)
setError(
'No account has this wallet yet. Sign in below with your email or a provider and it attaches to that account; after that, the wallet signs you in. New here? Create an account.',
'No account has this wallet yet. Sign in with your email or a provider and it attaches to that account; after that, the wallet signs you in.',
)
} else if (res.error) {
setError(res.error)
@@ -354,13 +347,13 @@ export function SocialButtons({
<div className="hanzo-id-social">
{ordered.map((k) => {
if (k === 'form') {
// The credential form, and the rule that says the ways above it end
// The credential form, and the rule that says the other ways in start
// 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.
// the field and the entries, so it moves with the field.
return (
<Fragment key="form">
{ruled ? <Divider /> : null}
{children}
{ruled ? <Divider /> : null}
</Fragment>
)
}
+31
View File
@@ -36,6 +36,9 @@ catalogs:
'@paulmillr/qr':
specifier: ^0.3.0
version: 0.3.0
'@playwright/test':
specifier: 1.63.0
version: 1.63.0
'@tanstack/react-router':
specifier: 1.170.8
version: 1.170.8
@@ -288,6 +291,9 @@ importers:
specifier: 'catalog:'
version: 2.56.1([email protected])
devDependencies:
'@playwright/test':
specifier: 'catalog:'
version: 1.63.0
'@types/react':
specifier: 'catalog:'
version: 19.2.18
@@ -1079,6 +1085,11 @@ packages:
resolution: {integrity: sha512-3s/cagXuoXTA2gWSfSfJNanNgm2ifmqgoX8WLOs5//3qrIJ3WWHFjqFqCxvYGf46Afwv6PctT9eAOXLDGwp96Q==}
deprecated: 'Switch to "qr" (new package name) for security updates: npm install qr'
'@playwright/[email protected]':
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
engines: {node: '>=20'}
hasBin: true
'@poppinss/[email protected]':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
@@ -2135,6 +2146,16 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
engines: {node: '>=20'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
engines: {node: '>=20'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2991,6 +3012,10 @@ snapshots:
'@paulmillr/[email protected]': {}
'@playwright/[email protected]':
dependencies:
playwright: 1.63.0
'@poppinss/[email protected]':
dependencies:
kleur: 4.1.5
@@ -4008,6 +4033,12 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.63.0
[email protected]: {}
[email protected]:
+1
View File
@@ -120,6 +120,7 @@ catalog:
'@noble/hashes': '1.8.0'
'@parsdao/brand': '^1.0.1'
'@paulmillr/qr': '^0.3.0'
'@playwright/test': '1.63.0'
'@tanstack/react-router': '1.170.8'
'@testing-library/react': '^16.3.3'
'@tonconnect/sdk': '^4.0.2'