mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
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:
@@ -9,6 +9,7 @@ out/
|
||||
.env.production.local
|
||||
# Vite + pnpm monorepo
|
||||
apps/*/dist
|
||||
apps/*/test-results
|
||||
pkgs/*/dist
|
||||
**/tsconfig.tsbuildinfo
|
||||
**/node_modules
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 } } },
|
||||
],
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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't match. New here? Create an account below. Forgot your password?{' '}
|
||||
That didn'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>
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
"include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Generated
+31
@@ -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]:
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user