Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5638f0772 |
@@ -13,20 +13,15 @@ permissions:
|
||||
packages: write
|
||||
jobs:
|
||||
docker:
|
||||
# Route to the org-level ARC scale set (always-on, autoscales 0→100).
|
||||
# The bare [self-hosted, linux, amd64] labels target the native dbc/evo/
|
||||
# spark runners, which queue indefinitely when offline. ARC v0.14 routes
|
||||
# by scale-set NAME, so name the pool directly (matches universe + the
|
||||
# shared hanzoai/.github docker-build.yml default).
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
runs-on: [self-hosted, linux, amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
SHA="sha-${GITHUB_SHA:0:7}"
|
||||
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -1)"
|
||||
SHA="sha-$(git rev-parse --short HEAD)"
|
||||
VER="$(node -p "require('./package.json').version")"
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "ver=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Tags: $SHA, $VER"
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# LLM.md — Hanzo ID
|
||||
|
||||
## Org-agnostic password login (fixed 0.1.23)
|
||||
|
||||
The portal login is now **org-agnostic**: it no longer pins
|
||||
`organization=<brand>` on `POST /v1/iam/login`. `LoginForm` passes
|
||||
`tenant.loginOrg` (a NEW, normally-UNSET `TenantConfig` field) as the
|
||||
`organization`, and `client.login()` OMITS the field entirely when it is
|
||||
empty/undefined. With no org posted, IAM runs its **cross-org resolution**
|
||||
(`object.GetUserByFields` → `GetUserByFieldCrossOrg`) and the session encodes the
|
||||
user's REAL owner-org (`GetOrganizationByUser`), never the posted hint.
|
||||
|
||||
Why this matters (the bug it fixes): IAM's `IsGlobalAdmin()` is
|
||||
`user.Owner == "admin"` (it ignores the stored `isGlobalAdmin` column), and
|
||||
`get-organizations` returns ALL orgs only for a global admin, else just the
|
||||
caller's own org. The seeded superusers (`z@hanzo.ai`, `a@hanzo.ai`,
|
||||
`woo@lux.network`) exist in BOTH the `admin` org (the global identity) AND their
|
||||
brand org (`hanzo/lux`). Pinning `organization=hanzo` made `GetUserByFields`
|
||||
hit the colliding `hanzo/z` row FIRST (in-org lookup succeeds → cross-org
|
||||
fallback never runs), so an admin got a 1-org `hanzo` session via the UI even
|
||||
though the API could reach the 45-org global session. Omitting the org makes the
|
||||
in-org lookups miss → cross-org fallback → `admin/z` (global) for the colliding
|
||||
hanzo-domain emails, while a brand-only identity (`z@lux.network`,
|
||||
`major@hanzo.ai`, …) still resolves to its own org. Verified live on
|
||||
`hanzo.id`: `z@hanzo.ai` → `owner=admin`, 45 orgs; a brand-only user → 1 org.
|
||||
|
||||
Boundaries (do NOT regress):
|
||||
- **Signup** still sends a concrete `organization` (`tenant.orgId`) — you cannot
|
||||
create a user in "no org". Only LOGIN omits it.
|
||||
- **Per-app SSO** (console/chat/team pass their own `client_id` + `redirect_uri`)
|
||||
is unaffected: `type=code` + the app's client_id still flow; the auth code is
|
||||
bound to the cross-org-resolved user. Proven against live IAM with
|
||||
`application=hanzo-console`.
|
||||
- A brand that deliberately wants single-org portal login can set `loginOrg` in
|
||||
its runtime catalog entry (`id-tenant-catalog` ConfigMap) — no rebuild.
|
||||
- Contract locked in `pkgs/auth/src/client.test.ts` (omit-when-unset,
|
||||
omit-when-empty, include-when-set, SSO still omits, signup still sends).
|
||||
|
||||
## PKCE on password login (fixed 0.1.13)
|
||||
|
||||
`client.login()` (POST `/v1/iam/login`) must forward `code_challenge`
|
||||
|
||||
@@ -81,12 +81,6 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
.hanzo-id-footer-links { color: var(--muted); font-size: 14px; }
|
||||
.hanzo-id-footer-links a { color: var(--fg); }
|
||||
|
||||
/* A2P SMS consent disclosure (shown on phone/SMS surfaces). */
|
||||
.hanzo-id-sms-consent { color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.hanzo-id-sms-consent p { margin: 0 0 6px; }
|
||||
.hanzo-id-sms-consent-links { margin: 0; }
|
||||
.hanzo-id-sms-consent a { color: var(--fg); }
|
||||
|
||||
.hanzo-id-error {
|
||||
background: #2d0a0a;
|
||||
color: #ff7878;
|
||||
@@ -103,36 +97,6 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Forced TOTP enrollment ────────────────────────────────────── */
|
||||
.hanzo-id-mfa-enroll { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-mfa-enroll h2 { margin: 0; font-size: 22px; }
|
||||
.hanzo-id-mfa-qr {
|
||||
align-self: center;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.hanzo-id-mfa-qr svg { width: 100%; height: 100%; display: block; }
|
||||
.hanzo-id-mfa-manual { font-size: 14px; color: var(--muted); }
|
||||
.hanzo-id-mfa-manual summary { cursor: pointer; }
|
||||
.hanzo-id-mfa-secret,
|
||||
.hanzo-id-mfa-recovery code {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.hanzo-id-mfa-recovery { font-size: 13px; color: var(--muted); line-height: 1.6; }
|
||||
.hanzo-id-mfa-recovery code { letter-spacing: normal; }
|
||||
|
||||
/* ── Social / Web3 sign-in buttons ─────────────────────────────── */
|
||||
.hanzo-id-social { display: flex; flex-direction: column; gap: 10px; }
|
||||
.hanzo-id-social-btn {
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import {
|
||||
LoginForm,
|
||||
MfaEnrollForm,
|
||||
OTPForm,
|
||||
SocialButtons,
|
||||
mfaChannelOf,
|
||||
type AuthClient,
|
||||
type LoginResponse,
|
||||
} from '@hanzo/id-auth'
|
||||
import { LoginForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
@@ -18,78 +9,6 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
const clientIdOverride = sp.get('client_id') ?? undefined
|
||||
const codeChallenge = sp.get('code_challenge') ?? undefined
|
||||
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
|
||||
|
||||
// null = show the credential form; otherwise IAM returned an MFA signal and
|
||||
// we render the matching step instead of navigating on.
|
||||
const [mfa, setMfa] = useState<LoginResponse | null>(null)
|
||||
const [challengeError, setChallengeError] = useState<string | null>(null)
|
||||
|
||||
const clientId = clientIdOverride ?? client.tenant.clientId
|
||||
|
||||
// The credential check succeeded (or MFA was satisfied). For a downstream
|
||||
// OIDC request, re-enter authorize with the now-established IAM session so it
|
||||
// mints the code; for a bare portal sign-in, land on onboarding.
|
||||
function completeAfterAuth() {
|
||||
if (redirectUri) {
|
||||
window.location.href = client.authorize({
|
||||
clientId,
|
||||
redirectUri,
|
||||
state: state ?? '',
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
} else {
|
||||
window.location.href = '/onboarding'
|
||||
}
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'enroll') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<MfaEnrollForm client={client} onComplete={completeAfterAuth} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mfa?.mfaStage === 'challenge') {
|
||||
const iamType = mfa.mfaTypes?.[0] ?? 'app'
|
||||
async function onChallenge(code: string) {
|
||||
setChallengeError(null)
|
||||
const res = await client.mfaChallenge({
|
||||
mfaType: iamType,
|
||||
passcode: code,
|
||||
clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
redirectUri,
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
})
|
||||
if (res.error) {
|
||||
setChallengeError(res.error)
|
||||
} else if (res.redirectUrl) {
|
||||
window.location.href = res.redirectUrl
|
||||
} else {
|
||||
completeAfterAuth()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Two-factor authentication</h1>
|
||||
<p className="lede">Enter the code from your authenticator app to finish signing in.</p>
|
||||
{challengeError ? <p role="alert" className="hanzo-id-error">{challengeError}</p> : null}
|
||||
<OTPForm channel={mfaChannelOf(iamType)} onSubmit={onChallenge} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
@@ -108,7 +27,6 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
codeChallenge={codeChallenge}
|
||||
codeChallengeMethod={codeChallengeMethod}
|
||||
onMfaRequired={setMfa}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/id",
|
||||
"private": true,
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.23",
|
||||
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.11.0",
|
||||
"@paulmillr/qr": "^0.3.0"
|
||||
"@hanzo/iam": "^0.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* MFA wiring tests — pure, no network (fetch is mocked via `fetchImpl`).
|
||||
* Run with: pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Locks the wire contract verified live against iam.hanzo.ai:
|
||||
* - login answers a forced-MFA org with `data:"RequiredMfa"` (enroll) or
|
||||
* `data:"NextMfa"` + `data2` (challenge) — STRINGS, never a boolean.
|
||||
* - the `/v1/iam/mfa/setup/*` calls carry EVERY param on the query string with
|
||||
* an EMPTY body (the one shape IAM's authz self-match + controller accept).
|
||||
* - the challenge re-POSTs `/v1/iam/login` with `{mfaType,passcode}` and NO
|
||||
* username, riding the MFA session cookie.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import { createAuthClient, mfaChannelOf, MFA_TOTP } from './client.ts'
|
||||
|
||||
const TENANT: TenantConfig = {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
}
|
||||
|
||||
type Call = { url: string; init: RequestInit }
|
||||
|
||||
function mockFetch(body: unknown, calls: Call[]): typeof fetch {
|
||||
return (async (input: string | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(input), init: init ?? {} })
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
test('login → RequiredMfa maps to an enroll signal (not a redirect)', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'RequiredMfa' }, calls) })
|
||||
const res = await client.login({
|
||||
identifier: 'davelorenzini@gmail.com',
|
||||
password: 'x',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(res.mfaRequired, true)
|
||||
assert.equal(res.mfaStage, 'enroll')
|
||||
assert.equal(res.redirectUrl, undefined, 'must NOT short-circuit to /onboarding')
|
||||
})
|
||||
|
||||
test('login → NextMfa maps to a challenge signal and carries the allowed types', async () => {
|
||||
const calls: Call[] = []
|
||||
const body = {
|
||||
status: 'ok',
|
||||
data: 'NextMfa',
|
||||
data2: [{ mfaType: 'app', enabled: true }, { mfaType: 'sms', enabled: true }],
|
||||
}
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch(body, calls) })
|
||||
const res = await client.login({
|
||||
identifier: 'davelorenzini@gmail.com',
|
||||
password: 'x',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(res.mfaStage, 'challenge')
|
||||
assert.deepEqual(res.mfaTypes, ['app', 'sms'])
|
||||
})
|
||||
|
||||
test('mfaInitiate puts owner/name/mfaType on the query string with an empty body', async () => {
|
||||
const calls: Call[] = []
|
||||
const data = { secret: 'BOUYRUSHJCEDDB33', url: 'otpauth://totp/Hanzo:x?secret=BOUYRUSHJCEDDB33', recoveryCodes: ['rc-1'] }
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data }, calls) })
|
||||
const setup = await client.mfaInitiate({ owner: 'hanzo', name: 'davelorenzini@gmail.com' })
|
||||
|
||||
assert.equal(setup.secret, 'BOUYRUSHJCEDDB33')
|
||||
assert.equal(setup.mfaType, MFA_TOTP)
|
||||
assert.deepEqual(setup.recoveryCodes, ['rc-1'])
|
||||
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/initiate')
|
||||
assert.equal(u.searchParams.get('owner'), 'hanzo')
|
||||
assert.equal(u.searchParams.get('name'), 'davelorenzini@gmail.com')
|
||||
assert.equal(u.searchParams.get('mfaType'), 'app')
|
||||
assert.equal(calls[0].init.method, 'POST')
|
||||
assert.equal(calls[0].init.body, undefined, 'body must be empty for authz self-match')
|
||||
assert.equal(calls[0].init.credentials, 'include')
|
||||
})
|
||||
|
||||
test('mfaVerify carries owner/name (for authz) + secret + passcode on the query', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
|
||||
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '123456' })
|
||||
assert.equal(r.ok, true)
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/verify')
|
||||
assert.equal(u.searchParams.get('owner'), 'hanzo')
|
||||
assert.equal(u.searchParams.get('secret'), 'SEC')
|
||||
assert.equal(u.searchParams.get('passcode'), '123456')
|
||||
assert.equal(u.searchParams.get('mfaType'), 'app')
|
||||
})
|
||||
|
||||
test('mfaVerify surfaces an IAM error instead of throwing', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'error', msg: 'wrong passcode' }, calls) })
|
||||
const r = await client.mfaVerify({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', passcode: '000000' })
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.error, 'wrong passcode')
|
||||
})
|
||||
|
||||
test('mfaEnable echoes the recovery code back on the query', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'OK' }, calls) })
|
||||
const r = await client.mfaEnable({ owner: 'hanzo', name: 'dave@x', secret: 'SEC', recoveryCode: 'rc-1' })
|
||||
assert.equal(r.ok, true)
|
||||
const u = new URL(calls[0].url)
|
||||
assert.equal(u.pathname, '/v1/iam/mfa/setup/enable')
|
||||
assert.equal(u.searchParams.get('recoveryCodes'), 'rc-1')
|
||||
assert.equal(u.searchParams.get('secret'), 'SEC')
|
||||
})
|
||||
|
||||
test('mfaChallenge re-POSTs /v1/iam/login with mfaType/passcode and NO username', async () => {
|
||||
const calls: Call[] = []
|
||||
// code flow: data is the freshly minted auth code
|
||||
const client = createAuthClient({ tenant: TENANT, fetchImpl: mockFetch({ status: 'ok', data: 'AUTHCODE' }, calls) })
|
||||
const res = await client.mfaChallenge({
|
||||
mfaType: 'app',
|
||||
passcode: '654321',
|
||||
clientId: 'hanzo-id',
|
||||
application: 'hanzo-id',
|
||||
organization: 'hanzo',
|
||||
redirectUri: 'https://app.example/cb',
|
||||
state: 'st',
|
||||
})
|
||||
const sent = JSON.parse(String(calls[0].init.body)) as Record<string, unknown>
|
||||
assert.equal(new URL(calls[0].url).pathname, '/v1/iam/login')
|
||||
assert.equal(sent.mfaType, 'app')
|
||||
assert.equal(sent.passcode, '654321')
|
||||
assert.equal(sent.username, undefined, 'challenge must not send a username')
|
||||
assert.equal(calls[0].init.credentials, 'include')
|
||||
assert.equal(res.redirectUrl, 'https://app.example/cb?code=AUTHCODE&state=st')
|
||||
})
|
||||
|
||||
test('mfaChannelOf maps IAM types to UI channels', () => {
|
||||
assert.equal(mfaChannelOf('app'), 'totp')
|
||||
assert.equal(mfaChannelOf('sms'), 'sms')
|
||||
assert.equal(mfaChannelOf('email'), 'email')
|
||||
assert.equal(mfaChannelOf('anything-else'), 'totp')
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAuthClient } from './client.ts'
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
|
||||
// A capturing fetch double: records the URL + parsed JSON body of the last call
|
||||
// and returns a canned IAM "ok" response. No network.
|
||||
function capturingFetch() {
|
||||
const calls: { url: string; body: Record<string, unknown> }[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
let body: Record<string, unknown> = {}
|
||||
if (init?.body && typeof init.body === 'string') body = JSON.parse(init.body)
|
||||
calls.push({ url, body })
|
||||
return new Response(JSON.stringify({ status: 'ok', data: 'AUTHCODE' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
function tenant(overrides: Partial<TenantConfig> = {}): TenantConfig {
|
||||
return {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-console',
|
||||
appName: 'hanzo-console',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
oauthCallbackOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// THE FIX: with loginOrg unset, the portal must NOT pin the brand org — it omits
|
||||
// `organization` so IAM resolves the user cross-org (a global admin → the admin
|
||||
// org / full session; a brand user → their own org). Pinning `hanzo` here is the
|
||||
// live bug that truncates a global admin to one org.
|
||||
test('login OMITS organization when loginOrg is unset (org-agnostic resolution)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
// organization intentionally not provided (LoginForm passes tenant.loginOrg)
|
||||
})
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(
|
||||
'organization' in calls[0]!.body,
|
||||
false,
|
||||
'organization must be absent from the body so IAM runs cross-org resolution',
|
||||
)
|
||||
// The identity + app still ride the request.
|
||||
assert.equal(calls[0]!.body.username, 'z@hanzo.ai')
|
||||
assert.equal(calls[0]!.body.application, 'hanzo-console')
|
||||
})
|
||||
|
||||
// An empty-string org is treated the same as unset (defensive: a catalog might
|
||||
// emit "").
|
||||
test('login OMITS organization when it is an empty string', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: '',
|
||||
})
|
||||
assert.equal('organization' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// A brand that DELIBERATELY scopes its portal to one org can still force it.
|
||||
test('login INCLUDES organization when one is explicitly provided', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'someone',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
})
|
||||
|
||||
// Per-app SSO: the downstream app's client_id + redirect_uri still flow through;
|
||||
// `type` flips to `code` and the org is STILL omitted (resolution stays correct
|
||||
// for the SSO path too).
|
||||
test('app SSO (redirectUri present) uses type=code and still omits organization', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.login({
|
||||
identifier: 'z@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
redirectUri: 'https://console.hanzo.ai/auth/iam/callback',
|
||||
state: 'xyz',
|
||||
})
|
||||
assert.equal(calls[0]!.body.type, 'code')
|
||||
assert.match(calls[0]!.url, /type=code/)
|
||||
assert.equal('organization' in calls[0]!.body, false)
|
||||
})
|
||||
|
||||
// Signup MUST still carry a concrete org — you cannot create a user in "no org".
|
||||
test('signup STILL sends organization (unchanged — create needs a concrete org)', async () => {
|
||||
const { calls, fetchImpl } = capturingFetch()
|
||||
const client = createAuthClient({ tenant: tenant(), fetchImpl })
|
||||
await client.signup({
|
||||
email: 'new@hanzo.ai',
|
||||
password: 'pw',
|
||||
clientId: 'hanzo-console',
|
||||
application: 'hanzo-console',
|
||||
organization: 'hanzo',
|
||||
})
|
||||
assert.equal(calls[0]!.body.organization, 'hanzo')
|
||||
})
|
||||
+18
-186
@@ -5,23 +5,11 @@ import type {
|
||||
ForgotRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChallengeRequest,
|
||||
MfaChannel,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
OAuthAuthorizeRequest,
|
||||
SignupRequest,
|
||||
TokenResponse,
|
||||
} from './types'
|
||||
|
||||
/** IAM's TOTP MFA type constant (`object.TotpType`). */
|
||||
export const MFA_TOTP = 'app'
|
||||
|
||||
/** Map an IAM MFA type to the {@link MfaChannel} the OTP UI renders a label for. */
|
||||
export function mfaChannelOf(iamType: string): MfaChannel {
|
||||
return iamType === 'sms' ? 'sms' : iamType === 'email' ? 'email' : 'totp'
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable IAM client.
|
||||
*
|
||||
@@ -69,31 +57,6 @@ export interface AuthClient {
|
||||
* provider creds are seeded (the buttons are hidden until then).
|
||||
*/
|
||||
providerLogin(req: ProviderExchangeRequest): Promise<{ redirectUrl?: string; error?: string }>
|
||||
/**
|
||||
* Resolve the signed-in user's `{owner, name}` from the IAM session
|
||||
* (`/v1/iam/get-account`). After a `RequiredMfa` login the IAM session cookie
|
||||
* already authenticates the user (IAM calls `SetSessionUsername` before
|
||||
* answering `RequiredMfa`), so this is how the portal learns the identity to
|
||||
* key the forced-enrollment calls on. Resolves null when unauthenticated.
|
||||
*/
|
||||
getAccount(): Promise<MfaIdentity | null>
|
||||
/**
|
||||
* Begin TOTP enrollment: `POST /v1/iam/mfa/setup/initiate`. Returns the secret
|
||||
* + `otpauth://` URI + recovery codes. Does NOT persist anything — only
|
||||
* {@link mfaEnable} does.
|
||||
*/
|
||||
mfaInitiate(id: MfaIdentity): Promise<MfaSetup>
|
||||
/** Verify a TOTP code against a pending secret: `POST /v1/iam/mfa/setup/verify`. */
|
||||
mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/** Persist a verified TOTP enrollment: `POST /v1/iam/mfa/setup/enable`. */
|
||||
mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }>
|
||||
/**
|
||||
* Answer a `NextMfa` challenge: `POST /v1/iam/login` with `{mfaType, passcode}`
|
||||
* and NO username, riding the MFA session cookie IAM set with `NextMfa`.
|
||||
* Returns the same shape as {@link login} (a redirect with an auth code for the
|
||||
* code flow, or a bare-session signal for portal sign-in).
|
||||
*/
|
||||
mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse>
|
||||
}
|
||||
|
||||
/** Inputs to {@link AuthClient.providerLogin}, recovered from the /callback return. */
|
||||
@@ -133,19 +96,25 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', type)
|
||||
// `organization` is an OPTIONAL lookup hint (see LoginRequest). Omit it when
|
||||
// empty so IAM runs its cross-org resolution: a global-admin identity then
|
||||
// resolves to the `admin` org (full multi-org session) instead of being
|
||||
// pinned to — and truncated by — a colliding brand-org row. The session's
|
||||
// org is always the resolved user's real owner, never this hint.
|
||||
const body: Record<string, unknown> = {
|
||||
type,
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
application: req.application,
|
||||
signinMethod: 'Password',
|
||||
autoSignin: true,
|
||||
}
|
||||
if (req.organization) body.organization = req.organization
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
username: req.identifier,
|
||||
password: req.password,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
signinMethod: 'Password',
|
||||
autoSignin: true,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
@@ -308,129 +277,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
return data ? { redirectUrl: data } : { error: 'provider login returned no redirect' }
|
||||
}
|
||||
|
||||
async function getAccount(): Promise<MfaIdentity | null> {
|
||||
const url = new URL('/v1/iam/get-account', tenant.iamUrl)
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
if (typeof d.owner !== 'string' || typeof d.name !== 'string' || !d.owner || !d.name) return null
|
||||
return { owner: d.owner, name: d.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `/v1/iam/mfa/setup/*` POST URL with EVERY param on the query string
|
||||
* and send an EMPTY body. This is the one wire shape IAM's authz filter and
|
||||
* the MFA controller both accept: the controller reads `owner`/`name`/… from
|
||||
* the merged form (query + body), while the authz filter only extracts the
|
||||
* `{owner,name}` object from the query when the body is empty (a non-empty
|
||||
* body is JSON-unmarshalled, and a urlencoded body fails that parse → empty
|
||||
* object → the self-access match `sub==obj` fails → "Unauthorized operation").
|
||||
* `owner`/`name` ride the query on EVERY call — including `verify`, which
|
||||
* otherwise carries no identity — purely so that self-access check passes.
|
||||
*/
|
||||
async function mfaSetupPost(path: string, params: Record<string, string>): Promise<Record<string, unknown>> {
|
||||
const url = new URL(`/v1/iam/mfa/setup/${path}`, tenant.iamUrl)
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
||||
const res = await f(url.toString(), { method: 'POST', credentials: 'include' })
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (typeof body.status === 'string' && body.status === 'error') {
|
||||
throw new Error(typeof body.msg === 'string' && body.msg ? body.msg : `HTTP ${res.status}`)
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return body
|
||||
}
|
||||
|
||||
async function mfaInitiate(id: MfaIdentity): Promise<MfaSetup> {
|
||||
const body = await mfaSetupPost('initiate', { owner: id.owner, name: id.name, mfaType: MFA_TOTP })
|
||||
const d = (typeof body.data === 'object' && body.data ? body.data : {}) as Record<string, unknown>
|
||||
const secret = typeof d.secret === 'string' ? d.secret : ''
|
||||
const url = typeof d.url === 'string' ? d.url : ''
|
||||
if (!secret || !url) throw new Error('IAM returned no TOTP secret')
|
||||
return {
|
||||
mfaType: MFA_TOTP,
|
||||
secret,
|
||||
url,
|
||||
recoveryCodes: Array.isArray(d.recoveryCodes) ? d.recoveryCodes.filter((c): c is string => typeof c === 'string') : [],
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaVerify(req: MfaIdentity & { secret: string; passcode: string }): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await mfaSetupPost('verify', { owner: req.owner, name: req.name, mfaType: MFA_TOTP, secret: req.secret, passcode: req.passcode })
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaEnable(req: MfaIdentity & { secret: string; recoveryCode: string }): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
await mfaSetupPost('enable', {
|
||||
owner: req.owner,
|
||||
name: req.name,
|
||||
mfaType: MFA_TOTP,
|
||||
secret: req.secret,
|
||||
recoveryCodes: req.recoveryCode,
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async function mfaChallenge(req: MfaChallengeRequest): Promise<LoginResponse> {
|
||||
const type = req.redirectUri ? 'code' : 'login'
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
if (req.state) url.searchParams.set('state', req.state)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
|
||||
}
|
||||
url.searchParams.set('type', type)
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
// No username: IAM resolves the user from the MFA session cookie it set
|
||||
// when it answered NextMfa.
|
||||
mfaType: req.mfaType,
|
||||
passcode: req.passcode,
|
||||
application: req.application,
|
||||
organization: req.organization,
|
||||
enableMfaRemember: req.rememberDevice ?? false,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
}
|
||||
|
||||
return {
|
||||
tenant,
|
||||
login,
|
||||
signup,
|
||||
forgot,
|
||||
authorize,
|
||||
exchange,
|
||||
logout,
|
||||
getAppLogin,
|
||||
providerLogin,
|
||||
getAccount,
|
||||
mfaInitiate,
|
||||
mfaVerify,
|
||||
mfaEnable,
|
||||
mfaChallenge,
|
||||
}
|
||||
return { tenant, login, signup, forgot, authorize, exchange, logout, getAppLogin, providerLogin }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,23 +358,6 @@ async function parseLoginResponse(
|
||||
}
|
||||
const data = body.data
|
||||
|
||||
// Multi-factor signal — IAM answers a successful credential check with a
|
||||
// STRING in `data` (NOT a `mfa_required` boolean): `"RequiredMfa"` when org
|
||||
// policy forces MFA the user has not enrolled, `"NextMfa"` when the user has
|
||||
// MFA and must answer a challenge. Branch BEFORE any session/redirect return:
|
||||
// the password session is not yet usable, so the portal must render the
|
||||
// enrollment/challenge step rather than navigate on.
|
||||
if (data === 'RequiredMfa') {
|
||||
return { mfaRequired: true, mfaStage: 'enroll' }
|
||||
}
|
||||
if (data === 'NextMfa') {
|
||||
const allow = Array.isArray(body.data2) ? body.data2 : []
|
||||
const mfaTypes = allow
|
||||
.map((p) => (typeof p === 'object' && p !== null ? (p as Record<string, unknown>).mfaType : undefined))
|
||||
.filter((t): t is string => typeof t === 'string' && t.length > 0)
|
||||
return { mfaRequired: true, mfaStage: 'challenge', mfaTypes }
|
||||
}
|
||||
|
||||
// Authorization-code flow: a client redirectUri is present and `data` is the
|
||||
// freshly minted code — hand the SPA a fully-formed redirect back to the app.
|
||||
if (req?.redirectUri && typeof data === 'string' && data.length > 0) {
|
||||
@@ -552,5 +382,7 @@ async function parseLoginResponse(
|
||||
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
|
||||
idToken: typeof d.id_token === 'string' ? d.id_token : undefined,
|
||||
expiresAt: typeof d.expires_at === 'number' ? d.expires_at : undefined,
|
||||
mfaRequired: d.mfa_required === true,
|
||||
mfaChannel: typeof d.mfa_channel === 'string' ? (d.mfa_channel as LoginResponse['mfaChannel']) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -1,10 +1,4 @@
|
||||
export {
|
||||
createAuthClient,
|
||||
mfaChannelOf,
|
||||
MFA_TOTP,
|
||||
type AuthClient,
|
||||
type AuthClientOptions,
|
||||
} from './client'
|
||||
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
|
||||
export { createIam } from './iam'
|
||||
export {
|
||||
startProviderLogin,
|
||||
@@ -15,10 +9,6 @@ export {
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChannel,
|
||||
MfaChallengeRequest,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
SignupRequest,
|
||||
ForgotRequest,
|
||||
OAuthAuthorizeRequest,
|
||||
|
||||
+20
-60
@@ -3,82 +3,42 @@ export interface LoginRequest {
|
||||
readonly password: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
/**
|
||||
* Org-resolution anchor for the credential lookup. OPTIONAL by design.
|
||||
*
|
||||
* IAM resolves the user by (org, identifier); if the in-org lookup misses it
|
||||
* falls back to a CROSS-ORG lookup by email/username and the session always
|
||||
* encodes the user's REAL owner-org (`GetOrganizationByUser`), never this
|
||||
* value. So this field is a lookup HINT, not the session's org.
|
||||
*
|
||||
* Leaving it empty/undefined makes login ORG-AGNOSTIC: every in-org lookup
|
||||
* misses, the cross-org fallback runs, and an identity that lives in the
|
||||
* global `admin` org (a global admin) resolves to `admin` (→ full multi-org
|
||||
* session) while a brand-only identity resolves to its own brand org. This is
|
||||
* why the portal does NOT pin the brand org here — pinning `hanzo` would
|
||||
* resolve a colliding `hanzo/<name>` row and truncate a global admin to one
|
||||
* org. Set it only to FORCE a specific tenant (e.g. a brand that deliberately
|
||||
* scopes its portal to a single org). Signup, by contrast, MUST carry a
|
||||
* concrete org (you cannot create a user in "no org").
|
||||
*/
|
||||
readonly organization?: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
}
|
||||
|
||||
/** A multi-factor channel the portal can render a code entry for. */
|
||||
export type MfaChannel = 'totp' | 'sms' | 'email'
|
||||
|
||||
export interface LoginResponse {
|
||||
readonly accessToken?: string
|
||||
readonly refreshToken?: string
|
||||
readonly idToken?: string
|
||||
readonly expiresAt?: number
|
||||
readonly redirectUrl?: string
|
||||
/**
|
||||
* Set when IAM answered the login with a multi-factor signal instead of a
|
||||
* session/code. `mfaStage` discriminates the two IAM states:
|
||||
* - `'enroll'` — IAM returned `data:"RequiredMfa"`: org policy forces MFA
|
||||
* and the user has none yet → render forced TOTP enrollment.
|
||||
* - `'challenge'` — IAM returned `data:"NextMfa"`: the user has MFA enabled
|
||||
* → render a code challenge for one of `mfaTypes`.
|
||||
* The password session is NOT established until the enrollment/challenge
|
||||
* completes, so the portal must not navigate past this signal.
|
||||
*/
|
||||
readonly mfaRequired?: boolean
|
||||
readonly mfaStage?: 'enroll' | 'challenge'
|
||||
/**
|
||||
* The IAM MFA types available for a `'challenge'` (from the login response's
|
||||
* `data2`), in IAM's own vocabulary: `app` (TOTP), `sms`, `email`. Empty for
|
||||
* enrollment.
|
||||
*/
|
||||
readonly mfaTypes?: readonly string[]
|
||||
readonly mfaChannel?: 'totp' | 'sms' | 'email'
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The TOTP enrollment material minted by `/v1/iam/mfa/setup/initiate`. The
|
||||
* secret + `url` (an `otpauth://` URI) are rendered locally as a QR code — the
|
||||
* secret never leaves the browser to a third party. `recoveryCodes[0]` must be
|
||||
* echoed back to `/v1/iam/mfa/setup/enable`.
|
||||
*/
|
||||
export interface MfaSetup {
|
||||
/** IAM MFA type — `app` for TOTP. */
|
||||
readonly mfaType: string
|
||||
/** Base32 TOTP secret. */
|
||||
readonly secret: string
|
||||
/** `otpauth://totp/...` provisioning URI for the authenticator app. */
|
||||
readonly url: string
|
||||
/** One-time recovery codes issued alongside the secret. */
|
||||
readonly recoveryCodes: readonly string[]
|
||||
}
|
||||
|
||||
/** The signed-in user's identity, resolved from the IAM session for MFA setup. */
|
||||
export interface MfaIdentity {
|
||||
readonly owner: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** A TOTP challenge submission for a user who already enrolled (`NextMfa`). */
|
||||
export interface MfaChallengeRequest {
|
||||
/** IAM MFA type, e.g. `app` (TOTP), `sms`, `email`. */
|
||||
readonly mfaType: string
|
||||
readonly passcode: string
|
||||
readonly clientId: string
|
||||
readonly application: string
|
||||
readonly organization: string
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
/** Honor the org's "remember this device" window after a successful code. */
|
||||
readonly rememberDevice?: boolean
|
||||
}
|
||||
|
||||
export interface SignupRequest {
|
||||
readonly email: string
|
||||
readonly password: string
|
||||
|
||||
@@ -30,7 +30,12 @@ export function LoginForm(props: LoginFormProps) {
|
||||
password,
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
// Org-agnostic by default: `loginOrg` is unset, so no `organization` is
|
||||
// posted and IAM resolves the user cross-org by credentials. A global
|
||||
// admin (identity in the `admin` org) lands in the global multi-org
|
||||
// session; a brand-only user lands in their own org. Pinning the brand
|
||||
// org here would truncate a global admin to one org (the live bug).
|
||||
organization: client.tenant.loginOrg,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
codeChallenge: props.codeChallenge,
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import encodeQR from '@paulmillr/qr'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { MfaIdentity, MfaSetup } from '../types'
|
||||
import { OTPForm } from './OTPForm'
|
||||
|
||||
export interface MfaEnrollFormProps {
|
||||
readonly client: AuthClient
|
||||
/**
|
||||
* Called once the user has verified a TOTP code AND the enrollment is
|
||||
* persisted. The caller continues the session (onboarding or the OIDC
|
||||
* code redirect).
|
||||
*/
|
||||
readonly onComplete: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Forced TOTP enrollment, shown when IAM answers a login with `RequiredMfa`
|
||||
* (org policy requires MFA and the user has none). There is intentionally NO
|
||||
* skip / dismiss control — the only way past this screen is to enroll an
|
||||
* authenticator. The QR is rendered locally from the `otpauth://` URI, so the
|
||||
* TOTP secret never leaves the browser.
|
||||
*
|
||||
* Flow: `getAccount` (resolve identity from the session IAM set with
|
||||
* `RequiredMfa`) → `mfaInitiate` (secret + QR) → user scans → `mfaVerify`
|
||||
* (prove the code) → `mfaEnable` (persist) → `onComplete`.
|
||||
*/
|
||||
export function MfaEnrollForm({ client, onComplete }: MfaEnrollFormProps) {
|
||||
const [identity, setIdentity] = useState<MfaIdentity | null>(null)
|
||||
const [setup, setSetup] = useState<MfaSetup | null>(null)
|
||||
const [fatal, setFatal] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function begin() {
|
||||
try {
|
||||
const id = await client.getAccount()
|
||||
if (!id) throw new Error('Your session could not be resolved. Please sign in again.')
|
||||
const s = await client.mfaInitiate(id)
|
||||
if (cancelled) return
|
||||
setIdentity(id)
|
||||
setSetup(s)
|
||||
} catch (e) {
|
||||
if (!cancelled) setFatal(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
void begin()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const qrSvg = useMemo(() => (setup ? encodeQR(setup.url, 'svg') : ''), [setup])
|
||||
|
||||
async function onCode(code: string) {
|
||||
if (!identity || !setup || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const verified = await client.mfaVerify({ owner: identity.owner, name: identity.name, secret: setup.secret, passcode: code })
|
||||
if (!verified.ok) {
|
||||
setError(verified.error ?? 'That code did not match. Try the current code from your app.')
|
||||
return
|
||||
}
|
||||
const enabled = await client.mfaEnable({
|
||||
owner: identity.owner,
|
||||
name: identity.name,
|
||||
secret: setup.secret,
|
||||
recoveryCode: setup.recoveryCodes[0] ?? '',
|
||||
})
|
||||
if (!enabled.ok) {
|
||||
setError(enabled.error ?? 'Could not enable two-factor authentication.')
|
||||
return
|
||||
}
|
||||
onComplete()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (fatal) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p role="alert" className="hanzo-id-error">{fatal}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setup) {
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Two-factor setup</h2>
|
||||
<p className="lede">Preparing your authenticator…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const recoveryCode = setup.recoveryCodes[0]
|
||||
return (
|
||||
<div className="hanzo-id-mfa-enroll">
|
||||
<h2>Set up two-factor authentication</h2>
|
||||
<p className="lede">
|
||||
Your organization requires two-factor authentication. Scan this QR code with an
|
||||
authenticator app (Google Authenticator, 1Password, Authy), then enter the 6-digit code it
|
||||
shows.
|
||||
</p>
|
||||
<div
|
||||
className="hanzo-id-mfa-qr"
|
||||
role="img"
|
||||
aria-label="TOTP enrollment QR code"
|
||||
// Local SVG from @paulmillr/qr — the otpauth secret never leaves the browser.
|
||||
dangerouslySetInnerHTML={{ __html: qrSvg }}
|
||||
/>
|
||||
<details className="hanzo-id-mfa-manual">
|
||||
<summary>Can't scan? Enter this key manually</summary>
|
||||
<code className="hanzo-id-mfa-secret">{setup.secret}</code>
|
||||
</details>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<OTPForm channel="totp" onSubmit={onCode} />
|
||||
{recoveryCode ? (
|
||||
<p className="hanzo-id-mfa-recovery">
|
||||
Save this recovery code somewhere safe — it lets you sign in if you lose your device:
|
||||
<br />
|
||||
<code>{recoveryCode}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { SmsConsentNotice } from './SmsConsent'
|
||||
|
||||
export interface OTPFormProps {
|
||||
readonly onSubmit: (code: string) => void | Promise<void>
|
||||
@@ -40,7 +39,6 @@ export function OTPForm(props: OTPFormProps) {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{channel === 'sms' ? <SmsConsentNotice /> : null}
|
||||
<button type="submit" disabled={busy || code.length !== length}>{busy ? 'Verifying…' : 'Verify'}</button>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Canonical A2P 10DLC consent copy. This EXACT disclosure is reused at every
|
||||
// point where Hanzo collects or uses a phone number for messaging. It MUST stay
|
||||
// verbatim-identical to the public opt-in page (hanzo.ai/sms-opt-in,
|
||||
// `SMS_CONSENT_TEXT`) and to the IAM phone-login UI — Twilio / carrier campaign
|
||||
// review compares the wording across surfaces. One string, reused everywhere.
|
||||
export const SMS_CONSENT_TEXT =
|
||||
'I agree to receive text messages (SMS) from Hanzo AI at the number provided, ' +
|
||||
'including one-time passcodes and two-factor authentication, account and security ' +
|
||||
'alerts, and transactional notifications. Message frequency varies. Message and data ' +
|
||||
'rates may apply. Reply STOP to opt out at any time, or HELP for help. Consent is not ' +
|
||||
'a condition of any purchase.'
|
||||
|
||||
const TERMS_URL = 'https://hanzo.ai/terms'
|
||||
const PRIVACY_URL = 'https://hanzo.ai/privacy'
|
||||
|
||||
/**
|
||||
* SMS consent disclosure shown beneath any phone/SMS surface (disclosure-only,
|
||||
* no checkbox — the portal's SMS step is reached only after the user already
|
||||
* provided/opted-in their number in IAM, and after a code was sent).
|
||||
*
|
||||
* For a phone-number COLLECTION surface that requires affirmative opt-in (A2P),
|
||||
* gate the submit on a checkbox and reuse {@link SMS_CONSENT_TEXT} — see the IAM
|
||||
* SignupPage `SmsConsentCheckbox`. The portal does not yet render its own phone
|
||||
* field (collection happens in the IAM-hosted UI), so only the notice is used
|
||||
* here today.
|
||||
*/
|
||||
export function SmsConsentNotice() {
|
||||
return (
|
||||
<div className="hanzo-id-sms-consent" role="note">
|
||||
<p>{SMS_CONSENT_TEXT}</p>
|
||||
<p className="hanzo-id-sms-consent-links">
|
||||
By continuing, you agree to our{' '}
|
||||
<a href={TERMS_URL} target="_blank" rel="noreferrer">Terms of Service</a> and{' '}
|
||||
<a href={PRIVACY_URL} target="_blank" rel="noreferrer">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,5 @@ export { LoginForm } from './LoginForm'
|
||||
export { SignupForm } from './SignupForm'
|
||||
export { ForgotForm } from './ForgotForm'
|
||||
export { OTPForm } from './OTPForm'
|
||||
export { MfaEnrollForm, type MfaEnrollFormProps } from './MfaEnrollForm'
|
||||
export { SmsConsentNotice, SMS_CONSENT_TEXT } from './SmsConsent'
|
||||
export { SocialButtons, type SocialButtonsProps } from './SocialButtons'
|
||||
export { Divider } from './Divider'
|
||||
|
||||
@@ -158,7 +158,7 @@ function hostSkeleton(host: string): TenantConfig {
|
||||
function fromCatalog(entry: CatalogEntry | undefined): Partial<TenantConfig> {
|
||||
if (!entry) return {}
|
||||
const out: Record<string, string> = {}
|
||||
for (const k of ['orgId', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
|
||||
for (const k of ['orgId', 'loginOrg', 'iamUrl', 'iamIssuer', 'clientId', 'appName', 'publicOrigin', 'oauthCallbackOrigin', 'brandPackage'] as const) {
|
||||
const v = entry[k]
|
||||
if (typeof v === 'string' && v.length > 0) out[k] = v
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
export interface TenantConfig {
|
||||
/** Tenant org slug (matches the JWT `owner` claim and the IAM `<org>-<app>` namespace). */
|
||||
readonly orgId: string
|
||||
/**
|
||||
* OPTIONAL org-resolution anchor for PASSWORD LOGIN only. Unset (the default)
|
||||
* = org-agnostic: the SPA posts NO `organization`, IAM resolves the user
|
||||
* cross-org by credentials, and the session encodes the user's REAL owner-org
|
||||
* (a global admin → the `admin` org / full multi-org session; a brand user →
|
||||
* their own org). Pinning `orgId` here would resolve a colliding brand-org row
|
||||
* and truncate a global admin to a single org — so the portal leaves this
|
||||
* unset. Set it ONLY for a brand that deliberately scopes its portal login to
|
||||
* one tenant. Does NOT affect signup (which always targets `orgId`) or the
|
||||
* apps launcher (which is brand-scoped by `orgId`).
|
||||
*/
|
||||
readonly loginOrg?: string
|
||||
/** IAM (OIDC) backend origin, no trailing slash. */
|
||||
readonly iamUrl: string
|
||||
/** Pinned OIDC issuer claim. Defaults to iamUrl. */
|
||||
|
||||
Generated
+116
-122
@@ -4,6 +4,10 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
esbuild: ^0.28.1
|
||||
uuid: ^11.1.1
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -78,9 +82,6 @@ importers:
|
||||
'@hanzo/id-shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
'@paulmillr/qr':
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^19.0.0
|
||||
@@ -508,158 +509,158 @@ packages:
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.7':
|
||||
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.7':
|
||||
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.7':
|
||||
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.7':
|
||||
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.7':
|
||||
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.7':
|
||||
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.7':
|
||||
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.7':
|
||||
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.7':
|
||||
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.7':
|
||||
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.7':
|
||||
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.7':
|
||||
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.7':
|
||||
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.7':
|
||||
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.7':
|
||||
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.7':
|
||||
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.7':
|
||||
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.7':
|
||||
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1463,10 +1464,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=18.0.0'
|
||||
|
||||
'@paulmillr/qr@0.3.0':
|
||||
resolution: {integrity: sha512-3s/cagXuoXTA2gWSfSfJNanNgm2ifmqgoX8WLOs5//3qrIJ3WWHFjqFqCxvYGf46Afwv6PctT9eAOXLDGwp96Q==}
|
||||
deprecated: 'Switch to "qr" (new package name) for security updates: npm install qr'
|
||||
|
||||
'@react-native/assets-registry@0.86.0':
|
||||
resolution: {integrity: sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew==}
|
||||
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
|
||||
@@ -2095,8 +2092,8 @@ packages:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.27.7:
|
||||
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3110,9 +3107,8 @@ packages:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
uuid@7.0.3:
|
||||
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
uuid@11.1.1:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
validate-npm-package-name@5.0.1:
|
||||
@@ -3716,82 +3712,82 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.7':
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.7':
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.7':
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.7':
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.7':
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.7':
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.7':
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.7':
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.7':
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.7':
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.7':
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.7':
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.7':
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.7':
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.7':
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.7':
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.7':
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.7':
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.7':
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.7':
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.7':
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.7':
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.7':
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.7':
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.7':
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.7':
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(expo-constants@56.0.18(expo@56.0.12)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7)))(expo-font@56.0.7(expo@56.0.12)(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(expo@56.0.12)(react-dom@19.2.7(react@19.2.7))(react-native@0.86.0(@babel/core@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)(typescript@5.9.3)':
|
||||
@@ -6166,8 +6162,6 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
'@paulmillr/qr@0.3.0': {}
|
||||
|
||||
'@react-native/assets-registry@0.86.0': {}
|
||||
|
||||
'@react-native/babel-plugin-codegen@0.85.3(@babel/core@7.29.7)':
|
||||
@@ -6810,34 +6804,34 @@ snapshots:
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
esbuild@0.27.7:
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.7
|
||||
'@esbuild/android-arm': 0.27.7
|
||||
'@esbuild/android-arm64': 0.27.7
|
||||
'@esbuild/android-x64': 0.27.7
|
||||
'@esbuild/darwin-arm64': 0.27.7
|
||||
'@esbuild/darwin-x64': 0.27.7
|
||||
'@esbuild/freebsd-arm64': 0.27.7
|
||||
'@esbuild/freebsd-x64': 0.27.7
|
||||
'@esbuild/linux-arm': 0.27.7
|
||||
'@esbuild/linux-arm64': 0.27.7
|
||||
'@esbuild/linux-ia32': 0.27.7
|
||||
'@esbuild/linux-loong64': 0.27.7
|
||||
'@esbuild/linux-mips64el': 0.27.7
|
||||
'@esbuild/linux-ppc64': 0.27.7
|
||||
'@esbuild/linux-riscv64': 0.27.7
|
||||
'@esbuild/linux-s390x': 0.27.7
|
||||
'@esbuild/linux-x64': 0.27.7
|
||||
'@esbuild/netbsd-arm64': 0.27.7
|
||||
'@esbuild/netbsd-x64': 0.27.7
|
||||
'@esbuild/openbsd-arm64': 0.27.7
|
||||
'@esbuild/openbsd-x64': 0.27.7
|
||||
'@esbuild/openharmony-arm64': 0.27.7
|
||||
'@esbuild/sunos-x64': 0.27.7
|
||||
'@esbuild/win32-arm64': 0.27.7
|
||||
'@esbuild/win32-ia32': 0.27.7
|
||||
'@esbuild/win32-x64': 0.27.7
|
||||
'@esbuild/aix-ppc64': 0.28.1
|
||||
'@esbuild/android-arm': 0.28.1
|
||||
'@esbuild/android-arm64': 0.28.1
|
||||
'@esbuild/android-x64': 0.28.1
|
||||
'@esbuild/darwin-arm64': 0.28.1
|
||||
'@esbuild/darwin-x64': 0.28.1
|
||||
'@esbuild/freebsd-arm64': 0.28.1
|
||||
'@esbuild/freebsd-x64': 0.28.1
|
||||
'@esbuild/linux-arm': 0.28.1
|
||||
'@esbuild/linux-arm64': 0.28.1
|
||||
'@esbuild/linux-ia32': 0.28.1
|
||||
'@esbuild/linux-loong64': 0.28.1
|
||||
'@esbuild/linux-mips64el': 0.28.1
|
||||
'@esbuild/linux-ppc64': 0.28.1
|
||||
'@esbuild/linux-riscv64': 0.28.1
|
||||
'@esbuild/linux-s390x': 0.28.1
|
||||
'@esbuild/linux-x64': 0.28.1
|
||||
'@esbuild/netbsd-arm64': 0.28.1
|
||||
'@esbuild/netbsd-x64': 0.28.1
|
||||
'@esbuild/openbsd-arm64': 0.28.1
|
||||
'@esbuild/openbsd-x64': 0.28.1
|
||||
'@esbuild/openharmony-arm64': 0.28.1
|
||||
'@esbuild/sunos-x64': 0.28.1
|
||||
'@esbuild/win32-arm64': 0.28.1
|
||||
'@esbuild/win32-ia32': 0.28.1
|
||||
'@esbuild/win32-x64': 0.28.1
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
@@ -7905,7 +7899,7 @@ snapshots:
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@7.0.3: {}
|
||||
uuid@11.1.1: {}
|
||||
|
||||
validate-npm-package-name@5.0.1: {}
|
||||
|
||||
@@ -7913,7 +7907,7 @@ snapshots:
|
||||
|
||||
vite@7.3.5(@types/node@25.9.3)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.7
|
||||
esbuild: 0.28.1
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.15
|
||||
@@ -7957,7 +7951,7 @@ snapshots:
|
||||
xcode@3.0.1:
|
||||
dependencies:
|
||||
simple-plist: 1.3.1
|
||||
uuid: 7.0.3
|
||||
uuid: 11.1.1
|
||||
|
||||
xml2js@0.6.0:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user