Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4180ac6481 | ||
|
|
99db5965cf | ||
|
|
97e799caba | ||
|
|
3df982b9fc | ||
|
|
18d0ae0eb0 | ||
|
|
a8b4d9a007 | ||
|
|
187b74cbad | ||
|
|
debd1e71bb | ||
|
|
31a720dc77 | ||
|
|
ee2eee959c | ||
|
|
965f7cfd91 | ||
|
|
ce87a7f57f | ||
|
|
9d7d64797b | ||
|
|
6d8753c9ab | ||
|
|
c7b794c857 | ||
|
|
2913f5b2db | ||
|
|
05ddadc91a | ||
|
|
8b6a6471d2 | ||
|
|
33e2cb1a0c | ||
|
|
7c2607b899 | ||
|
|
fd145f190a | ||
|
|
851282b797 | ||
|
|
5dca171915 | ||
|
|
645828077d | ||
|
|
afc6ae06fa | ||
|
|
c69d05921e | ||
|
|
7b6ef89048 | ||
|
|
dd3045757c | ||
|
|
f2211f7e27 | ||
|
|
a4c5610acc | ||
|
|
95e71621de | ||
|
|
b04faad9d8 | ||
|
|
bb972779f6 | ||
|
|
b2253b6b03 | ||
|
|
7c15bd3640 | ||
|
|
48bb754ab5 | ||
|
|
94aff5915a | ||
|
|
f5fe4086b1 | ||
|
|
563d5482b7 | ||
|
|
afd9982e00 | ||
|
|
7808400d58 | ||
|
|
007a8fc262 | ||
|
|
4170fd1f3e | ||
|
|
cf0a70f5fa | ||
|
|
73a7d5be9a | ||
|
|
a5d696b772 | ||
|
|
7b73828908 | ||
|
|
1fccc6234c | ||
|
|
0046cb6417 | ||
|
|
12d271025a | ||
|
|
ed2b42617c | ||
|
|
664e86624b | ||
|
|
737171bc3b | ||
|
|
8241a409b8 | ||
|
|
0e01abba27 | ||
|
|
edcc3dc71e | ||
|
|
3ba61da43b | ||
|
|
b2ba1669cb | ||
|
|
1bbb6e8b5e | ||
|
|
57dec0a33e | ||
|
|
1ac00b4b2b | ||
|
|
85ce3e93c5 | ||
|
|
652c8150b6 | ||
|
|
514bf2704d | ||
|
|
95c9dbe899 | ||
|
|
894dd58091 | ||
|
|
b0779c7db9 | ||
|
|
8369a4a68e | ||
|
|
983c2b7a3e |
@@ -1,4 +1,8 @@
|
||||
name: Docker
|
||||
# Self-contained build on Hanzo self-hosted runners. The shared reusable
|
||||
# workflow (hanzoai/.github docker-build.yml@main) is currently failing graph
|
||||
# validation for every caller (org-wide startup_failure), so this repo builds
|
||||
# its own image directly. Cluster nodes are linux/amd64 → build that arch.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -9,11 +13,41 @@ permissions:
|
||||
packages: write
|
||||
jobs:
|
||||
docker:
|
||||
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
|
||||
with:
|
||||
image: ghcr.io/hanzoai/id
|
||||
pre-build-command: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
secrets: inherit
|
||||
# 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
|
||||
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)"
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "ver=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Tags: $SHA, $VER"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push (linux/amd64)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.sha }}
|
||||
ghcr.io/hanzoai/id:${{ steps.tags.outputs.ver }}
|
||||
cache-from: type=gha,scope=hanzoai-id
|
||||
cache-to: type=gha,scope=hanzoai-id,mode=max
|
||||
|
||||
@@ -10,6 +10,7 @@ COPY apps/web/package.json apps/web/
|
||||
COPY pkgs/shared/package.json pkgs/shared/
|
||||
COPY pkgs/auth/package.json pkgs/auth/
|
||||
COPY pkgs/idv/package.json pkgs/idv/
|
||||
COPY pkgs/onboarding/package.json pkgs/onboarding/
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
|
||||
COPY apps apps
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
# Hanzo ID
|
||||
# LLM.md — Hanzo ID
|
||||
|
||||
## PKCE on password login (fixed 0.1.13)
|
||||
|
||||
`client.login()` (POST `/v1/iam/login`) must forward `code_challenge`
|
||||
/`code_challenge_method` on the QUERY string, exactly like `authorize()`.
|
||||
IAM's Login handler (`hanzoai/iam` `controllers/account.go`) reads
|
||||
`code_challenge` from the query first, body fallback, then threads it into
|
||||
`GetOAuthCode` so the minted code stores the challenge. Omitting it makes
|
||||
IAM store an EMPTY challenge; the downstream public SPA client (e.g.
|
||||
`hanzo-platform`) then fails token exchange with
|
||||
`token.CodeChallenge: empty` → `invalid_client` (it falls back to a
|
||||
client_secret check the public client can't satisfy — see
|
||||
`object/token_oauth.go:809-844`: with a non-empty stored challenge AND no
|
||||
client_secret sent, the secret check is bypassed). Social login was never
|
||||
affected (it rides `authorize()`). Plumb the URL's `code_challenge`
|
||||
through Login page → LoginForm → `client.login()`; do NOT default a
|
||||
challenge — only forward what the downstream OAuth request put on the URL.
|
||||
|
||||
Known SEPARATE blocker (NOT this repo): after a 200 token exchange,
|
||||
platform's `/v1/iam/session` (`hanzo/platform` `pkg/platform/src/lib/iam.ts`
|
||||
→ `@hanzo/iam/server` `getServerSession`) returns 401 "Invalid IAM token"
|
||||
for a valid `aud:[hanzo-platform]` RS256 JWT signed by `cert-hanzo` (key IS
|
||||
in hanzo.id JWKS; the duplicate `cert-hanzo` entry is identical, harmless).
|
||||
That is a platform/SDK-side verification bug, tracked separately.
|
||||
|
||||
## What this is
|
||||
|
||||
@@ -27,12 +51,57 @@ pars.id ──┘ │
|
||||
createAuthClient(tenant)
|
||||
│
|
||||
▼
|
||||
https://iam.hanzo.ai (Casdoor fork, Go)
|
||||
per-brand OIDC issuer host: hanzo.id / lux.id /
|
||||
zoo.id / pars.id (serves /.well-known + /v1/iam/*;
|
||||
same Casdoor-fork backend, tenant-scoped by org)
|
||||
│
|
||||
▼
|
||||
iam-* postgres in hanzo namespace
|
||||
```
|
||||
|
||||
`iamUrl` is the brand's OWN `*.id` host, NOT `iam.hanzo.ai` (HIP-0111:
|
||||
discovery must be host-relative or the SDK resolves to the IAM SPA HTML
|
||||
catch-all). One backend serves every brand behind its issuer host.
|
||||
|
||||
## Auth methods — the full set
|
||||
|
||||
Email/password + email/SMS code + GitHub + Google + Web3 (wallet). The enabled
|
||||
set is read LIVE from `/v1/iam/get-app-login` (`AuthClient.getAppLogin`), which
|
||||
mirrors each `-id` app's provider config in
|
||||
`universe/infra/k8s/iam/init_data.json`. Password sign-in goes through the IAM
|
||||
REST `login` and returns an auth code directly. Both honor a downstream
|
||||
`redirect_uri`.
|
||||
|
||||
### Social providers — render only when configured; redirect via the "hop"
|
||||
|
||||
`SocialButtons` renders ONLY providers IAM holds a REAL credential for
|
||||
(`AppProvider.configured` = non-placeholder clientId). With the seed's
|
||||
placeholders every social button is hidden, so a user never hits a dead-end;
|
||||
they reappear automatically once real creds land. Clicking a configured OAuth
|
||||
provider runs the **hop** (`social.ts::startProviderLogin`), which redirects
|
||||
straight to the provider with a base64 `state` that round-trips the original
|
||||
authorize request — matching the IAM (Casdoor) `getAuthUrl` contract. The
|
||||
provider returns to `/callback`; `Callback.tsx` detects the provider state and
|
||||
calls `client.providerLogin` to exchange the code at the IAM backend, then
|
||||
follows the continue-URL (which re-enters `/callback` as the normal OIDC code).
|
||||
(NOT `@hanzo/iam` `signinRedirect` — that loops back to the login page.)
|
||||
|
||||
**To ENABLE real social login (the only remaining work):**
|
||||
1. Register an OAuth app per provider (GitHub/Google) with callback
|
||||
**`https://<brand>/v1/iam/callback`** AND the app authorize redirect
|
||||
`https://<brand>/callback` (per brand host: hanzo.id, lux.id, pars.id …).
|
||||
2. Put the client id/secret in KMS at **project `hanzo-iam`, env `prod`**, keys
|
||||
`IAM_GITHUB_CLIENT_ID` / `IAM_GITHUB_CLIENT_SECRET` (and `IAM_GOOGLE_*`). The
|
||||
`iam-kms-sync` KMSSecret (`universe/infra/k8s/iam/secret.yaml`) syncs that
|
||||
path into `iam-secrets`; init_data.json substitutes `${IAM_GITHUB_CLIENT_ID}`
|
||||
at deploy. The whole sync + env-ref chain already exists — today those keys
|
||||
just hold placeholder values, so providers read as unconfigured (buttons
|
||||
hidden). Replace the values; nothing else to wire.
|
||||
3. The buttons appear automatically (no portal change). **Live-verify** the
|
||||
round-trip reaches the provider and completes — the hop + exchange are wired
|
||||
and unit-tested (`pkgs/auth/src/social.test.ts`) but can only be exercised
|
||||
end-to-end once real creds exist.
|
||||
|
||||
## Workspace
|
||||
|
||||
```
|
||||
@@ -40,8 +109,14 @@ apps/
|
||||
web/ @hanzo/id-web — Vite + React 19 + @hanzo/gui SPA
|
||||
pkgs/
|
||||
shared/ @hanzo/id-shared — TenantConfig, resolveTenant, loadBrand
|
||||
auth/ @hanzo/id-auth — composable login/signup/OTP forms +
|
||||
AuthClient (wraps @hanzo/iam REST)
|
||||
auth/ @hanzo/id-auth — composable login/signup/OTP/forgot forms +
|
||||
SocialButtons (GitHub/Google/Web3) +
|
||||
AuthClient (wraps @hanzo/iam REST + SDK PKCE)
|
||||
onboarding/ @hanzo/id-onboarding — post-login org → project → wallet flow.
|
||||
domain (serializable step machine) / service
|
||||
(IAM-backed writes) / ui (self-contained flow).
|
||||
Tests: `pnpm --filter @hanzo/id-onboarding test`
|
||||
(Node built-in runner, no test-framework dep).
|
||||
idv/ @hanzo/id-idv — pluggable identity verification
|
||||
(Persona, Onfido, Veriff, stub)
|
||||
legacy-nextjs/ Frozen predecessor. Delete after v0.1.0 ships.
|
||||
@@ -63,29 +138,6 @@ legacy-nextjs/ Frozen predecessor. Delete after v0.1.0 ships.
|
||||
5. **Mirrors downstream tenant id-app forks.** Same `apps/` + `pkgs/`
|
||||
pattern, same `@hanzo/gui` shell, same per-tenant brand resolution.
|
||||
|
||||
## UI surfaces
|
||||
|
||||
- **Login / Signup** (`apps/web/src/pages/Login.tsx`, `Signup.tsx`): split-view
|
||||
— auth card (left) + per-org marketing/branding panel (right, `.hanzo-id-split-brand`,
|
||||
hidden < 1024px). Restored from `legacy-nextjs` design. The auth itself is the
|
||||
untouched `<LoginForm>`/`<SignupForm>` from `@hanzo/id-auth`; the page only wraps
|
||||
it in layout. **Do not** move auth logic into the page.
|
||||
- **Portal** (`apps/web/src/pages/Portal.tsx`): after a bare sign-in the user lands
|
||||
on `/?signed_in=1` and sees the **apps launcher** ("<Brand> apps", grid of
|
||||
`appsFor(orgId)` cards) + account/billing nav. Logged-out visitors get the brand hero.
|
||||
Auth state = `/v1/iam/get-account` (cookie session) OR the `?signed_in=1` marker
|
||||
(the cross-proxy `get-account` does not echo the bare-login session, so the marker
|
||||
is the authoritative "just authenticated" signal).
|
||||
- **Marketing copy + app links + billing URLs** are per-org in
|
||||
`apps/web/src/marketing.ts`, keyed by `tenant.orgId` (hanzo/lux/zoo/pars), ported
|
||||
from the legacy `staticBranding.content` + `orgApps`. The brand-neutral
|
||||
`BrandContract` stays visual-only; this file holds the org-specific copy.
|
||||
- **Logo**: `apps/web/src/components/BrandLogo.tsx` renders `brand.logoUrl` (CDN) and
|
||||
falls back to the in-image `/brand/<pkg>/assets/logo/logo.svg` on error (the
|
||||
jsdelivr CDN path 404s for some brands — the self-hosted asset always works).
|
||||
- Styling is hand-written CSS in `apps/web/src/app.css` (`.hanzo-id-*`). **No Tailwind**
|
||||
in this SPA — match existing class conventions.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
@@ -150,11 +202,21 @@ Custom providers: implement the `IDVProvider` interface in
|
||||
## Backend
|
||||
|
||||
The Go IAM backend lives at `~/work/hanzo/iam` (Casdoor fork, module
|
||||
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). This portal talks
|
||||
to it via the routes in `pkgs/auth/src/client.ts`:
|
||||
`github.com/hanzoai/iam`, image `ghcr.io/hanzoai/iam`). All paths are under
|
||||
the `/v1/iam` prefix — no legacy `/oauth/*`, no `/api/`. This portal talks
|
||||
to it via:
|
||||
|
||||
- `/v1/iam/login` `/v1/iam/signup` `/v1/iam/send-verification-code`
|
||||
- `/oauth/authorize` `/oauth/token` `/oauth/logout`
|
||||
- auth (`pkgs/auth/src/client.ts`): `/v1/iam/login` `/v1/iam/signup`
|
||||
`/v1/iam/send-verification-code` `/v1/iam/get-app-login`, and the OIDC
|
||||
PKCE endpoints `/v1/iam/oauth/{authorize,token,userinfo,logout}` (via the
|
||||
`@hanzo/iam` SDK).
|
||||
- onboarding (`pkgs/onboarding/src/service/onboarding.ts`):
|
||||
`/v1/iam/get-organizations` (allowed for any signed-in user, scoped to
|
||||
their memberships server-side), `/v1/iam/add-organization` +
|
||||
`/v1/iam/add-project` (admin-gated in IAM authz — the create path surfaces
|
||||
a permission message for non-admins and stays skippable),
|
||||
`/v1/iam/get-account` + `/v1/iam/update-user?columns=web3onboard` (wallet
|
||||
link).
|
||||
|
||||
All hostnames talk to the same IAM backend — the org is carried in the
|
||||
request body (`organization: <orgId>`), and the IAM backend tenant-scopes
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@hanzo/id-web",
|
||||
"private": true,
|
||||
"version": "0.1.10",
|
||||
"description": "Hanzo ID \u2014 white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
|
||||
"version": "0.1.22",
|
||||
"description": "Hanzo ID — white-label login / signup / IDV portal. Vite + React 19 + @hanzo/gui. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -13,9 +13,10 @@
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.3.0",
|
||||
"@hanzo/gui": "^7.2.4",
|
||||
"@hanzo/iam": "^0.9.4",
|
||||
"@hanzo/iam": "^0.11.0",
|
||||
"@hanzo/id-auth": "workspace:*",
|
||||
"@hanzo/id-idv": "workspace:*",
|
||||
"@hanzo/id-onboarding": "workspace:*",
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@luxfi/brand": "^1.0.0",
|
||||
"@parsdao/brand": "^1.0.0",
|
||||
|
||||
+42
-14
@@ -6,6 +6,7 @@ import { Login } from './pages/Login'
|
||||
import { Signup } from './pages/Signup'
|
||||
import { Forgot } from './pages/Forgot'
|
||||
import { Callback } from './pages/Callback'
|
||||
import { Onboarding } from './pages/Onboarding'
|
||||
|
||||
/**
|
||||
* Top-level wiring. Resolves tenant + brand once on mount, then routes via
|
||||
@@ -18,17 +19,45 @@ export function App() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const runtimeCatalog = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
|
||||
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(runtimeCatalog) })
|
||||
setTenant(t)
|
||||
loadBrand(t.brandPackage, t.brandUrl)
|
||||
.then((b) => {
|
||||
let cancelled = false
|
||||
async function boot() {
|
||||
// The runtime serves the per-host tenant catalog at /config.json
|
||||
// (templated from SPA_IAM_TENANT_CONFIG_JSON by the static server). Read
|
||||
// it from there — NOT a `window.__ID_CATALOG__` global, which the runtime
|
||||
// never injects (relying on it silently dropped every catalog-only host,
|
||||
// e.g. osage.id, to the bundled Hanzo default). Fall back to the inlined
|
||||
// global, then empty, so a host always resolves to something.
|
||||
let catalogRaw: string | undefined
|
||||
try {
|
||||
const res = await fetch('/config.json', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
const cfg = (await res.json()) as { iamTenantConfigJson?: string }
|
||||
catalogRaw = cfg.iamTenantConfigJson
|
||||
}
|
||||
} catch {
|
||||
// network/parse error → fall back below
|
||||
}
|
||||
if (!catalogRaw) {
|
||||
catalogRaw = (window as unknown as { __ID_CATALOG__?: string }).__ID_CATALOG__
|
||||
}
|
||||
const t = resolveTenant(window.location.hostname, { catalog: parseCatalog(catalogRaw) })
|
||||
if (cancelled) return
|
||||
setTenant(t)
|
||||
try {
|
||||
const b = await loadBrand(t.brandPackage)
|
||||
if (cancelled) return
|
||||
setBrand(b)
|
||||
document.title = `Sign in — ${b.name}`
|
||||
const fav = document.getElementById('favicon') as HTMLLinkElement | null
|
||||
if (fav && b.faviconUrl) fav.href = b.faviconUrl
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(String(e))
|
||||
}
|
||||
}
|
||||
void boot()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const client = useMemo(() => (tenant ? createAuthClient({ tenant }) : null), [tenant])
|
||||
@@ -36,12 +65,11 @@ export function App() {
|
||||
if (error) return <div className="hanzo-id-error">{error}</div>
|
||||
if (!tenant || !brand || !client) return <div>Loading…</div>
|
||||
|
||||
// undefined = enabled; only an explicit `false` disables self-service signup.
|
||||
const signupEnabled = tenant.signupEnabled !== false
|
||||
const path = window.location.pathname
|
||||
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} tenant={tenant} signupEnabled={signupEnabled} />
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return signupEnabled ? <Signup client={client} brand={brand} tenant={tenant} /> : <Login client={client} brand={brand} tenant={tenant} signupEnabled={signupEnabled} />
|
||||
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} tenant={tenant} />
|
||||
if (path === '/callback' || path.startsWith('/callback/')) return <Callback client={client} brand={brand} tenant={tenant} />
|
||||
return <Portal client={client} brand={brand} tenant={tenant} signupEnabled={signupEnabled} />
|
||||
if (path === '/login' || path.startsWith('/login/')) return <Login client={client} brand={brand} />
|
||||
if (path === '/signup' || path.startsWith('/signup/')) return <Signup client={client} brand={brand} />
|
||||
if (path === '/forget' || path === '/forgot' || path.startsWith('/forg')) return <Forgot client={client} brand={brand} />
|
||||
if (path === '/callback' || path.startsWith('/callback/')) return <Callback tenant={tenant} brand={brand} />
|
||||
if (path === '/onboarding' || path.startsWith('/onboarding/')) return <Onboarding tenant={tenant} brand={brand} />
|
||||
return <Portal client={client} brand={brand} tenant={tenant} />
|
||||
}
|
||||
|
||||
+105
-209
@@ -31,6 +31,14 @@ body {
|
||||
padding: 16px 0 32px;
|
||||
}
|
||||
|
||||
/* Text fallback when a brand ships no logo asset (see BrandHeader). */
|
||||
.hanzo-id-wordmark {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.hanzo-id-page main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -73,6 +81,12 @@ 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;
|
||||
@@ -89,20 +103,39 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* --- Social providers + passwordless (email/SMS) login + signup --- */
|
||||
.hanzo-id-login,
|
||||
.hanzo-id-signup,
|
||||
.hanzo-id-code-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
/* ── 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-providers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
.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-provider-btn {
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -111,220 +144,83 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
padding: 11px 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
.hanzo-id-provider-btn:hover {
|
||||
border-color: var(--muted);
|
||||
background: #161616;
|
||||
}
|
||||
.hanzo-id-provider-web3 {
|
||||
border-color: #3a3357;
|
||||
}
|
||||
.hanzo-id-or {
|
||||
.hanzo-id-social-btn:hover { border-color: #3a3a3a; background: #161616; }
|
||||
.hanzo-id-social-btn svg { flex: none; }
|
||||
|
||||
/* Labeled divider between social row and email form. */
|
||||
.hanzo-id-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 13px;
|
||||
}
|
||||
.hanzo-id-or::before,
|
||||
.hanzo-id-or::after {
|
||||
.hanzo-id-divider::before,
|
||||
.hanzo-id-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.hanzo-id-divider span { padding: 0 12px; }
|
||||
|
||||
/* ── Onboarding flow ───────────────────────────────────────────── */
|
||||
.hanzo-id-onboarding { display: flex; flex-direction: column; gap: 24px; }
|
||||
.hanzo-id-onboarding-head { display: flex; flex-direction: column; gap: 6px; }
|
||||
.hanzo-id-onboarding-head h1 { margin: 0; font-size: 26px; }
|
||||
.hanzo-id-onboarding-body { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done { display: flex; flex-direction: column; gap: 16px; }
|
||||
.hanzo-id-onboarding-done h1 { margin: 0; font-size: 26px; }
|
||||
|
||||
.hanzo-id-stepdots { display: flex; gap: 8px; }
|
||||
.hanzo-id-stepdots span {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
.hanzo-id-stepdots span.on { background: var(--brand); }
|
||||
|
||||
.hanzo-id-org-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.hanzo-id-org-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #111;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.hanzo-id-org-row:hover { border-color: #3a3a3a; background: #161616; }
|
||||
.hanzo-id-org-slug { color: var(--muted); font-size: 13px; font-family: ui-monospace, monospace; }
|
||||
|
||||
.hanzo-id-linkbtn {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
width: auto;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.hanzo-id-linkbtn:hover {
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hanzo-id-toggle-mode {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.hanzo-id-notice {
|
||||
background: #0a1f2d;
|
||||
color: #78b8ff;
|
||||
border: 1px solid #14385a;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hanzo-id-slug-preview { color: var(--muted); font-size: 13px; margin: -8px 0 0; font-family: ui-monospace, monospace; }
|
||||
|
||||
/* ============================================================
|
||||
Split-view login (form left, marketing panel right)
|
||||
Ported from legacy-nextjs/app/login + components/MarketingPanel
|
||||
============================================================ */
|
||||
.hanzo-id-split {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.hanzo-id-split-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.hanzo-id-split-form { width: 50%; }
|
||||
}
|
||||
.hanzo-id-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.hanzo-id-card-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.hanzo-id-card-head img { display: block; }
|
||||
.hanzo-id-card-title { margin: 0; font-size: 28px; font-weight: 700; }
|
||||
.hanzo-id-onboarding-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.hanzo-id-onboarding-actions .hanzo-id-btn { flex: 1; min-width: 120px; }
|
||||
.hanzo-id-btn.ghost { background: transparent; color: var(--fg); border: 1px solid var(--border); }
|
||||
.hanzo-id-btn.ghost:hover { border-color: #3a3a3a; }
|
||||
|
||||
/* Right panel — hidden below lg, gradient backdrop above */
|
||||
.hanzo-id-split-brand {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.hanzo-id-split-brand {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
background: linear-gradient(135deg, #000 0%, #18181b 50%, #000 100%);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.hanzo-id-marketing {
|
||||
max-width: 28rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
.hanzo-id-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
padding: 6px 14px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid #3f3f46;
|
||||
font-size: 14px;
|
||||
color: #d4d4d8;
|
||||
}
|
||||
.hanzo-id-pill-star { color: #facc15; }
|
||||
.hanzo-id-marketing-title { margin: 0; font-size: 36px; line-height: 1.1; font-weight: 700; color: #fff; }
|
||||
.hanzo-id-marketing-subtitle { margin: 0; font-size: 18px; color: #a1a1aa; }
|
||||
|
||||
.hanzo-id-quote-card {
|
||||
background: rgba(24, 24, 27, 0.5);
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
.hanzo-id-quote-card blockquote { margin: 0 0 16px; color: #fff; font-size: 16px; line-height: 1.6; }
|
||||
.hanzo-id-quote-mark { color: #52525b; font-size: 22px; }
|
||||
.hanzo-id-quote-author { display: flex; align-items: center; gap: 12px; }
|
||||
.hanzo-id-quote-avatar {
|
||||
width: 40px; height: 40px; border-radius: 9999px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #09090b; font-weight: 600; font-size: 14px; flex: 0 0 auto;
|
||||
}
|
||||
.hanzo-id-quote-name { font-weight: 600; color: #fff; }
|
||||
.hanzo-id-quote-role { font-size: 14px; color: #71717a; }
|
||||
.hanzo-id-quote-dots { display: flex; justify-content: center; gap: 8px; margin-top: 16px; }
|
||||
.hanzo-id-quote-dot {
|
||||
width: 8px; height: 8px; border-radius: 9999px; border: 0;
|
||||
background: #52525b; cursor: pointer; padding: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Authenticated portal — apps launcher ("all the apps")
|
||||
Ported from legacy-nextjs/app/account/page.tsx
|
||||
============================================================ */
|
||||
.hanzo-id-loading {
|
||||
flex: 1; min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #000;
|
||||
}
|
||||
.hanzo-id-spinner {
|
||||
width: 32px; height: 32px; border-radius: 9999px;
|
||||
border: 2px solid #3f3f46; border-top-color: #fff;
|
||||
animation: hanzo-id-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes hanzo-id-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.hanzo-id-portal-authed { flex: 1; min-height: 100vh; }
|
||||
.hanzo-id-nav {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 24px; border-bottom: 1px solid rgba(39, 39, 42, 0.5);
|
||||
}
|
||||
.hanzo-id-nav img { display: block; }
|
||||
.hanzo-id-nav-links { display: flex; align-items: center; gap: 20px; }
|
||||
.hanzo-id-nav-links a { color: #a1a1aa; font-size: 14px; text-decoration: none; }
|
||||
.hanzo-id-nav-links a:hover { color: #fff; }
|
||||
|
||||
.hanzo-id-portal-body { max-width: 56rem; margin: 0 auto; padding: 48px 24px; }
|
||||
.hanzo-id-profile { display: flex; align-items: center; gap: 24px; margin-bottom: 48px; }
|
||||
.hanzo-id-profile-avatar { width: 80px; height: 80px; border-radius: 9999px; object-fit: cover; }
|
||||
.hanzo-id-profile-avatar-fallback {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.08); font-size: 30px; font-weight: 700;
|
||||
}
|
||||
.hanzo-id-profile h1 { margin: 0; font-size: 30px; font-weight: 700; color: #fff; }
|
||||
.hanzo-id-profile .lede { margin: 4px 0 0; }
|
||||
|
||||
.hanzo-id-section-title { margin: 0 0 16px; font-size: 18px; font-weight: 600; color: #fff; }
|
||||
.hanzo-id-apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
@media (min-width: 640px) { .hanzo-id-apps-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (min-width: 1024px) { .hanzo-id-apps-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
.hanzo-id-app-card {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #27272a;
|
||||
background: rgba(24, 24, 27, 0.3);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.hanzo-id-app-card:hover { background: rgba(24, 24, 27, 0.6); border-color: #3f3f46; }
|
||||
.hanzo-id-app-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.hanzo-id-app-name { font-weight: 600; color: #fff; }
|
||||
.hanzo-id-app-arrow { color: #52525b; font-size: 16px; }
|
||||
.hanzo-id-app-card:hover .hanzo-id-app-arrow { color: #a1a1aa; }
|
||||
.hanzo-id-app-desc { margin: 0; font-size: 14px; color: #71717a; }
|
||||
|
||||
.hanzo-id-portal-footer {
|
||||
margin-top: 48px; padding-top: 32px; border-top: 1px solid #27272a;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.hanzo-id-muted-link { color: #71717a; font-size: 14px; text-decoration: none; }
|
||||
.hanzo-id-muted-link:hover { color: #fff; }
|
||||
.hanzo-id-btn.ghost {
|
||||
background: transparent; color: #a1a1aa;
|
||||
border: 1px solid #3f3f46; font-size: 14px; padding: 8px 16px;
|
||||
}
|
||||
.hanzo-id-btn.ghost:hover { color: #fff; border-color: #71717a; }
|
||||
.hanzo-id-summary { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; }
|
||||
.hanzo-id-summary dt { color: var(--muted); font-size: 14px; }
|
||||
.hanzo-id-summary dd { margin: 0; font-size: 14px; font-family: ui-monospace, monospace; }
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { BrandLogo } from './BrandLogo'
|
||||
import { useState } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
|
||||
export function BrandHeader({ brand, tenant }: { brand: BrandContract; tenant: TenantConfig }) {
|
||||
/**
|
||||
* Brand lockup for the auth pages. Renders the brand logo when it loads, and
|
||||
* falls back to the brand NAME as a text wordmark when the logo is absent or
|
||||
* fails to load — so a 404 logo (e.g. a brand package that doesn't ship its
|
||||
* assets) never shows a broken-image icon. The name always exists on the
|
||||
* brand contract, so the header is always presentable.
|
||||
*/
|
||||
export function BrandHeader({ brand }: { brand: BrandContract }) {
|
||||
const [imgOk, setImgOk] = useState(true)
|
||||
const showImg = Boolean(brand.logoUrl) && imgOk
|
||||
return (
|
||||
<header className="hanzo-id-brand-header">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<BrandLogo brand={brand} tenant={tenant} height={32} />
|
||||
{showImg ? (
|
||||
<img src={brand.logoUrl} alt={brand.name} height={32} onError={() => setImgOk(false)} />
|
||||
) : (
|
||||
<span className="hanzo-id-wordmark">{brand.name}</span>
|
||||
)}
|
||||
</a>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
|
||||
/**
|
||||
* Brand logo with a self-hosted fallback.
|
||||
*
|
||||
* `brand.logoUrl` is a CDN URL (jsdelivr). When that 404s or is blocked, we
|
||||
* fall back to the brand package's logo shipped inside this image at
|
||||
* `/brand/<pkg>/assets/logo/logo.svg` — the same package `loadBrand` fetches,
|
||||
* served by the same `hanzoai/spa` server. No external dependency required.
|
||||
*/
|
||||
export function BrandLogo({
|
||||
brand,
|
||||
tenant,
|
||||
height = 32,
|
||||
}: {
|
||||
brand: BrandContract
|
||||
tenant: TenantConfig
|
||||
height?: number
|
||||
}) {
|
||||
const local = `/brand/${encodeURIComponent(tenant.brandPackage)}/assets/logo/logo.svg`
|
||||
const [src, setSrc] = useState(brand.logoUrl || local)
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={brand.name}
|
||||
height={height}
|
||||
style={{ height, width: 'auto', display: 'block' }}
|
||||
onError={() => {
|
||||
if (src !== local) setSrc(local)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Marketing } from '../marketing'
|
||||
|
||||
/**
|
||||
* Right-hand branding panel for the split-view login. Ported from the frozen
|
||||
* `legacy-nextjs/components/MarketingPanel.tsx`: a tagline pill, hero copy, and
|
||||
* an auto-rotating testimonial card. Accent color comes from the brand
|
||||
* contract; copy comes from the per-org marketing map.
|
||||
*/
|
||||
export function MarketingPanel({ marketing, accent }: { marketing: Marketing; accent: string }) {
|
||||
const quotes = marketing.quotes
|
||||
const [i, setI] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (quotes.length <= 1) return
|
||||
const t = setInterval(() => setI((p) => (p + 1) % quotes.length), 5000)
|
||||
return () => clearInterval(t)
|
||||
}, [quotes.length])
|
||||
|
||||
const q = quotes[i]
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-marketing">
|
||||
{marketing.tagline ? (
|
||||
<div className="hanzo-id-pill">
|
||||
<span className="hanzo-id-pill-star">✦</span>
|
||||
<span>{marketing.tagline}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h2 className="hanzo-id-marketing-title">{marketing.title}</h2>
|
||||
<p className="hanzo-id-marketing-subtitle">{marketing.subtitle}</p>
|
||||
|
||||
{q ? (
|
||||
<div className="hanzo-id-quote-card">
|
||||
<blockquote>
|
||||
<span className="hanzo-id-quote-mark">“</span>
|
||||
{q.text}
|
||||
<span className="hanzo-id-quote-mark">”</span>
|
||||
</blockquote>
|
||||
<div className="hanzo-id-quote-author">
|
||||
<div className="hanzo-id-quote-avatar" style={{ backgroundColor: accent }}>
|
||||
{q.author
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="hanzo-id-quote-name">{q.author}</div>
|
||||
{q.role ? <div className="hanzo-id-quote-role">{q.role}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{quotes.length > 1 ? (
|
||||
<div className="hanzo-id-quote-dots">
|
||||
{quotes.map((_, n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
aria-label={`Show testimonial ${n + 1}`}
|
||||
className="hanzo-id-quote-dot"
|
||||
onClick={() => setI(n)}
|
||||
style={{ backgroundColor: n === i ? accent : undefined }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +1,93 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import type { AuthClient } from '@hanzo/id-auth'
|
||||
import { createIam, createAuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
export function Callback({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
|
||||
/**
|
||||
* OAuth/OIDC callback.
|
||||
*
|
||||
* Two kinds of return land here:
|
||||
* 1. The portal's own OIDC PKCE return (password / SDK `signinRedirect`) —
|
||||
* completed by the `@hanzo/iam` SDK's `handleCallback`, which reads back
|
||||
* the exact PKCE verifier/state it stored.
|
||||
* 2. A SOCIAL provider return (GitHub/Google), where `social.ts` sent the
|
||||
* user out with a base64 `state` that encodes the original authorize
|
||||
* request. We detect that, exchange the provider `code` at the IAM backend
|
||||
* (`client.providerLogin`), and follow the continue-URL it returns — which
|
||||
* re-enters this callback as case (1). (Pending live verification; only
|
||||
* reachable once real provider creds are seeded.)
|
||||
*
|
||||
* Routing after the OIDC exchange:
|
||||
* - A downstream app left its target in `post_login_redirect` → forward tokens.
|
||||
* - A bare portal sign-in → `/onboarding`.
|
||||
*/
|
||||
|
||||
/** Decode a social-provider `state` (base64 of the original authorize query). */
|
||||
function decodeProviderState(state: string | null): URLSearchParams | null {
|
||||
if (!state) return null
|
||||
try {
|
||||
const decoded = atob(state)
|
||||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||||
// A provider-login state always carries application + provider markers.
|
||||
if (params.get('provider') && params.get('application')) return params
|
||||
} catch {
|
||||
// not base64 → an SDK/OIDC state, not a provider return
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function Callback({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const code = sp.get('code')
|
||||
if (!code) {
|
||||
setError('Missing authorization code.')
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const providerState = decodeProviderState(search.get('state'))
|
||||
|
||||
// Case (2): social provider return → exchange the provider code, then follow
|
||||
// the continue-URL back into case (1).
|
||||
if (providerState && search.get('code')) {
|
||||
const client = createAuthClient({ tenant })
|
||||
const oidcQuery = atob(search.get('state')!)
|
||||
client
|
||||
.providerLogin({
|
||||
application: providerState.get('application') ?? '',
|
||||
provider: providerState.get('provider') ?? '',
|
||||
code: search.get('code') ?? '',
|
||||
oidcQuery,
|
||||
method: providerState.get('method') ?? 'signin',
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.redirectUrl) window.location.replace(r.redirectUrl)
|
||||
else setError(r.error ?? 'Sign-in failed')
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
return
|
||||
}
|
||||
const codeVerifier = sessionStorage.getItem('pkce_verifier') ?? undefined
|
||||
client
|
||||
.exchange(code, codeVerifier)
|
||||
|
||||
// Case (1): the portal's own OIDC PKCE return.
|
||||
const iam = createIam(tenant)
|
||||
iam
|
||||
.handleCallback(window.location.href)
|
||||
.then((tok) => {
|
||||
// Forward the tokens to whichever app initiated this flow.
|
||||
const target = sessionStorage.getItem('post_login_redirect') ?? '/'
|
||||
const url = new URL(target, window.location.origin)
|
||||
url.searchParams.set('access_token', tok.accessToken)
|
||||
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
|
||||
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
|
||||
sessionStorage.removeItem('pkce_verifier')
|
||||
const target = sessionStorage.getItem('post_login_redirect')
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
window.location.replace(url.toString())
|
||||
if (target) {
|
||||
// Forward tokens to whichever app initiated this flow.
|
||||
const url = new URL(target, window.location.origin)
|
||||
url.searchParams.set('access_token', tok.accessToken)
|
||||
if (tok.refreshToken) url.searchParams.set('refresh_token', tok.refreshToken)
|
||||
if (tok.idToken) url.searchParams.set('id_token', tok.idToken)
|
||||
window.location.replace(url.toString())
|
||||
return
|
||||
}
|
||||
// Bare portal sign-in → onboarding.
|
||||
window.location.replace('/onboarding')
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
}, [client])
|
||||
}, [tenant])
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-callback">
|
||||
<BrandHeader brand={brand} tenant={tenant} />
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : <p>Completing sign-in…</p>}
|
||||
</main>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { ForgotForm, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
export function Forgot({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
|
||||
export function Forgot({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-forgot">
|
||||
<BrandHeader brand={brand} tenant={tenant} />
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Reset your {brand.name} password</h1>
|
||||
<ForgotForm client={client} />
|
||||
|
||||
+109
-55
@@ -1,65 +1,119 @@
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { LoginForm, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandLogo } from '../components/BrandLogo'
|
||||
import { MarketingPanel } from '../components/MarketingPanel'
|
||||
import { marketingFor } from '../marketing'
|
||||
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 { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* Split-view login (restored from the legacy Next.js design): the login card
|
||||
* on the left, the per-org marketing/branding panel on the right (hidden on
|
||||
* narrow viewports). The auth wiring is untouched — `<LoginForm>` from
|
||||
* `@hanzo/id-auth` still drives the `/v1/iam/login` + code flow.
|
||||
*/
|
||||
export function Login({
|
||||
client,
|
||||
brand,
|
||||
tenant,
|
||||
signupEnabled,
|
||||
}: {
|
||||
client: AuthClient
|
||||
brand: BrandContract
|
||||
tenant: TenantConfig
|
||||
signupEnabled: boolean
|
||||
}) {
|
||||
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const redirectUri = sp.get('redirect_uri') ?? undefined
|
||||
const state = sp.get('state') ?? undefined
|
||||
const clientIdOverride = sp.get('client_id') ?? undefined
|
||||
const accent = brand.accentColor ?? '#ffffff'
|
||||
const marketing = marketingFor(tenant.orgId)
|
||||
const search = window.location.search
|
||||
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-split">
|
||||
<section className="hanzo-id-split-form">
|
||||
<div className="hanzo-id-card">
|
||||
<header className="hanzo-id-card-head">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<BrandLogo brand={brand} tenant={tenant} height={36} />
|
||||
</a>
|
||||
</header>
|
||||
<h1 className="hanzo-id-card-title">Sign in to {brand.name}</h1>
|
||||
<LoginForm
|
||||
client={client}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
clientIdOverride={clientIdOverride ?? undefined}
|
||||
/>
|
||||
<p className="hanzo-id-footer-links">
|
||||
<a href="/forget">Forgot password?</a>
|
||||
{signupEnabled ? (
|
||||
<>
|
||||
{' '}·{' '}
|
||||
<a href={`/signup${search}`}>Create account</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="hanzo-id-split-brand">
|
||||
<MarketingPanel marketing={marketing} accent={accent} />
|
||||
</aside>
|
||||
<div className="hanzo-id-page hanzo-id-login">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Sign in to {brand.name}</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signin"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<LoginForm
|
||||
client={client}
|
||||
redirectUri={redirectUri}
|
||||
state={state}
|
||||
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>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { createIam } from '@hanzo/id-auth'
|
||||
import { OnboardingFlow, createOnboardingService, type OnboardingState } from '@hanzo/id-onboarding'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
* Post-login onboarding page.
|
||||
*
|
||||
* Reached after a bare portal sign-in (no downstream `redirect_uri`). Mounts
|
||||
* the `@hanzo/id-onboarding` flow (org → project → wallet) wired to:
|
||||
*
|
||||
* - the IAM session token: read from the same `@hanzo/iam` PKCE client the
|
||||
* Callback stored it on, so the onboarding writes ride the logged-in
|
||||
* user's bearer token. One client, one way.
|
||||
* - a `window.ethereum` wallet connector: the host owns the wallet lib so
|
||||
* the onboarding pkg stays wallet-agnostic. Absent injected provider →
|
||||
* the wallet step is skip-only.
|
||||
*
|
||||
* On completion it lands on the portal home (`/`); a downstream app that
|
||||
* wanted a token would have carried `redirect_uri` and never reached here.
|
||||
*/
|
||||
export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: BrandContract }) {
|
||||
const iam = useMemo(() => createIam(tenant), [tenant])
|
||||
|
||||
const service = useMemo(
|
||||
() =>
|
||||
createOnboardingService({
|
||||
iamUrl: tenant.iamUrl,
|
||||
orgId: tenant.orgId,
|
||||
getAccessToken: () => iam.getValidAccessToken(),
|
||||
}),
|
||||
[tenant, iam],
|
||||
)
|
||||
|
||||
function onComplete(_state: OnboardingState) {
|
||||
// Land on the authenticated portal (apps launcher), NOT the bare hero.
|
||||
// The marker makes the portal treat the just-established session as authed
|
||||
// even before the cross-request get-account read settles.
|
||||
window.location.replace('/?signed_in=1')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-onboarding-page">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<OnboardingFlow
|
||||
service={service}
|
||||
brandName={brand.name}
|
||||
connectWallet={connectInjectedWallet}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Minimal EIP-1193 `eth_requestAccounts` connector. Null on cancel/no wallet. */
|
||||
async function connectInjectedWallet(): Promise<string | null> {
|
||||
const eth = (window as unknown as { ethereum?: Eip1193 }).ethereum
|
||||
if (!eth) return null
|
||||
try {
|
||||
const accounts = (await eth.request({ method: 'eth_requestAccounts' })) as string[]
|
||||
return accounts?.[0] ?? null
|
||||
} catch {
|
||||
return null // user rejected the connection prompt
|
||||
}
|
||||
}
|
||||
|
||||
interface Eip1193 {
|
||||
request(args: { method: string; params?: unknown[] }): Promise<unknown>
|
||||
}
|
||||
+68
-111
@@ -1,168 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import type { AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandLogo } from '../components/BrandLogo'
|
||||
import { Login } from './Login'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
import { appsFor, billingFor } from '../marketing'
|
||||
|
||||
interface SessionUser {
|
||||
readonly id: string
|
||||
readonly name?: string
|
||||
readonly displayName?: string
|
||||
readonly email?: string
|
||||
readonly avatar?: string
|
||||
}
|
||||
|
||||
type AuthState = { status: 'loading' } | { status: 'anon' } | { status: 'authed'; user: SessionUser }
|
||||
type Auth =
|
||||
| { s: 'loading' }
|
||||
| { s: 'anon' }
|
||||
| { s: 'authed'; name?: string; email?: string }
|
||||
|
||||
/**
|
||||
* Post-login portal. Detects the IAM session via the same-origin
|
||||
* `/v1/iam/get-account` proxy (cookie-scoped, `credentials: 'include'`):
|
||||
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
|
||||
*
|
||||
* - signed in → the apps launcher ("all the apps") + account/billing cards,
|
||||
* restored from the legacy `account` page.
|
||||
* - signed out → the brand hero with sign-in / create-account CTAs.
|
||||
* - signed out → the actual `<Login>` form (GitHub/Google/email+password),
|
||||
* identical to `/login`. A bare sign-in here lands on
|
||||
* onboarding, then back on `/` authenticated.
|
||||
* - signed in → the apps launcher (the org's apps) + billing / sign-out.
|
||||
*
|
||||
* No token juggling in localStorage — the portal is a cookie-session OIDC
|
||||
* provider, so the session lives in the IAM cookie and we just read it.
|
||||
* Auth is read same-origin from `/v1/iam/get-account` (cookie session;
|
||||
* `tenant.iamUrl` is the brand's own `*.id` host, so this is first-party and
|
||||
* the session cookie rides along). The `?signed_in=1` marker set by the
|
||||
* bare-login / onboarding-complete redirect is the authoritative "just
|
||||
* authenticated" signal when the cookie read hasn't propagated yet.
|
||||
*/
|
||||
export function Portal({
|
||||
client,
|
||||
brand,
|
||||
tenant,
|
||||
signupEnabled,
|
||||
}: {
|
||||
client: AuthClient
|
||||
brand: BrandContract
|
||||
tenant: TenantConfig
|
||||
signupEnabled: boolean
|
||||
}) {
|
||||
const [auth, setAuth] = useState<AuthState>({ status: 'loading' })
|
||||
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
// `?signed_in=1` is set by the bare-portal login flow the moment the IAM
|
||||
// session cookie is established this tab. It's our authoritative "just
|
||||
// authenticated" signal; `get-account` is the richer-but-best-effort source
|
||||
// for the user's name/email/avatar.
|
||||
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
|
||||
fetch(new URL('/v1/iam/get-account', tenant.publicOrigin).toString(), {
|
||||
fetch(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body: Record<string, unknown>) => {
|
||||
.then((b: Record<string, unknown>) => {
|
||||
if (!alive) return
|
||||
const d = body.data as Record<string, unknown> | undefined
|
||||
if (body.status === 'ok' && d && typeof d === 'object') {
|
||||
setAuth({
|
||||
status: 'authed',
|
||||
user: {
|
||||
id: String(d.id ?? d.name ?? ''),
|
||||
name: typeof d.name === 'string' ? d.name : undefined,
|
||||
displayName: typeof d.displayName === 'string' ? d.displayName : undefined,
|
||||
email: typeof d.email === 'string' ? d.email : undefined,
|
||||
avatar: typeof d.avatar === 'string' ? d.avatar : undefined,
|
||||
},
|
||||
})
|
||||
} else if (justSignedIn) {
|
||||
setAuth({ status: 'authed', user: { id: '' } })
|
||||
const d = b.data as Record<string, unknown> | undefined
|
||||
if (b.status === 'ok' && d && typeof d === 'object') {
|
||||
setAuth({ s: 'authed', name: str(d.displayName) ?? str(d.name), email: str(d.email) })
|
||||
} else {
|
||||
setAuth({ status: 'anon' })
|
||||
setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!alive) return
|
||||
setAuth(justSignedIn ? { status: 'authed', user: { id: '' } } : { status: 'anon' })
|
||||
if (alive) setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [tenant.publicOrigin])
|
||||
}, [tenant.iamUrl])
|
||||
|
||||
if (auth.status === 'loading') {
|
||||
if (auth.s === 'loading') {
|
||||
return (
|
||||
<div className="hanzo-id-loading">
|
||||
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (auth.status === 'anon') {
|
||||
return (
|
||||
<div className="hanzo-id-page hanzo-id-portal">
|
||||
<header className="hanzo-id-brand-header">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<BrandLogo brand={brand} tenant={tenant} height={32} />
|
||||
</a>
|
||||
</header>
|
||||
<main>
|
||||
<h1>Welcome to {brand.name}</h1>
|
||||
<p className="lede">{brand.description}</p>
|
||||
<div className="hanzo-id-cta-row">
|
||||
<a className="hanzo-id-btn primary" href="/login">Sign in</a>
|
||||
{signupEnabled ? <a className="hanzo-id-btn" href="/signup">Create account</a> : null}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Signed out: the root IS the login form (no marketing hero).
|
||||
if (auth.s === 'anon') return <Login client={client} brand={brand} />
|
||||
|
||||
const { user } = auth
|
||||
// Signed in: the apps launcher.
|
||||
const apps = appsFor(tenant.orgId)
|
||||
const billingUrl = billingFor(tenant.orgId)
|
||||
const display = user.displayName || user.name || user.email || 'Account'
|
||||
const logoutUrl = client.logout(undefined, tenant.publicOrigin + '/login')
|
||||
const logoutUrl = client.logout(undefined, `${tenant.publicOrigin}/login`)
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-portal-authed">
|
||||
<nav className="hanzo-id-nav">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<BrandLogo brand={brand} tenant={tenant} height={28} />
|
||||
</a>
|
||||
<div className="hanzo-id-nav-links">
|
||||
<a href={billingUrl}>Billing</a>
|
||||
<a href={logoutUrl}>Sign out</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="hanzo-id-portal-body">
|
||||
<div className="hanzo-id-profile">
|
||||
{user.avatar ? (
|
||||
<img className="hanzo-id-profile-avatar" src={user.avatar} alt="" />
|
||||
) : (
|
||||
<div
|
||||
className="hanzo-id-profile-avatar hanzo-id-profile-avatar-fallback"
|
||||
style={{ color: brand.accentColor ?? '#fff' }}
|
||||
<div className="hanzo-id-page hanzo-id-portal">
|
||||
<BrandHeader brand={brand} />
|
||||
<main style={{ width: '100%', maxWidth: 760 }}>
|
||||
<h1>Your {brand.name} apps</h1>
|
||||
{auth.email ? <p className="lede">{auth.email}</p> : null}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))',
|
||||
gap: 12,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
{apps.map((a) => (
|
||||
<a
|
||||
key={a.name}
|
||||
href={a.href}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '16px 18px',
|
||||
border: '1px solid rgba(255,255,255,0.14)',
|
||||
borderRadius: 12,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{display[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1>{display}</h1>
|
||||
{user.email ? <p className="lede">{user.email}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="hanzo-id-section-title">{brand.name} apps</h2>
|
||||
<div className="hanzo-id-apps-grid">
|
||||
{apps.map((app) => (
|
||||
<a key={app.name} className="hanzo-id-app-card" href={app.href}>
|
||||
<div className="hanzo-id-app-card-head">
|
||||
<span className="hanzo-id-app-name">{app.name}</span>
|
||||
<span className="hanzo-id-app-arrow" aria-hidden="true">↗</span>
|
||||
<div style={{ fontWeight: 600, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{a.name}</span>
|
||||
<span aria-hidden style={{ opacity: 0.5 }}>↗</span>
|
||||
</div>
|
||||
<p className="hanzo-id-app-desc">{app.description}</p>
|
||||
<div style={{ opacity: 0.6, fontSize: 13, marginTop: 4 }}>{a.description}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hanzo-id-portal-footer">
|
||||
<a className="hanzo-id-muted-link" href={brand.appDomain ? `https://${brand.appDomain}` : '/'}>
|
||||
← Back to {brand.name}
|
||||
</a>
|
||||
<a className="hanzo-id-btn ghost" href={logoutUrl}>Sign out</a>
|
||||
<div style={{ marginTop: 28, display: 'flex', gap: 18 }}>
|
||||
<a className="hanzo-id-linkbtn" href={billingUrl}>Billing</a>
|
||||
<a className="hanzo-id-linkbtn" href={logoutUrl}>Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import { SignupForm, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandLogo } from '../components/BrandLogo'
|
||||
import { MarketingPanel } from '../components/MarketingPanel'
|
||||
import { marketingFor } from '../marketing'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { SignupForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/** Split-view signup, mirroring the restored login layout. */
|
||||
export function Signup({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
|
||||
export function Signup({ client, brand }: { client: AuthClient; brand: BrandContract }) {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const inviteCode = sp.get('invite') ?? undefined
|
||||
const accent = brand.accentColor ?? '#ffffff'
|
||||
const marketing = marketingFor(tenant.orgId)
|
||||
const search = window.location.search
|
||||
|
||||
const clientIdOverride = sp.get('client_id') ?? undefined
|
||||
const redirectUri = sp.get('redirect_uri') ?? undefined
|
||||
return (
|
||||
<div className="hanzo-id-split">
|
||||
<section className="hanzo-id-split-form">
|
||||
<div className="hanzo-id-card">
|
||||
<header className="hanzo-id-card-head">
|
||||
<a href="/" aria-label={brand.name}>
|
||||
<BrandLogo brand={brand} tenant={tenant} height={36} />
|
||||
</a>
|
||||
</header>
|
||||
<h1 className="hanzo-id-card-title">Create your {brand.name} account</h1>
|
||||
<SignupForm client={client} inviteCode={inviteCode} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
Already have an account? <a href={`/login${search}`}>Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="hanzo-id-split-brand">
|
||||
<MarketingPanel marketing={marketing} accent={accent} />
|
||||
</aside>
|
||||
<div className="hanzo-id-page hanzo-id-signup">
|
||||
<BrandHeader brand={brand} />
|
||||
<main>
|
||||
<h1>Create your {brand.name} account</h1>
|
||||
<SocialButtons
|
||||
client={client}
|
||||
clientIdOverride={clientIdOverride}
|
||||
intent="signup"
|
||||
postLoginRedirect={redirectUri}
|
||||
/>
|
||||
<SignupForm client={client} inviteCode={inviteCode} />
|
||||
<p className="hanzo-id-footer-links">
|
||||
Already have an account? <a href="/login">Sign in</a>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+22
-14
@@ -4,38 +4,46 @@ import { resolve } from 'path'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
// Vite loads this config as a native ESM module, where `require` is undefined.
|
||||
// Build a CJS-style resolver bound to this file so `<pkg>/brand.json` subpath
|
||||
// resolution works in both `configureServer` and `generateBundle`.
|
||||
const require = createRequire(import.meta.url)
|
||||
// ESM Vite config has no global `require`; build one bound to this file so
|
||||
// `require.resolve('@scope/brand/brand.json')` works at config-eval time.
|
||||
const req = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Per-brand brand.json copy plugin.
|
||||
*
|
||||
* Each per-org brand package (`@hanzo/brand`, `@luxfi/brand`, `@zooai/brand`,
|
||||
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve them
|
||||
* verbatim at `/brand/<pkg>/brand.json` so the runtime tenant resolver can
|
||||
* fetch the right one based on hostname.
|
||||
* `@parsdao/brand`) ships a `brand.json` at the package root. We serve each at
|
||||
* a FLAT, encoding-safe path `/brand/<scope>.json` (scope = the npm scope:
|
||||
* `@hanzo/brand` -> `hanzo`). A nested `/brand/@hanzo/brand/brand.json` URL
|
||||
* carries a literal `@` and an encoded `%2F` that the production static server
|
||||
* (hanzoai/static) cannot map to the on-disk file — it falls through to the
|
||||
* SPA catch-all and returns index.html, so the runtime brand fetch would parse
|
||||
* HTML as JSON. The flat slug avoids that entirely. `loadBrand` fetches the
|
||||
* same `/brand/<scope>.json`.
|
||||
*
|
||||
* Assets (logos, favicons) are imported by URL inside the per-brand `brand.json`
|
||||
* (CDN URLs in production), so no further asset copying is needed.
|
||||
*/
|
||||
const BRAND_PACKAGES = ['@hanzo/brand', '@luxfi/brand', '@zooai/brand', '@parsdao/brand']
|
||||
|
||||
/** npm scope -> flat brand slug: `@hanzo/brand` -> `hanzo`. */
|
||||
const brandSlug = (pkg: string): string => pkg.replace(/^@/, '').split('/')[0]!
|
||||
|
||||
function brandJsonPlugin() {
|
||||
return {
|
||||
name: 'hanzo-id-brand-json',
|
||||
configureServer(server: any) {
|
||||
server.middlewares.use((req: any, res: any, next: any) => {
|
||||
const m = /^\/brand\/(.+)\/brand\.json$/.exec(req.url ?? '')
|
||||
server.middlewares.use((req2: any, res: any, next: any) => {
|
||||
const m = /^\/brand\/([^/]+)\.json$/.exec(req2.url ?? '')
|
||||
if (!m) return next()
|
||||
const pkg = decodeURIComponent(m[1]!)
|
||||
if (!BRAND_PACKAGES.includes(pkg)) {
|
||||
const slug = m[1]!
|
||||
const pkg = BRAND_PACKAGES.find((p) => brandSlug(p) === slug)
|
||||
if (!pkg) {
|
||||
res.statusCode = 404
|
||||
return res.end()
|
||||
}
|
||||
try {
|
||||
const path = require.resolve(`${pkg}/brand.json`)
|
||||
const path = req.resolve(`${pkg}/brand.json`)
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
return res.end(readFileSync(path, 'utf8'))
|
||||
@@ -48,11 +56,11 @@ function brandJsonPlugin() {
|
||||
generateBundle(this: any) {
|
||||
for (const pkg of BRAND_PACKAGES) {
|
||||
try {
|
||||
const path = require.resolve(`${pkg}/brand.json`)
|
||||
const path = req.resolve(`${pkg}/brand.json`)
|
||||
if (!existsSync(path)) continue
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `brand/${pkg}/brand.json`,
|
||||
fileName: `brand/${brandSlug(pkg)}.json`,
|
||||
source: readFileSync(path, 'utf8'),
|
||||
})
|
||||
} catch {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/id",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.29",
|
||||
"description": "Hanzo ID — white-label login + identity verification portal (Vite + @hanzo/gui)",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/id-auth",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
@@ -14,11 +14,13 @@
|
||||
},
|
||||
"files": ["src"],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit"
|
||||
"tc": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.9.4"
|
||||
"@hanzo/iam": "^0.11.0",
|
||||
"@paulmillr/qr": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
+300
-76
@@ -1,15 +1,27 @@
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import type {
|
||||
AppLoginInfo,
|
||||
CodeLoginRequest,
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
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.
|
||||
*
|
||||
@@ -39,12 +51,63 @@ export interface AuthClient {
|
||||
authorize(req: OAuthAuthorizeRequest): string
|
||||
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
|
||||
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
|
||||
/** Fetch the application's enabled providers + sign-in methods (drives which buttons render). */
|
||||
appLogin(): Promise<AppLoginInfo>
|
||||
/** Send an email/SMS verification code for passwordless login. dest = email or E.164 phone. */
|
||||
sendLoginCode(dest: string): Promise<{ ok: boolean; error?: string }>
|
||||
/** Complete a passwordless login with the code sent to dest. */
|
||||
loginWithCode(req: CodeLoginRequest): Promise<LoginResponse>
|
||||
/**
|
||||
* Read the live enabled-auth-methods view for an application from
|
||||
* `/v1/iam/get-app-login` — the canonical source of truth for which
|
||||
* sign-in buttons (password / GitHub / Google / Web3) to render.
|
||||
* Resolves to null when the endpoint is unreachable so callers can fall
|
||||
* back to the tenant's declared default method set.
|
||||
*/
|
||||
getAppLogin(clientId?: string): Promise<AppLogin | null>
|
||||
/**
|
||||
* Complete a social provider login when the provider redirects back to
|
||||
* `/callback` with a `code` + base64 `state` (see `social.ts`). Exchanges the
|
||||
* provider code at the IAM backend (the Casdoor `AuthBackend.login` contract)
|
||||
* and resolves the URL to redirect to — the original OIDC `redirect_uri` with
|
||||
* an authorization code, which the portal's normal PKCE callback then
|
||||
* completes. NOTE: pending live verification — runs only once real OAuth
|
||||
* 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. */
|
||||
export interface ProviderExchangeRequest {
|
||||
/** IAM application name (from the decoded state). */
|
||||
readonly application: string
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly provider: string
|
||||
/** The provider's authorization code (the `?code=` on the /callback return). */
|
||||
readonly code: string
|
||||
/** The ORIGINAL OIDC authorize query string (decoded from the base64 state). */
|
||||
readonly oidcQuery: string
|
||||
/** "signin" | "signup". */
|
||||
readonly method: string
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
@@ -65,6 +128,10 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
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',
|
||||
@@ -135,7 +202,6 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
url.searchParams.set('response_type', req.responseType ?? 'code')
|
||||
url.searchParams.set('scope', req.scope ?? 'openid profile email')
|
||||
url.searchParams.set('state', req.state)
|
||||
if (req.provider) url.searchParams.set('provider', req.provider)
|
||||
if (req.nonce) url.searchParams.set('nonce', req.nonce)
|
||||
if (req.codeChallenge) {
|
||||
url.searchParams.set('code_challenge', req.codeChallenge)
|
||||
@@ -180,72 +246,145 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
// appLogin fetches the application's enabled providers + sign-in methods so
|
||||
// the UI renders exactly what the IAM app offers (social buttons, code login).
|
||||
async function appLogin(): Promise<AppLoginInfo> {
|
||||
async function getAppLogin(clientId?: string): Promise<AppLogin | null> {
|
||||
const id = clientId ?? tenant.clientId
|
||||
const url = new URL('/v1/iam/get-app-login', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', tenant.clientId)
|
||||
url.searchParams.set('clientId', id)
|
||||
url.searchParams.set('responseType', 'code')
|
||||
url.searchParams.set('redirectUri', `${tenant.publicOrigin}/callback`)
|
||||
url.searchParams.set('scope', 'openid profile email')
|
||||
url.searchParams.set('state', 'login')
|
||||
const res = await f(url.toString(), { credentials: 'include' })
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
const d = (body.data ?? {}) as Record<string, unknown>
|
||||
const providers = Array.isArray(d.providers)
|
||||
? (d.providers as Array<Record<string, unknown>>).map((p) => {
|
||||
const prov = (p.provider ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
name: String(p.name ?? prov.name ?? ''),
|
||||
displayName: typeof prov.displayName === 'string' ? prov.displayName : undefined,
|
||||
type: typeof prov.type === 'string' ? prov.type : undefined,
|
||||
category: typeof prov.category === 'string' ? prov.category : undefined,
|
||||
canSignIn: p.canSignIn !== false,
|
||||
canSignUp: p.canSignUp !== false,
|
||||
}
|
||||
})
|
||||
: []
|
||||
const signinMethods = Array.isArray(d.signinMethods)
|
||||
? (d.signinMethods as Array<Record<string, unknown>>).map((m) => ({
|
||||
name: String(m.name ?? ''),
|
||||
rule: typeof m.rule === 'string' ? m.rule : undefined,
|
||||
}))
|
||||
: []
|
||||
return {
|
||||
name: String(d.name ?? tenant.appName),
|
||||
displayName: typeof d.displayName === 'string' ? d.displayName : undefined,
|
||||
providers,
|
||||
signinMethods,
|
||||
enablePassword: d.enablePassword !== false,
|
||||
enableCodeSignin: d.enableCodeSignin === true,
|
||||
enableSignUp: d.enableSignUp !== false,
|
||||
url.searchParams.set('state', 'app-login')
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) return null
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (body.status !== 'ok' || typeof body.data !== 'object' || body.data === null) return null
|
||||
return parseAppLogin(body.data as Record<string, unknown>, tenant.appName, tenant.orgId)
|
||||
}
|
||||
|
||||
async function sendLoginCode(dest: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const url = new URL('/v1/iam/send-verification-code', tenant.iamUrl)
|
||||
url.searchParams.set('clientId', tenant.clientId)
|
||||
url.searchParams.set('organization', tenant.orgId)
|
||||
const isEmail = dest.includes('@')
|
||||
async function providerLogin(
|
||||
req: ProviderExchangeRequest,
|
||||
): Promise<{ redirectUrl?: string; error?: string }> {
|
||||
// POST the provider code to the IAM backend with the original OIDC params
|
||||
// as the query string (Casdoor `AuthBackend.login(body, oAuthParams)`). The
|
||||
// backend exchanges the code, signs the user in, and returns the URL to
|
||||
// continue the original authorize request.
|
||||
const url = new URL('/v1/iam/login', tenant.iamUrl)
|
||||
const oidc = new URLSearchParams(req.oidcQuery.replace(/^\?/, ''))
|
||||
for (const [k, v] of oidc) {
|
||||
if (['client_id', 'redirect_uri', 'response_type', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method'].includes(k)) {
|
||||
url.searchParams.set(k, v)
|
||||
}
|
||||
}
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
applicationId: `admin/${tenant.appName}`,
|
||||
organization: tenant.orgId,
|
||||
dest,
|
||||
type: isEmail ? 'email' : 'phone',
|
||||
method: 'login',
|
||||
checkUser: dest,
|
||||
type: 'code',
|
||||
application: req.application,
|
||||
provider: req.provider,
|
||||
code: req.code,
|
||||
state: req.application,
|
||||
redirectUri: `${tenant.publicOrigin}/callback`,
|
||||
method: req.method,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: typeof body.msg === 'string' ? body.msg : 'failed' }
|
||||
return { ok: true }
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || body.status === 'error') {
|
||||
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
|
||||
}
|
||||
// On success the backend returns the continue-URL in `data`.
|
||||
const data = typeof body.data === 'string' ? body.data : ''
|
||||
return data ? { redirectUrl: data } : { error: 'provider login returned no redirect' }
|
||||
}
|
||||
|
||||
async function loginWithCode(req: CodeLoginRequest): Promise<LoginResponse> {
|
||||
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)
|
||||
@@ -253,21 +392,24 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
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 isEmail = req.dest.includes('@')
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
username: req.dest,
|
||||
code: req.code,
|
||||
// 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,
|
||||
signinMethod: 'Verification code',
|
||||
...(isEmail ? { email: req.dest } : { phone: req.dest }),
|
||||
autoSignin: true,
|
||||
enableMfaRemember: req.rememberDevice ?? false,
|
||||
}),
|
||||
})
|
||||
return parseLoginResponse(res, req)
|
||||
@@ -281,9 +423,76 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
authorize,
|
||||
exchange,
|
||||
logout,
|
||||
appLogin,
|
||||
sendLoginCode,
|
||||
loginWithCode,
|
||||
getAppLogin,
|
||||
providerLogin,
|
||||
getAccount,
|
||||
mfaInitiate,
|
||||
mfaVerify,
|
||||
mfaEnable,
|
||||
mfaChallenge,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an IAM provider record to its canonical authorize-endpoint `provider`
|
||||
* key. IAM names providers `provider-<key>` (e.g. `provider-github`); the
|
||||
* `/v1/iam/oauth/authorize?provider=<key>` param wants the bare key. The
|
||||
* Web3Onboard wallet provider maps to `web3`.
|
||||
*/
|
||||
function providerKey(name: string): string {
|
||||
return name.replace(/^provider-/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider is renderable only when IAM holds a real OAuth clientId for it.
|
||||
* The seed ships obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`,
|
||||
* `placeholder`); an empty or placeholder id means the provider isn't
|
||||
* provisioned, so its button is hidden rather than dead-ending the user. Real
|
||||
* OAuth client ids never contain "placeholder".
|
||||
*/
|
||||
function isConfiguredClientId(clientId: string): boolean {
|
||||
return clientId.length > 0 && !/placeholder/i.test(clientId)
|
||||
}
|
||||
|
||||
/** Shape the `/v1/iam/get-app-login` `data` payload into the {@link AppLogin} view. */
|
||||
function parseAppLogin(
|
||||
data: Record<string, unknown>,
|
||||
fallbackApp: string,
|
||||
fallbackOrg: string,
|
||||
): AppLogin {
|
||||
const rawProviders = Array.isArray(data.providers) ? data.providers : []
|
||||
const providers: AppProvider[] = rawProviders
|
||||
.map((p): AppProvider | null => {
|
||||
if (typeof p !== 'object' || p === null) return null
|
||||
const rec = p as Record<string, unknown>
|
||||
const name = typeof rec.name === 'string' ? rec.name : ''
|
||||
if (!name) return null
|
||||
// The clientId lives on the nested provider record (`rec.provider`), not
|
||||
// the outer link object.
|
||||
const inner =
|
||||
typeof rec.provider === 'object' && rec.provider !== null
|
||||
? (rec.provider as Record<string, unknown>)
|
||||
: {}
|
||||
const clientId = typeof inner.clientId === 'string' ? inner.clientId : ''
|
||||
return {
|
||||
name,
|
||||
key: providerKey(name),
|
||||
canSignIn: rec.canSignIn !== false,
|
||||
canSignUp: rec.canSignUp !== false,
|
||||
configured: isConfiguredClientId(clientId),
|
||||
type: typeof inner.type === 'string' ? inner.type : '',
|
||||
clientId,
|
||||
scopes: typeof inner.scopes === 'string' ? inner.scopes : '',
|
||||
}
|
||||
})
|
||||
.filter((p): p is AppProvider => p !== null)
|
||||
return {
|
||||
application: typeof data.name === 'string' ? data.name : fallbackApp,
|
||||
organization: typeof data.organization === 'string' ? data.organization : fallbackOrg,
|
||||
enablePassword: data.enablePassword !== false,
|
||||
enableSignUp: data.enableSignUp !== false,
|
||||
enableCodeSignin: data.enableCodeSignin === true,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +511,23 @@ 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) {
|
||||
@@ -311,12 +537,12 @@ async function parseLoginResponse(
|
||||
}
|
||||
}
|
||||
|
||||
// Bare portal sign-in: the IAM session cookie is now set; land on the portal.
|
||||
// The `signed_in` marker tells the portal the session was just established
|
||||
// this tab — it shows the apps launcher even if the cross-proxy
|
||||
// `get-account` session lookup hasn't propagated yet.
|
||||
// Bare portal sign-in: the IAM session cookie is now set; land on the
|
||||
// post-login onboarding flow. Onboarding's IAM writes ride the same
|
||||
// session cookie (`credentials: include`), so no bearer token is needed
|
||||
// for the password path.
|
||||
if (!req?.redirectUri) {
|
||||
return { redirectUrl: '/?signed_in=1' }
|
||||
return { redirectUrl: '/onboarding' }
|
||||
}
|
||||
|
||||
// Fallback: a nested token payload (future direct-token IAM responses).
|
||||
@@ -326,7 +552,5 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TenantConfig } from '@hanzo/id-shared'
|
||||
import { IAM } from '@hanzo/iam/browser'
|
||||
|
||||
/**
|
||||
* One IAM browser-SDK instance per tenant, wired to the portal's own
|
||||
* `/callback` route. This is the single place that constructs the PKCE
|
||||
* client — social/web3 sign-in (here) and the callback handler
|
||||
* (`Callback.tsx`) share it so the PKCE verifier/state the SDK stores on
|
||||
* `signinRedirect` is the same one it reads on `handleCallback`. One way.
|
||||
*
|
||||
* The portal is its own OIDC client (`clientId` = the brand `-id` app), so
|
||||
* every flow it initiates lands back at `${publicOrigin}/callback`.
|
||||
*/
|
||||
export function createIam(tenant: TenantConfig, clientId?: string): IAM {
|
||||
return new IAM({
|
||||
serverUrl: tenant.iamUrl,
|
||||
clientId: clientId ?? tenant.clientId,
|
||||
redirectUri: `${tenant.publicOrigin}/callback`,
|
||||
scope: 'openid profile email',
|
||||
})
|
||||
}
|
||||
+20
-5
@@ -1,14 +1,29 @@
|
||||
export { createAuthClient, type AuthClient, type AuthClientOptions } from './client'
|
||||
export {
|
||||
createAuthClient,
|
||||
mfaChannelOf,
|
||||
MFA_TOTP,
|
||||
type AuthClient,
|
||||
type AuthClientOptions,
|
||||
} from './client'
|
||||
export { createIam } from './iam'
|
||||
export {
|
||||
startProviderLogin,
|
||||
buildProviderAuthUrl,
|
||||
isHoppableProvider,
|
||||
type ProviderLoginParams,
|
||||
} from './social'
|
||||
export type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MfaChannel,
|
||||
MfaChallengeRequest,
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
SignupRequest,
|
||||
ForgotRequest,
|
||||
OAuthAuthorizeRequest,
|
||||
TokenResponse,
|
||||
ProviderInfo,
|
||||
SigninMethod,
|
||||
AppLoginInfo,
|
||||
CodeLoginRequest,
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
} from './types'
|
||||
export * from './ui'
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Provider-hop URL builder tests — pure, no network. Run with:
|
||||
* pnpm --filter @hanzo/id-auth test
|
||||
*
|
||||
* Verifies the URL + base64 state match the Hanzo-IAM (Casdoor) `getAuthUrl`
|
||||
* contract so the backend `/callback` exchange accepts the return. The
|
||||
* end-to-end OAuth round-trip still needs live verification once real provider
|
||||
* creds are seeded — but the URL/state construction is locked down here.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildProviderAuthUrl, isHoppableProvider } from './social.ts'
|
||||
|
||||
const ORIGIN = 'https://hanzo.id'
|
||||
// The original OIDC authorize query the portal was bounced here with.
|
||||
const SEARCH = '?client_id=hanzo-id&redirect_uri=https%3A%2F%2Fhanzo.id%2Fcallback&response_type=code&scope=openid&state=rp123'
|
||||
|
||||
test('GitHub hop builds the correct endpoint, client_id, redirect_uri, and scope', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(url.startsWith('https://github.com/login/oauth/authorize?'))
|
||||
assert.ok(url.includes('client_id=gh_real_123'))
|
||||
// No callbackOrigin → defaults to the browser origin.
|
||||
assert.ok(url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
assert.ok(url.includes('scope=user:email+read:user')) // GitHub default
|
||||
assert.ok(url.includes('response_type=code'))
|
||||
})
|
||||
|
||||
test('the registered callback origin overrides the browser origin in redirect_uri', () => {
|
||||
// The shared OAuth client is registered against iam.hanzo.ai/callback, so the
|
||||
// hop must return there even though the SPA runs on hanzo.id — otherwise the
|
||||
// provider rejects the redirect_uri (verified live: Google accepts ONLY
|
||||
// https://iam.hanzo.ai/callback for this client).
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
'https://iam.hanzo.ai',
|
||||
)!
|
||||
assert.ok(url.includes('redirect_uri=https://iam.hanzo.ai/callback'))
|
||||
assert.ok(!url.includes('redirect_uri=https://hanzo.id/callback'))
|
||||
})
|
||||
|
||||
test('state base64-encodes the original OIDC query + application/provider/method (round-trips)', () => {
|
||||
const url = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_real_123', method: 'signup' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
const state = new URL(url).searchParams.get('state')!
|
||||
const decoded = Buffer.from(state, 'base64').toString('utf8')
|
||||
// The RP's original request survives so the backend can complete it.
|
||||
assert.ok(decoded.includes('client_id=hanzo-id'))
|
||||
assert.ok(decoded.includes('state=rp123'))
|
||||
assert.ok(decoded.includes('application=hanzo-id'))
|
||||
assert.ok(decoded.includes('provider=provider-github'))
|
||||
assert.ok(decoded.includes('method=signup'))
|
||||
})
|
||||
|
||||
test('Google uses its own endpoint + scope; a custom provider scope overrides', () => {
|
||||
const g = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-google', type: 'Google', clientId: 'goog_1' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(g.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
|
||||
assert.ok(g.includes('scope=profile+email'))
|
||||
|
||||
const custom = buildProviderAuthUrl(
|
||||
{ application: 'hanzo-id', providerName: 'provider-github', type: 'GitHub', clientId: 'gh_1', scopes: 'repo+user' },
|
||||
ORIGIN,
|
||||
SEARCH,
|
||||
)!
|
||||
assert.ok(custom.includes('scope=repo+user'))
|
||||
})
|
||||
|
||||
test('an unconfigured (empty clientId) or unknown provider type yields no URL', () => {
|
||||
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'GitHub', clientId: '' }, ORIGIN, SEARCH), null)
|
||||
assert.equal(buildProviderAuthUrl({ application: 'a', providerName: 'p', type: 'Mystery', clientId: 'x' }, ORIGIN, SEARCH), null)
|
||||
})
|
||||
|
||||
test('isHoppableProvider knows the OAuth set, not wallet', () => {
|
||||
assert.equal(isHoppableProvider('GitHub'), true)
|
||||
assert.equal(isHoppableProvider('Google'), true)
|
||||
assert.equal(isHoppableProvider('Web3Onboard'), false)
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Social provider redirect — the "hop" that sends the browser to GitHub /
|
||||
* Google / … to start an OAuth login, replicating the Hanzo-IAM (Casdoor)
|
||||
* front-end `Provider.getAuthUrl` contract so the IAM backend's `/callback`
|
||||
* exchange accepts the return.
|
||||
*
|
||||
* Why this exists: the IAM backend's OIDC authorize endpoint, for an
|
||||
* unauthenticated request, 302s to its OWN login-page route (`/login/oauth/
|
||||
* authorize`) and expects the FRONT END to read `?provider=` and bounce to the
|
||||
* provider. We replaced that front end with this portal, so the portal must do
|
||||
* the bounce. `iam.signinRedirect({provider})` does NOT — it just re-enters the
|
||||
* authorize endpoint and loops.
|
||||
*
|
||||
* Contract (from `web/src/auth/Provider.tsx::getAuthUrl` + `Util.tsx::
|
||||
* getStateFromQueryParams` in the IAM fork):
|
||||
* url = `${endpoint}?client_id=${clientId}&redirect_uri=${origin}/callback`
|
||||
* `&scope=${scope}&response_type=code&state=${state}`
|
||||
* state = btoa(`${window.location.search}&application=${app}&provider=`
|
||||
* `${providerName}&method=${method}`) // base64 of the ORIGINAL
|
||||
* // OIDC query + app/provider/method, so the backend recovers the
|
||||
* // original request when the provider returns to /callback.
|
||||
*
|
||||
* Only the standard OAuth2 set is wired here (the providers a `-id` app
|
||||
* actually enables: github, google, +web3 handled elsewhere). Apple uses the
|
||||
* backend callback and is added when needed.
|
||||
*
|
||||
* NOTE: live-verify this end-to-end once real OAuth credentials are seeded —
|
||||
* it cannot be exercised while every provider carries placeholder creds (the
|
||||
* buttons are hidden until then; see SocialButtons + AppProvider.configured).
|
||||
*/
|
||||
|
||||
/** Provider `type` → OAuth2 authorize endpoint + default scope (IAM `authInfo`). */
|
||||
const AUTH_INFO: Record<string, { endpoint: string; scope: string }> = {
|
||||
GitHub: { endpoint: 'https://github.com/login/oauth/authorize', scope: 'user:email+read:user' },
|
||||
// Canonical Google OAuth2 authorize endpoint. (Google aliases the legacy
|
||||
// `/signin/oauth` path, but `/o/oauth2/v2/auth` is the documented, stable one.)
|
||||
Google: { endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', scope: 'profile+email' },
|
||||
}
|
||||
|
||||
export interface ProviderLoginParams {
|
||||
/** IAM application name the portal authenticates as (e.g. `hanzo-id`). */
|
||||
readonly application: string
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly providerName: string
|
||||
/** IAM provider `type`, e.g. `GitHub` / `Google` (selects the endpoint). */
|
||||
readonly type: string
|
||||
/** The provider's real OAuth client id (from `get-app-login`). */
|
||||
readonly clientId: string
|
||||
/** Override scope; falls back to the type default. */
|
||||
readonly scopes?: string
|
||||
/** "signin" (default) or "signup" — passed through to the backend. */
|
||||
readonly method?: 'signin' | 'signup'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provider authorize URL (pure; testable without navigating).
|
||||
*
|
||||
* `callbackOrigin` is the origin of the `/callback` that MUST be registered as
|
||||
* the provider's authorized redirect URI. The OAuth client (one per provider)
|
||||
* is registered against a SINGLE callback host — the IAM backend host
|
||||
* (`iam.hanzo.ai`) — shared across every brand portal, so the provider only
|
||||
* accepts that exact `redirect_uri`. Sending the browser's own origin (e.g.
|
||||
* `hanzo.id`) yields `redirect_uri_mismatch`. Callers pass the registered
|
||||
* origin; it defaults to `origin` for the single-host / local-dev case.
|
||||
*
|
||||
* `iam.hanzo.ai/callback` serves the SAME `@hanzo/id` SPA (the headless
|
||||
* `Callback` page — no login UI), which decodes the base64 `state` to recover
|
||||
* the original app's `redirect_uri`, exchanges the provider `code` at the IAM
|
||||
* backend, and forwards the browser back to the originating app.
|
||||
*/
|
||||
export function buildProviderAuthUrl(
|
||||
p: ProviderLoginParams,
|
||||
origin: string,
|
||||
search: string,
|
||||
callbackOrigin: string = origin,
|
||||
): string | null {
|
||||
const info = AUTH_INFO[p.type]
|
||||
if (!info || !p.clientId) return null
|
||||
const scope = p.scopes && p.scopes.trim() !== '' ? p.scopes : info.scope
|
||||
const redirectUri = `${callbackOrigin}/callback`
|
||||
const method = p.method ?? 'signin'
|
||||
// Base64 of the original OIDC query + routing — the backend decodes this on
|
||||
// the /callback return to complete the original authorize request.
|
||||
const state = btoa(`${search}&application=${encodeURIComponent(p.application)}&provider=${encodeURIComponent(p.providerName)}&method=${method}`)
|
||||
return `${info.endpoint}?client_id=${p.clientId}&redirect_uri=${redirectUri}&scope=${scope}&response_type=code&state=${state}`
|
||||
}
|
||||
|
||||
/** True when this portal knows how to start an OAuth hop for the given type. */
|
||||
export function isHoppableProvider(type: string): boolean {
|
||||
return type in AUTH_INFO
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the browser to the provider to begin login. No-op return on bad input.
|
||||
*
|
||||
* `callbackOrigin` (the provider's registered redirect host, e.g.
|
||||
* `https://iam.hanzo.ai`) defaults to the current origin when omitted.
|
||||
*/
|
||||
export function startProviderLogin(p: ProviderLoginParams, callbackOrigin?: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
const url = buildProviderAuthUrl(p, window.location.origin, window.location.search, callbackOrigin ?? window.location.origin)
|
||||
if (url) window.location.assign(url)
|
||||
}
|
||||
+110
-1
@@ -6,19 +6,79 @@ export interface LoginRequest {
|
||||
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 mfaChannel?: 'totp' | 'sms' | 'email'
|
||||
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 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
|
||||
@@ -96,3 +156,52 @@ export interface TokenResponse {
|
||||
readonly expiresIn?: number
|
||||
readonly scope?: string
|
||||
}
|
||||
|
||||
/** A social/web3 provider enabled on an IAM application. */
|
||||
export interface AppProvider {
|
||||
/** IAM provider record name, e.g. `provider-github`. */
|
||||
readonly name: string
|
||||
/** Normalized provider key passed to the authorize endpoint, e.g. `github`, `google`, `web3`. */
|
||||
readonly key: string
|
||||
/** Whether the provider may be used to sign in. */
|
||||
readonly canSignIn: boolean
|
||||
/** Whether the provider may be used to sign up. */
|
||||
readonly canSignUp: boolean
|
||||
/**
|
||||
* Whether IAM holds a real OAuth credential for this provider (a non-empty,
|
||||
* non-placeholder clientId). The login UI renders ONLY configured providers,
|
||||
* so an unprovisioned button never dead-ends the user — it appears
|
||||
* automatically once real credentials are seeded into IAM. The seed ships
|
||||
* obvious placeholders (`GITHUB_CLIENT_ID_PLACEHOLDER`, `placeholder`), which
|
||||
* read as not-configured.
|
||||
*/
|
||||
readonly configured: boolean
|
||||
/** IAM provider `type`, e.g. `GitHub` / `Google` / `Web3Onboard` (selects the OAuth endpoint). */
|
||||
readonly type: string
|
||||
/** The provider's OAuth client id (used to build the provider redirect; empty when unconfigured). */
|
||||
readonly clientId: string
|
||||
/** Override OAuth scopes, if the provider record sets them. */
|
||||
readonly scopes: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled-auth-methods view of an IAM application, read live from
|
||||
* `/v1/iam/get-app-login`. This is the canonical source of truth for which
|
||||
* buttons to render — it reflects the per-app config in `init_data.json`
|
||||
* (password + GitHub + Google + Web3). The portal renders exactly what IAM
|
||||
* reports enabled, so there is no client/server method drift.
|
||||
*/
|
||||
export interface AppLogin {
|
||||
/** IAM application name (e.g. `hanzo-id`). */
|
||||
readonly application: string
|
||||
/** Owning organization slug. */
|
||||
readonly organization: string
|
||||
/** Email/username + password sign-in is enabled. */
|
||||
readonly enablePassword: boolean
|
||||
/** Self-service signup is enabled. */
|
||||
readonly enableSignUp: boolean
|
||||
/** Email/SMS verification-code sign-in is enabled. */
|
||||
readonly enableCodeSignin: boolean
|
||||
/** Social + Web3 providers enabled on the app, in display order. */
|
||||
readonly providers: readonly AppProvider[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Labeled horizontal rule, e.g. "or", separating social from email sign-in. */
|
||||
export function Divider({ label = 'or' }: { label?: string }) {
|
||||
return (
|
||||
<div className="hanzo-id-divider" role="separator" aria-label={label}>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+37
-166
@@ -1,58 +1,26 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { AppLoginInfo, LoginResponse } from '../types'
|
||||
import { ProviderButtons } from './ProviderButtons'
|
||||
import { OTPForm } from './OTPForm'
|
||||
import type { LoginResponse } from '../types'
|
||||
|
||||
export interface LoginFormProps {
|
||||
readonly client: AuthClient
|
||||
readonly redirectUri?: string
|
||||
readonly state?: string
|
||||
readonly clientIdOverride?: string
|
||||
readonly codeChallenge?: string
|
||||
readonly codeChallengeMethod?: 'S256' | 'plain'
|
||||
readonly onSuccess?: (res: LoginResponse) => void
|
||||
readonly onMfaRequired?: (res: LoginResponse) => void
|
||||
}
|
||||
|
||||
export function LoginForm(props: LoginFormProps) {
|
||||
const { client } = props
|
||||
const [app, setApp] = useState<AppLoginInfo | null>(null)
|
||||
const [mode, setMode] = useState<'password' | 'code'>('password')
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
// Load the application's providers + sign-in methods so we render exactly
|
||||
// what IAM offers (social buttons, email/SMS code). Best-effort: on failure
|
||||
// we still show password login.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
client
|
||||
.appLogin()
|
||||
.then((a) => {
|
||||
if (alive) setApp(a)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const codeEnabled =
|
||||
!!app &&
|
||||
(app.enableCodeSignin ||
|
||||
app.signinMethods.some((m) => m.name === 'Verification code' && m.rule !== 'None'))
|
||||
|
||||
function handleResult(res: LoginResponse) {
|
||||
if (res.error) setError(res.error)
|
||||
else if (res.mfaRequired) props.onMfaRequired?.(res)
|
||||
else if (res.redirectUrl) window.location.href = res.redirectUrl
|
||||
else props.onSuccess?.(res)
|
||||
}
|
||||
|
||||
async function onPasswordSubmit(e: FormEvent) {
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
@@ -65,27 +33,17 @@ export function LoginForm(props: LoginFormProps) {
|
||||
organization: client.tenant.orgId,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
codeChallenge: props.codeChallenge,
|
||||
codeChallengeMethod: props.codeChallengeMethod,
|
||||
})
|
||||
handleResult(res)
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSendCode(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const r = await client.sendLoginCode(identifier)
|
||||
if (r.ok) {
|
||||
setCodeSent(true)
|
||||
setNotice(`Code sent to ${identifier}`)
|
||||
if (res.error) {
|
||||
setError(res.error)
|
||||
} else if (res.mfaRequired) {
|
||||
props.onMfaRequired?.(res)
|
||||
} else if (res.redirectUrl) {
|
||||
window.location.href = res.redirectUrl
|
||||
} else {
|
||||
setError(r.error ?? 'Could not send code')
|
||||
props.onSuccess?.(res)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
@@ -94,117 +52,30 @@ export function LoginForm(props: LoginFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onVerifyCode(code: string) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await client.loginWithCode({
|
||||
dest: identifier,
|
||||
code,
|
||||
clientId: props.clientIdOverride ?? client.tenant.clientId,
|
||||
application: client.tenant.appName,
|
||||
organization: client.tenant.orgId,
|
||||
redirectUri: props.redirectUri,
|
||||
state: props.state,
|
||||
})
|
||||
handleResult(res)
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-login">
|
||||
{app && app.providers.length > 0 ? (
|
||||
<ProviderButtons
|
||||
client={client}
|
||||
providers={app.providers}
|
||||
mode="login"
|
||||
redirectUri={props.redirectUri}
|
||||
state={props.state}
|
||||
clientIdOverride={props.clientIdOverride}
|
||||
<form onSubmit={onSubmit} className="hanzo-id-login-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email or username</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{mode === 'password' ? (
|
||||
<form onSubmit={onPasswordSubmit} className="hanzo-id-login-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email or username</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="hanzo-id-code-login">
|
||||
{!codeSent ? (
|
||||
<form onSubmit={onSendCode} className="hanzo-id-login-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email or phone</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="you@example.com or +1 555 555 5555"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Sending…' : 'Send code'}</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
{notice ? <p className="hanzo-id-notice">{notice}</p> : null}
|
||||
<OTPForm channel={identifier.includes('@') ? 'email' : 'sms'} onSubmit={onVerifyCode} />
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-linkbtn"
|
||||
onClick={() => {
|
||||
setCodeSent(false)
|
||||
setNotice(null)
|
||||
}}
|
||||
>
|
||||
Use a different address
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{codeEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hanzo-id-linkbtn hanzo-id-toggle-mode"
|
||||
onClick={() => {
|
||||
setMode(mode === 'password' ? 'code' : 'password')
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
setCodeSent(false)
|
||||
}}
|
||||
>
|
||||
{mode === 'password' ? 'Sign in with email or SMS code' : 'Sign in with password'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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,4 +1,5 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { SmsConsentNotice } from './SmsConsent'
|
||||
|
||||
export interface OTPFormProps {
|
||||
readonly onSubmit: (code: string) => void | Promise<void>
|
||||
@@ -39,6 +40,7 @@ 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,7 +1,5 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { AppLoginInfo } from '../types'
|
||||
import { ProviderButtons } from './ProviderButtons'
|
||||
|
||||
export interface SignupFormProps {
|
||||
readonly client: AuthClient
|
||||
@@ -11,25 +9,11 @@ export interface SignupFormProps {
|
||||
|
||||
export function SignupForm(props: SignupFormProps) {
|
||||
const { client } = props
|
||||
const [app, setApp] = useState<AppLoginInfo | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
client
|
||||
.appLogin()
|
||||
.then((a) => {
|
||||
if (alive) setApp(a)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [client])
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
@@ -54,29 +38,24 @@ export function SignupForm(props: SignupFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-signup">
|
||||
{app && app.providers.length > 0 ? (
|
||||
<ProviderButtons client={client} providers={app.providers} mode="signup" />
|
||||
) : null}
|
||||
<form onSubmit={onSubmit} className="hanzo-id-signup-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
|
||||
</form>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="hanzo-id-signup-form" aria-busy={busy}>
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<button type="submit" disabled={busy}>{busy ? 'Creating account…' : 'Create account'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import type { AuthClient } from '../client'
|
||||
import type { AppProvider } from '../types'
|
||||
import { createIam } from '../iam'
|
||||
import { startProviderLogin, isHoppableProvider } from '../social'
|
||||
import { GitHubIcon, GoogleIcon, WalletIcon } from './icons'
|
||||
import { Divider } from './Divider'
|
||||
|
||||
/**
|
||||
* Social + Web3 sign-in buttons.
|
||||
*
|
||||
* The enabled set is read live from `/v1/iam/get-app-login` (via
|
||||
* `client.getAppLogin()`) — the canonical source of truth that mirrors the
|
||||
* per-app provider config in `init_data.json`. We render ONLY providers IAM
|
||||
* holds real credentials for (`AppProvider.configured`); a provider seeded with
|
||||
* placeholder creds is hidden so its button never dead-ends, and reappears once
|
||||
* real creds land. When the config is unreadable we render none.
|
||||
*
|
||||
* Each OAuth button drives the provider "hop" (`startProviderLogin`) — it
|
||||
* redirects straight to GitHub/Google with a Casdoor-compatible state that
|
||||
* round-trips the original authorize request, so the IAM backend's `/callback`
|
||||
* exchange completes it. (Web3/wallet falls back to the `@hanzo/iam` redirect.)
|
||||
*/
|
||||
export interface SocialButtonsProps {
|
||||
readonly client: AuthClient
|
||||
/** Override the OAuth client_id (e.g. a downstream app's id). */
|
||||
readonly clientIdOverride?: string
|
||||
/** "signin" (default) or "signup" — only changes button copy. */
|
||||
readonly intent?: 'signin' | 'signup'
|
||||
/**
|
||||
* Downstream app's `redirect_uri`, if this portal is mid-flow for another
|
||||
* app. Social/Web3 sign-in always returns to the portal's own `/callback`
|
||||
* (the SDK's fixed redirectUri), so we stash this target before the
|
||||
* redirect; `Callback` reads it back and forwards the tokens there. Absent
|
||||
* → a bare portal sign-in that lands on onboarding.
|
||||
*/
|
||||
readonly postLoginRedirect?: string
|
||||
}
|
||||
|
||||
interface ProviderMeta {
|
||||
readonly key: string
|
||||
readonly label: string
|
||||
readonly Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
|
||||
/** Display metadata for the providers the portal knows how to render. */
|
||||
const PROVIDER_META: Record<string, ProviderMeta> = {
|
||||
github: { key: 'github', label: 'GitHub', Icon: GitHubIcon },
|
||||
google: { key: 'google', label: 'Google', Icon: GoogleIcon },
|
||||
web3: { key: 'web3', label: 'Wallet', Icon: WalletIcon },
|
||||
}
|
||||
|
||||
/** Canonical render order. */
|
||||
const ORDER = ['github', 'google', 'web3']
|
||||
|
||||
interface Resolved {
|
||||
/** IAM application name (for the provider-hop state). */
|
||||
readonly application: string
|
||||
/** Configured + renderable providers, keyed by their normalized key. */
|
||||
readonly providers: Record<string, AppProvider>
|
||||
}
|
||||
|
||||
export function SocialButtons({
|
||||
client,
|
||||
clientIdOverride,
|
||||
intent = 'signin',
|
||||
postLoginRedirect,
|
||||
}: SocialButtonsProps) {
|
||||
const [resolved, setResolved] = useState<Resolved | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
client
|
||||
.getAppLogin(clientIdOverride)
|
||||
.then((app) => {
|
||||
if (cancelled) return
|
||||
if (!app) {
|
||||
// Can't read the app config → render no social rather than risk a
|
||||
// dead-end button. Password / email-code still render.
|
||||
setResolved({ application: '', providers: {} })
|
||||
return
|
||||
}
|
||||
const want = intent === 'signup' ? (p: AppProvider) => p.canSignUp : (p: AppProvider) => p.canSignIn
|
||||
// Render ONLY providers IAM actually holds credentials for. A provider
|
||||
// with placeholder/empty creds would dead-end the OAuth redirect, so we
|
||||
// hide it; it reappears automatically once real creds are seeded.
|
||||
const providers: Record<string, AppProvider> = {}
|
||||
for (const p of app.providers) {
|
||||
if (want(p) && p.configured && p.key in PROVIDER_META) providers[p.key] = p
|
||||
}
|
||||
setResolved({ application: app.application, providers })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResolved({ application: '', providers: {} })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client, clientIdOverride, intent])
|
||||
|
||||
if (resolved === null) return null // resolving — render nothing rather than flicker
|
||||
const ordered = ORDER.filter((k) => k in resolved.providers)
|
||||
if (ordered.length === 0) return null
|
||||
|
||||
const verb = intent === 'signup' ? 'Sign up' : 'Continue'
|
||||
|
||||
function start(provider: AppProvider) {
|
||||
setError(null)
|
||||
// Persist the downstream target across the IAM round-trip; `Callback`
|
||||
// reads it back and forwards tokens there (else lands on onboarding).
|
||||
if (postLoginRedirect) sessionStorage.setItem('post_login_redirect', postLoginRedirect)
|
||||
else sessionStorage.removeItem('post_login_redirect')
|
||||
const method = intent === 'signup' ? 'signup' : 'signin'
|
||||
// OAuth providers (github/google) hop straight to the provider; wallet/web3
|
||||
// falls back to the @hanzo/iam redirect.
|
||||
if (isHoppableProvider(provider.type)) {
|
||||
startProviderLogin(
|
||||
{
|
||||
application: resolved!.application,
|
||||
providerName: provider.name,
|
||||
type: provider.type,
|
||||
clientId: provider.clientId,
|
||||
scopes: provider.scopes,
|
||||
method,
|
||||
},
|
||||
// The shared OAuth client is registered against the IAM backend's
|
||||
// /callback (not this brand host), so the hop must return there or the
|
||||
// provider rejects the redirect_uri. Catalog-driven; defaults to host.
|
||||
client.tenant.oauthCallbackOrigin,
|
||||
)
|
||||
return
|
||||
}
|
||||
const iam = createIam(client.tenant, clientIdOverride)
|
||||
iam.signinRedirect({ additionalParams: { provider: provider.key } }).catch((e) => {
|
||||
setError(String(e))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hanzo-id-social">
|
||||
{ordered.map((k) => {
|
||||
const meta = PROVIDER_META[k]
|
||||
const { Icon } = meta
|
||||
const provider = resolved.providers[k]!
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="hanzo-id-social-btn"
|
||||
data-provider={k}
|
||||
onClick={() => start(provider)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{verb} with {meta.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
</div>
|
||||
{/* The "or" separator belongs WITH the social block — render it only when
|
||||
there are buttons, so it never dangles above the password form when
|
||||
no providers are configured. */}
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Minimal inline provider marks. Brand-neutral, currentColor-driven, no
|
||||
* external icon dependency. One 18px glyph per supported sign-in provider.
|
||||
*/
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
const base = (props: SVGProps<SVGSVGElement>) => ({
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: '0 0 24 24',
|
||||
'aria-hidden': true,
|
||||
focusable: false as const,
|
||||
...props,
|
||||
})
|
||||
|
||||
export function GitHubIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="currentColor">
|
||||
<path d="M12 .5C5.73.5.5 5.73.5 12a11.5 11.5 0 0 0 7.86 10.92c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.53-1.34-1.3-1.7-1.3-1.7-1.05-.72.08-.7.08-.7 1.17.08 1.78 1.2 1.78 1.2 1.04 1.78 2.73 1.27 3.4.97.1-.75.4-1.27.73-1.56-2.56-.29-5.26-1.28-5.26-5.7 0-1.26.45-2.29 1.2-3.1-.12-.3-.52-1.48.11-3.08 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.8 0c2.2-1.5 3.17-1.18 3.17-1.18.63 1.6.23 2.78.11 3.08.75.81 1.2 1.84 1.2 3.1 0 4.43-2.7 5.4-5.28 5.69.42.36.79 1.07.79 2.16v3.2c0 .31.21.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)}>
|
||||
<path fill="#4285F4" d="M23.52 12.27c0-.82-.07-1.6-.21-2.36H12v4.46h6.46a5.52 5.52 0 0 1-2.4 3.62v3h3.88c2.27-2.09 3.58-5.17 3.58-8.72Z" />
|
||||
<path fill="#34A853" d="M12 24c3.24 0 5.96-1.08 7.94-2.91l-3.88-3c-1.08.72-2.45 1.15-4.06 1.15-3.12 0-5.77-2.11-6.71-4.95H1.28v3.1A12 12 0 0 0 12 24Z" />
|
||||
<path fill="#FBBC05" d="M5.29 14.29A7.2 7.2 0 0 1 4.91 12c0-.8.14-1.57.38-2.29v-3.1H1.28A12 12 0 0 0 0 12c0 1.94.46 3.77 1.28 5.39l4.01-3.1Z" />
|
||||
<path fill="#EA4335" d="M12 4.76c1.76 0 3.34.61 4.58 1.8l3.43-3.43A11.99 11.99 0 0 0 12 0 12 12 0 0 0 1.28 6.61l4.01 3.1C6.23 6.87 8.88 4.76 12 4.76Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...base(props)} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1H5a2 2 0 0 0-2 2V7Z" />
|
||||
<path d="M3 9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9Z" />
|
||||
<circle cx="16.5" cy="13" r="1.25" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -2,4 +2,7 @@ export { LoginForm } from './LoginForm'
|
||||
export { SignupForm } from './SignupForm'
|
||||
export { ForgotForm } from './ForgotForm'
|
||||
export { OTPForm } from './OTPForm'
|
||||
export { ProviderButtons } from './ProviderButtons'
|
||||
export { MfaEnrollForm, type MfaEnrollFormProps } from './MfaEnrollForm'
|
||||
export { SmsConsentNotice, SMS_CONSENT_TEXT } from './SmsConsent'
|
||||
export { SocialButtons, type SocialButtonsProps } from './SocialButtons'
|
||||
export { Divider } from './Divider'
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@hanzo/id-onboarding",
|
||||
"version": "0.1.0",
|
||||
"description": "Post-login onboarding for the Hanzo ID portal: choose/create org → optional project → optional wallet link. White-labeled by host. Domain / service / UI split.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./service": "./src/service/onboarding.ts",
|
||||
"./flow": "./src/ui/OnboardingFlow.tsx",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["src", "!src/**/*.test.ts"],
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"tc": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/id-shared": "workspace:*",
|
||||
"@hanzo/iam": "^0.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
"react-dom": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Onboarding domain types — React-free, serializable.
|
||||
*
|
||||
* The post-login onboarding is a three-step linear flow:
|
||||
*
|
||||
* 1. org — choose an existing org the user already belongs to, or
|
||||
* create a new one. Required (every account needs a home org).
|
||||
* 2. project — create a first project inside the chosen org. Optional
|
||||
* (skippable; the org ships with a default project).
|
||||
* 3. wallet — link a Web3 wallet to the account. Optional (skippable).
|
||||
*
|
||||
* The flow is declared as data here so the UI layer can render it without
|
||||
* the domain importing React. `OnboardingService` (the service layer) does
|
||||
* the actual IAM writes; this module only describes the shape of the flow
|
||||
* and its accumulated state.
|
||||
*/
|
||||
|
||||
/** Identifier for each step in the onboarding flow. */
|
||||
export type StepId = 'org' | 'project' | 'wallet' | 'done'
|
||||
|
||||
/** A step's place in the linear flow. */
|
||||
export interface StepDesc {
|
||||
readonly id: StepId
|
||||
/** Heading shown at the top of the step. */
|
||||
readonly title: string
|
||||
/** One-line subhead under the title. */
|
||||
readonly byline: string
|
||||
/** Whether the user may skip this step (Continue without acting). */
|
||||
readonly skippable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical step sequence. `done` is a terminal pseudo-step the flow
|
||||
* lands on after `wallet`; it renders the success state and hands control
|
||||
* back to the host via `onComplete`.
|
||||
*/
|
||||
export const STEPS: readonly StepDesc[] = [
|
||||
{
|
||||
id: 'org',
|
||||
title: 'Choose your organization',
|
||||
byline: 'Pick an organization you belong to, or create a new one.',
|
||||
skippable: false,
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
title: 'Create your first project',
|
||||
byline: 'Projects group your apps, keys, and usage. You can add more later.',
|
||||
skippable: true,
|
||||
},
|
||||
{
|
||||
id: 'wallet',
|
||||
title: 'Link a wallet',
|
||||
byline: 'Connect a Web3 wallet to sign and pay onchain. Optional.',
|
||||
skippable: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** A minimal org reference the UI lists in the "choose org" step. */
|
||||
export interface OrgRef {
|
||||
/** Casdoor org slug (the `<org>` in `<org>-<app>`). */
|
||||
readonly name: string
|
||||
/** Human-facing name; falls back to `name` when unset. */
|
||||
readonly displayName: string
|
||||
}
|
||||
|
||||
/** A minimal project reference returned after creation. */
|
||||
export interface ProjectRef {
|
||||
readonly owner: string
|
||||
readonly name: string
|
||||
readonly displayName: string
|
||||
readonly organization: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulated flow state. Each step writes its result here; the success
|
||||
* screen and `onComplete` read it. Serializable so the host can persist a
|
||||
* resume point if it wants (this pkg does not persist on its own).
|
||||
*/
|
||||
export interface OnboardingState {
|
||||
/** Slug of the org the user landed in (chosen or created). */
|
||||
readonly orgName?: string
|
||||
/** Whether the org was freshly created in this flow (vs. pre-existing). */
|
||||
readonly orgCreated?: boolean
|
||||
/** Name of the project created in step 2, if any. */
|
||||
readonly projectName?: string
|
||||
/** Wallet address linked in step 3, if any. */
|
||||
readonly walletAddress?: string
|
||||
}
|
||||
|
||||
/** Resolve a step descriptor by id. */
|
||||
export function stepById(id: StepId): StepDesc | undefined {
|
||||
return STEPS.find((s) => s.id === id)
|
||||
}
|
||||
|
||||
/** The step that follows `id` in the linear flow (`done` is terminal). */
|
||||
export function nextStep(id: StepId): StepId {
|
||||
if (id === 'done') return 'done'
|
||||
const i = STEPS.findIndex((s) => s.id === id)
|
||||
if (i < 0 || i + 1 >= STEPS.length) return 'done'
|
||||
return STEPS[i + 1]!.id
|
||||
}
|
||||
|
||||
/** The step that precedes `id`, or undefined at the first step. */
|
||||
export function prevStep(id: StepId): StepId | undefined {
|
||||
const i = STEPS.findIndex((s) => s.id === id)
|
||||
if (i <= 0) return undefined
|
||||
return STEPS[i - 1]!.id
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// @hanzo/id-onboarding — post-login onboarding for the Hanzo ID portal.
|
||||
//
|
||||
// Three-step flow: choose/create org → optional project → optional wallet
|
||||
// link. White-labeled by the host's brand name. Domain (serializable types +
|
||||
// step machine) / service (IAM-backed writes) / UI (self-contained flow)
|
||||
// split. Auth lives in @hanzo/id-auth — import login/signup from there.
|
||||
|
||||
// ── Domain ──────────────────────────────────────────────────────
|
||||
export {
|
||||
STEPS,
|
||||
stepById,
|
||||
nextStep,
|
||||
prevStep,
|
||||
type StepId,
|
||||
type StepDesc,
|
||||
type OrgRef,
|
||||
type ProjectRef,
|
||||
type OnboardingState,
|
||||
} from './domain/types'
|
||||
|
||||
// ── Service ─────────────────────────────────────────────────────
|
||||
export {
|
||||
createOnboardingService,
|
||||
type OnboardingService,
|
||||
type OnboardingServiceOptions,
|
||||
type Result,
|
||||
} from './service/onboarding'
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────────────
|
||||
export { OnboardingFlow, type OnboardingFlowProps } from './ui/OnboardingFlow'
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Onboarding unit tests — run with the Node built-in test runner and native
|
||||
* TypeScript stripping (no test-framework dependency):
|
||||
*
|
||||
* node --test --experimental-strip-types src/onboarding.test.ts
|
||||
*
|
||||
* Covers the React-free surface: the domain step machine and the service's
|
||||
* request shaping + IAM response translation (with an injected fake fetch).
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { STEPS, stepById, nextStep, prevStep } from './domain/types.ts'
|
||||
import { createOnboardingService } from './service/onboarding.ts'
|
||||
|
||||
// ── Domain: step machine ────────────────────────────────────────────
|
||||
|
||||
test('step machine walks org → project → wallet → done', () => {
|
||||
assert.equal(STEPS[0]!.id, 'org')
|
||||
assert.equal(nextStep('org'), 'project')
|
||||
assert.equal(nextStep('project'), 'wallet')
|
||||
assert.equal(nextStep('wallet'), 'done')
|
||||
assert.equal(nextStep('done'), 'done') // terminal is a fixpoint
|
||||
})
|
||||
|
||||
test('prevStep is the inverse within the flow, undefined at the head', () => {
|
||||
assert.equal(prevStep('org'), undefined)
|
||||
assert.equal(prevStep('project'), 'org')
|
||||
assert.equal(prevStep('wallet'), 'project')
|
||||
})
|
||||
|
||||
test('only org is required; project and wallet are skippable', () => {
|
||||
assert.equal(stepById('org')!.skippable, false)
|
||||
assert.equal(stepById('project')!.skippable, true)
|
||||
assert.equal(stepById('wallet')!.skippable, true)
|
||||
})
|
||||
|
||||
// ── Service: fake-fetch harness ─────────────────────────────────────
|
||||
|
||||
interface Recorded {
|
||||
url: string
|
||||
method: string
|
||||
body?: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
/** Build a service whose fetch records calls and returns scripted JSON. */
|
||||
function harness(script: (rec: Recorded) => { status?: number; json: unknown }) {
|
||||
const calls: Recorded[] = []
|
||||
const fetchImpl = (async (input: string | URL, init?: RequestInit) => {
|
||||
const headers: Record<string, string> = {}
|
||||
const h = init?.headers as Record<string, string> | undefined
|
||||
if (h) for (const k of Object.keys(h)) headers[k] = h[k]!
|
||||
const rec: Recorded = {
|
||||
url: String(input),
|
||||
method: init?.method ?? 'GET',
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
headers,
|
||||
}
|
||||
calls.push(rec)
|
||||
const { status = 200, json } = script(rec)
|
||||
return new Response(JSON.stringify(json), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const service = createOnboardingService({
|
||||
iamUrl: 'https://hanzo.id',
|
||||
orgId: 'hanzo',
|
||||
getAccessToken: () => 'tok-123',
|
||||
fetchImpl,
|
||||
})
|
||||
return { service, calls }
|
||||
}
|
||||
|
||||
test('listOrgs hits get-organizations with the bearer token and maps rows', async () => {
|
||||
const { service, calls } = harness(() => ({
|
||||
json: { status: 'ok', data: [{ name: 'hanzo', displayName: 'Hanzo' }, { name: 'acme' }] },
|
||||
}))
|
||||
const orgs = await service.listOrgs()
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/get-organizations')
|
||||
assert.equal(calls[0]!.headers.Authorization, 'Bearer tok-123')
|
||||
assert.deepEqual(orgs, [
|
||||
{ name: 'hanzo', displayName: 'Hanzo' },
|
||||
{ name: 'acme', displayName: 'acme' }, // displayName falls back to name
|
||||
])
|
||||
})
|
||||
|
||||
test('listOrgs returns [] (not throw) on a server error', async () => {
|
||||
const { service } = harness(() => ({ status: 500, json: { status: 'error', msg: 'boom' } }))
|
||||
assert.deepEqual(await service.listOrgs(), [])
|
||||
})
|
||||
|
||||
test('createOrg posts the org and reports IAM error messages', async () => {
|
||||
const ok = harness(() => ({ json: { status: 'ok' } }))
|
||||
const res = await ok.service.createOrg({ name: 'acme', displayName: 'Acme Inc' })
|
||||
assert.equal(ok.calls[0]!.url, 'https://hanzo.id/v1/iam/add-organization')
|
||||
assert.equal(ok.calls[0]!.method, 'POST')
|
||||
const sent = JSON.parse(ok.calls[0]!.body!)
|
||||
assert.equal(sent.name, 'acme')
|
||||
assert.equal(sent.displayName, 'Acme Inc')
|
||||
assert.deepEqual(res, { ok: true, value: { name: 'acme', displayName: 'Acme Inc' } })
|
||||
|
||||
const denied = harness(() => ({ status: 403, json: { status: 'error', msg: 'permission denied' } }))
|
||||
const fail = await denied.service.createOrg({ name: 'x', displayName: 'X' })
|
||||
assert.deepEqual(fail, { ok: false, error: 'HTTP 403' })
|
||||
})
|
||||
|
||||
test('linkWallet rejects a malformed address before any network call', async () => {
|
||||
const { service, calls } = harness(() => ({ json: { status: 'ok' } }))
|
||||
const res = await service.linkWallet('not-an-address')
|
||||
assert.deepEqual(res, { ok: false, error: 'invalid wallet address' })
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('linkWallet resolves the user via get-account then writes web3onboard', async () => {
|
||||
const addr = '0x' + 'a'.repeat(40)
|
||||
const { service, calls } = harness((rec) => {
|
||||
if (rec.url.includes('get-account')) return { json: { status: 'ok', data: { owner: 'hanzo', name: 'alice' } } }
|
||||
return { json: { status: 'ok' } }
|
||||
})
|
||||
const res = await service.linkWallet(addr)
|
||||
assert.deepEqual(res, { ok: true, value: addr })
|
||||
// 1) get-account, 2) update-user keyed by owner/name, column-scoped
|
||||
assert.match(calls[0]!.url, /get-account$/)
|
||||
const upd = calls[1]!
|
||||
assert.ok(upd.url.includes('/v1/iam/update-user'))
|
||||
assert.ok(upd.url.includes('id=hanzo%2Falice') || upd.url.includes('id=hanzo/alice'))
|
||||
assert.ok(upd.url.includes('columns=web3onboard'))
|
||||
const sent = JSON.parse(upd.body!)
|
||||
assert.equal(sent.web3onboard, addr)
|
||||
assert.equal(sent.owner, 'hanzo')
|
||||
assert.equal(sent.name, 'alice')
|
||||
})
|
||||
|
||||
test('linkWallet fails closed when there is no signed-in user', async () => {
|
||||
const addr = '0x' + 'b'.repeat(40)
|
||||
const { service } = harness((rec) => {
|
||||
if (rec.url.includes('get-account')) return { status: 401, json: { status: 'error', msg: 'not signed in' } }
|
||||
return { json: { status: 'ok' } }
|
||||
})
|
||||
assert.deepEqual(await service.linkWallet(addr), { ok: false, error: 'not signed in' })
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Onboarding service — the IAM-backed implementation of the org/project/
|
||||
* wallet flow.
|
||||
*
|
||||
* One way: every write goes through the canonical IAM REST surface under
|
||||
* `/v1/iam/*` (the same Casdoor-compat paths the auth client uses), carrying
|
||||
* the user's bearer token. There is no separate onboarding backend — the org
|
||||
* and project records live in IAM, which is the identity registry.
|
||||
*
|
||||
* listOrgs() GET /v1/iam/get-organizations (user-scoped server-side)
|
||||
* createOrg() POST /v1/iam/add-organization
|
||||
* createProject POST /v1/iam/add-project
|
||||
* linkWallet() client-side wallet connect → IAM update-user (host-driven)
|
||||
*
|
||||
* Token is supplied by the host through `getAccessToken` (the portal already
|
||||
* holds the session after login). The service never stores it.
|
||||
*/
|
||||
import type { Organization, Project } from '@hanzo/iam'
|
||||
import type { OrgRef, ProjectRef } from '../domain/types'
|
||||
|
||||
/** Result of a write that can fail gracefully (no throw on expected errors). */
|
||||
export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }
|
||||
|
||||
export interface OnboardingService {
|
||||
/**
|
||||
* List organizations the signed-in user can land in. IAM scopes
|
||||
* `get-organizations` to the caller's memberships server-side from the
|
||||
* bearer token. Returns [] (not an error) when the user belongs to none.
|
||||
*/
|
||||
listOrgs(): Promise<OrgRef[]>
|
||||
/** Create a new organization owned by the user. */
|
||||
createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>>
|
||||
/** Create a project inside `organization`. */
|
||||
createProject(input: { organization: string; name: string; displayName: string }): Promise<Result<ProjectRef>>
|
||||
/**
|
||||
* Attach a wallet address to the signed-in user (IAM `update-user`,
|
||||
* `web3Onboard` address field). The actual wallet connect happens in the
|
||||
* browser via the host-supplied `connectWallet`; this only persists the
|
||||
* resulting address.
|
||||
*/
|
||||
linkWallet(address: string): Promise<Result<string>>
|
||||
}
|
||||
|
||||
export interface OnboardingServiceOptions {
|
||||
/** IAM origin, no trailing slash (the tenant's `iamUrl`, i.e. hanzo.id). */
|
||||
readonly iamUrl: string
|
||||
/** Owning org slug used as the default `owner` for new records. */
|
||||
readonly orgId: string
|
||||
/** Bearer-token provider; resolves null when no session is present. */
|
||||
readonly getAccessToken: () => Promise<string | null> | string | null
|
||||
/** Override fetch (testing). Defaults to global fetch. */
|
||||
readonly fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
const trimSlash = (s: string): string => s.replace(/\/+$/, '')
|
||||
|
||||
export function createOnboardingService(opts: OnboardingServiceOptions): OnboardingService {
|
||||
const base = trimSlash(opts.iamUrl)
|
||||
const f = opts.fetchImpl ?? fetch
|
||||
|
||||
async function authHeaders(json = true): Promise<HeadersInit> {
|
||||
const token = await opts.getAccessToken()
|
||||
const h: Record<string, string> = { Accept: 'application/json' }
|
||||
if (json) h['Content-Type'] = 'application/json'
|
||||
if (token) h.Authorization = `Bearer ${token}`
|
||||
return h
|
||||
}
|
||||
|
||||
async function listOrgs(): Promise<OrgRef[]> {
|
||||
const url = new URL('/v1/iam/get-organizations', base)
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: await authHeaders(false), credentials: 'include' })
|
||||
if (!res.ok) return []
|
||||
body = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const rows = extractRows(body)
|
||||
return rows.map(toOrgRef).filter((o): o is OrgRef => o !== null)
|
||||
}
|
||||
|
||||
async function createOrg(input: { name: string; displayName: string }): Promise<Result<OrgRef>> {
|
||||
const url = new URL('/v1/iam/add-organization', base)
|
||||
const org: Partial<Organization> = {
|
||||
owner: 'admin',
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
isPersonal: false,
|
||||
balanceCurrency: 'USD',
|
||||
}
|
||||
return writeRecord(url, org, () => ({ name: input.name, displayName: input.displayName }))
|
||||
}
|
||||
|
||||
async function createProject(input: {
|
||||
organization: string
|
||||
name: string
|
||||
displayName: string
|
||||
}): Promise<Result<ProjectRef>> {
|
||||
const url = new URL('/v1/iam/add-project', base)
|
||||
const project: Partial<Project> = {
|
||||
owner: input.organization,
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
organization: input.organization,
|
||||
isDefault: false,
|
||||
}
|
||||
return writeRecord(url, project, () => ({
|
||||
owner: input.organization,
|
||||
name: input.name,
|
||||
displayName: input.displayName,
|
||||
organization: input.organization,
|
||||
}))
|
||||
}
|
||||
|
||||
async function linkWallet(address: string): Promise<Result<string>> {
|
||||
const trimmed = address.trim()
|
||||
if (!isHexAddress(trimmed)) return { ok: false, error: 'invalid wallet address' }
|
||||
// Resolve the signed-in user (owner/name) from the session — IAM's
|
||||
// update-user is keyed by `id=<owner>/<name>`, not a "self" alias.
|
||||
const account = await getAccount()
|
||||
if (!account) return { ok: false, error: 'not signed in' }
|
||||
const url = new URL('/v1/iam/update-user', base)
|
||||
url.searchParams.set('id', `${account.owner}/${account.name}`)
|
||||
// Scope the write to the single `web3onboard` column so the rest of the
|
||||
// user row is untouched (Casdoor replaces unscoped writes wholesale).
|
||||
url.searchParams.set('columns', 'web3onboard')
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
// Casdoor's User JSON tag is lowercase `web3onboard`; send the full
|
||||
// owner/name so the row identity is unambiguous on the server.
|
||||
body: JSON.stringify({ owner: account.owner, name: account.name, web3onboard: trimmed }),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: msgOf(body) }
|
||||
return { ok: true, value: trimmed }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the signed-in user's `{owner, name}` from `/v1/iam/get-account`. */
|
||||
async function getAccount(): Promise<{ owner: string; name: string } | null> {
|
||||
const url = new URL('/v1/iam/get-account', base)
|
||||
try {
|
||||
const res = await f(url.toString(), { headers: await authHeaders(false), credentials: 'include' })
|
||||
if (!res.ok) return null
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
const data = (body.data ?? body) as Record<string, unknown>
|
||||
const owner = typeof data.owner === 'string' ? data.owner : ''
|
||||
const name = typeof data.name === 'string' ? data.name : ''
|
||||
if (!owner || !name) return null
|
||||
return { owner, name }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRecord<T>(
|
||||
url: URL,
|
||||
payload: unknown,
|
||||
onOk: () => T,
|
||||
): Promise<Result<T>> {
|
||||
try {
|
||||
const res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: await authHeaders(),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (body.status === 'error') return { ok: false, error: msgOf(body) }
|
||||
return { ok: true, value: onOk() }
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
return { listOrgs, createOrg, createProject, linkWallet }
|
||||
}
|
||||
|
||||
/** Pull the array payload out of an IAM list response (`data` or `data2`). */
|
||||
function extractRows(body: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const candidate = Array.isArray(body.data) ? body.data : Array.isArray(body.data2) ? body.data2 : []
|
||||
return candidate.filter((r): r is Record<string, unknown> => typeof r === 'object' && r !== null)
|
||||
}
|
||||
|
||||
function toOrgRef(row: Record<string, unknown>): OrgRef | null {
|
||||
const name = typeof row.name === 'string' ? row.name : ''
|
||||
if (!name) return null
|
||||
const displayName = typeof row.displayName === 'string' && row.displayName ? row.displayName : name
|
||||
return { name, displayName }
|
||||
}
|
||||
|
||||
function msgOf(body: Record<string, unknown>): string {
|
||||
return typeof body.msg === 'string' && body.msg ? body.msg : 'request failed'
|
||||
}
|
||||
|
||||
/** EIP-55-agnostic 0x-prefixed 20-byte address check. */
|
||||
function isHexAddress(s: string): boolean {
|
||||
return /^0x[0-9a-fA-F]{40}$/.test(s)
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import { useCallback, useEffect, useReducer, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
STEPS,
|
||||
nextStep,
|
||||
prevStep,
|
||||
stepById,
|
||||
type OnboardingState,
|
||||
type OrgRef,
|
||||
type StepId,
|
||||
} from '../domain/types'
|
||||
import type { OnboardingService } from '../service/onboarding'
|
||||
|
||||
/**
|
||||
* Post-login onboarding flow.
|
||||
*
|
||||
* A self-contained three-step wizard (org → project → wallet) driven by an
|
||||
* internal step machine — no router lib, consistent with the rest of the
|
||||
* portal which routes on `window.location` and keeps page-local state in
|
||||
* React. The host renders this once after login and gets the accumulated
|
||||
* {@link OnboardingState} back via `onComplete`.
|
||||
*
|
||||
* White-label: all copy comes from the domain `STEPS` table + the `brandName`
|
||||
* prop. No brand-specific strings live in this component. Styling reuses the
|
||||
* portal's `hanzo-id-*` classes (defined in the web app's app.css).
|
||||
*/
|
||||
export interface OnboardingFlowProps {
|
||||
readonly service: OnboardingService
|
||||
/** Brand display name for headings (e.g. the resolved tenant brand). */
|
||||
readonly brandName: string
|
||||
/**
|
||||
* Host-supplied wallet connector. Returns the connected address (0x…) or
|
||||
* null if the user cancels. Kept as a prop so this pkg stays free of any
|
||||
* specific wallet library — the host wires Web3Onboard / wagmi / window
|
||||
* .ethereum. When omitted, the wallet step shows a "not available" note
|
||||
* and can only be skipped.
|
||||
*/
|
||||
readonly connectWallet?: () => Promise<string | null>
|
||||
/** Called once the flow reaches `done`, with the final accumulated state. */
|
||||
readonly onComplete: (state: OnboardingState) => void
|
||||
}
|
||||
|
||||
interface FlowState {
|
||||
readonly step: StepId
|
||||
readonly data: OnboardingState
|
||||
}
|
||||
|
||||
type FlowAction =
|
||||
| { type: 'advance'; patch: Partial<OnboardingState> }
|
||||
| { type: 'back' }
|
||||
|
||||
function reducer(state: FlowState, action: FlowAction): FlowState {
|
||||
switch (action.type) {
|
||||
case 'advance':
|
||||
return { step: nextStep(state.step), data: { ...state.data, ...action.patch } }
|
||||
case 'back': {
|
||||
const prev = prevStep(state.step)
|
||||
return prev ? { ...state, step: prev } : state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingFlow({ service, brandName, connectWallet, onComplete }: OnboardingFlowProps) {
|
||||
const [state, dispatch] = useReducer(reducer, { step: 'org', data: {} })
|
||||
|
||||
// Terminal step: hand the accumulated state back to the host exactly once.
|
||||
useEffect(() => {
|
||||
if (state.step === 'done') onComplete(state.data)
|
||||
}, [state.step, state.data, onComplete])
|
||||
|
||||
const advance = useCallback((patch: Partial<OnboardingState>) => dispatch({ type: 'advance', patch }), [])
|
||||
const back = useCallback(() => dispatch({ type: 'back' }), [])
|
||||
|
||||
const desc = stepById(state.step)
|
||||
const stepIndex = STEPS.findIndex((s) => s.id === state.step)
|
||||
const showBack = stepIndex > 0
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding">
|
||||
{state.step !== 'done' && desc ? (
|
||||
<>
|
||||
<StepDots active={stepIndex} total={STEPS.length} />
|
||||
<header className="hanzo-id-onboarding-head">
|
||||
<h1>{desc.title}</h1>
|
||||
<p className="lede">{desc.byline}</p>
|
||||
</header>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{state.step === 'org' ? (
|
||||
<OrgStep service={service} onNext={advance} />
|
||||
) : null}
|
||||
{state.step === 'project' ? (
|
||||
<ProjectStep
|
||||
service={service}
|
||||
orgName={state.data.orgName}
|
||||
showBack={showBack}
|
||||
onBack={back}
|
||||
onNext={advance}
|
||||
/>
|
||||
) : null}
|
||||
{state.step === 'wallet' ? (
|
||||
<WalletStep
|
||||
service={service}
|
||||
connectWallet={connectWallet}
|
||||
showBack={showBack}
|
||||
onBack={back}
|
||||
onNext={advance}
|
||||
/>
|
||||
) : null}
|
||||
{state.step === 'done' ? <DoneStep brandName={brandName} data={state.data} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Linear progress dots. */
|
||||
function StepDots({ active, total }: { active: number; total: number }) {
|
||||
return (
|
||||
<div className="hanzo-id-stepdots" role="progressbar" aria-valuenow={active + 1} aria-valuemax={total}>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span key={i} className={i <= active ? 'on' : ''} aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 1: organization ────────────────────────────────────────────
|
||||
|
||||
function OrgStep({
|
||||
service,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [orgs, setOrgs] = useState<OrgRef[] | null>(null)
|
||||
const [mode, setMode] = useState<'pick' | 'create'>('pick')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
service.listOrgs().then((list) => {
|
||||
if (cancelled) return
|
||||
setOrgs(list)
|
||||
// No existing memberships → drop straight into create mode.
|
||||
if (list.length === 0) setMode('create')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [service])
|
||||
|
||||
async function pick(org: OrgRef) {
|
||||
onNext({ orgName: org.name, orgCreated: false })
|
||||
}
|
||||
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const name = slugify(displayName)
|
||||
if (!name) {
|
||||
setError('Enter an organization name.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await service.createOrg({ name, displayName: displayName.trim() })
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(humanizeError(res.error))
|
||||
return
|
||||
}
|
||||
onNext({ orgName: res.value.name, orgCreated: true })
|
||||
}
|
||||
|
||||
if (orgs === null) return <p className="hanzo-id-info">Loading your organizations…</p>
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
{mode === 'pick' && orgs.length > 0 ? (
|
||||
<>
|
||||
<ul className="hanzo-id-org-list">
|
||||
{orgs.map((o) => (
|
||||
<li key={o.name}>
|
||||
<button type="button" className="hanzo-id-org-row" onClick={() => pick(o)}>
|
||||
<span>{o.displayName}</span>
|
||||
<span className="hanzo-id-org-slug">{o.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" className="hanzo-id-linkbtn" onClick={() => setMode('create')}>
|
||||
+ Create a new organization
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<form onSubmit={create} aria-busy={busy}>
|
||||
<label>
|
||||
<span>Organization name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Acme Inc"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{orgs.length > 0 ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => setMode('pick')}>
|
||||
Back
|
||||
</button>
|
||||
) : (
|
||||
// No org to fall back to and creation may be denied — let the
|
||||
// user proceed rather than dead-end. They land org-less; an
|
||||
// admin can add them to an org later.
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip for now
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="hanzo-id-btn primary" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create organization'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 2: project (optional) ──────────────────────────────────────
|
||||
|
||||
function ProjectStep({
|
||||
service,
|
||||
orgName,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
orgName?: string
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function create(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!orgName) return // can't create a project without a home org
|
||||
const name = slugify(displayName)
|
||||
if (!name) {
|
||||
setError('Enter a project name.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const res = await service.createProject({ organization: orgName, name, displayName: displayName.trim() })
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(humanizeError(res.error))
|
||||
return
|
||||
}
|
||||
onNext({ projectName: res.value.name })
|
||||
}
|
||||
|
||||
// No org was chosen (org step skipped) — a project needs a home org, so
|
||||
// offer only to continue.
|
||||
if (!orgName) {
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<p className="hanzo-id-info">Choose an organization first to create a project. You can do this later.</p>
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn primary" onClick={() => onNext({})}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
<form onSubmit={create} aria-busy={busy}>
|
||||
<label>
|
||||
<span>Project name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Production"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{displayName ? <p className="hanzo-id-slug-preview">slug: {slugify(displayName) || '—'}</p> : null}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip
|
||||
</button>
|
||||
<button type="submit" className="hanzo-id-btn primary" disabled={busy}>
|
||||
{busy ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Step 3: wallet (optional) ───────────────────────────────────────
|
||||
|
||||
function WalletStep({
|
||||
service,
|
||||
connectWallet,
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
service: OnboardingService
|
||||
connectWallet?: () => Promise<string | null>
|
||||
showBack: boolean
|
||||
onBack: () => void
|
||||
onNext: (patch: Partial<OnboardingState>) => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function link() {
|
||||
if (!connectWallet) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const address = await connectWallet()
|
||||
if (!address) {
|
||||
setBusy(false)
|
||||
return // user cancelled the wallet prompt
|
||||
}
|
||||
const res = await service.linkWallet(address)
|
||||
setBusy(false)
|
||||
if (!res.ok) {
|
||||
setError(res.error)
|
||||
return
|
||||
}
|
||||
onNext({ walletAddress: res.value })
|
||||
} catch (e) {
|
||||
setBusy(false)
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-body">
|
||||
{connectWallet ? null : (
|
||||
<p className="hanzo-id-info">Wallet linking isn’t available here. You can add one later in settings.</p>
|
||||
)}
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
<div className="hanzo-id-onboarding-actions">
|
||||
{showBack ? (
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="hanzo-id-btn ghost" onClick={() => onNext({})} disabled={busy}>
|
||||
Skip
|
||||
</button>
|
||||
{connectWallet ? (
|
||||
<button type="button" className="hanzo-id-btn primary" onClick={link} disabled={busy}>
|
||||
{busy ? 'Connecting…' : 'Connect wallet'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Terminal: success ───────────────────────────────────────────────
|
||||
|
||||
function DoneStep({ brandName, data }: { brandName: string; data: OnboardingState }) {
|
||||
return (
|
||||
<div className="hanzo-id-onboarding-done">
|
||||
<h1>You’re all set</h1>
|
||||
<p className="lede">Welcome to {brandName}.</p>
|
||||
<dl className="hanzo-id-summary">
|
||||
{data.orgName ? (
|
||||
<>
|
||||
<dt>Organization</dt>
|
||||
<dd>{data.orgName}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{data.projectName ? (
|
||||
<>
|
||||
<dt>Project</dt>
|
||||
<dd>{data.projectName}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{data.walletAddress ? (
|
||||
<>
|
||||
<dt>Wallet</dt>
|
||||
<dd>{shortAddr(data.walletAddress)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map raw IAM errors to a human sentence. Org/project creation is admin-gated
|
||||
* in IAM authz (`add-organization` requires the `admin` role; `add-project`
|
||||
* default-denies for non-admins), so a normal member hits a permission error
|
||||
* — say so plainly instead of leaking an HTTP code, and the step stays
|
||||
* skippable so onboarding never hard-blocks.
|
||||
*/
|
||||
function humanizeError(raw: string): string {
|
||||
const lower = raw.toLowerCase()
|
||||
if (lower.includes('403') || lower.includes('permission') || lower.includes('not allowed') || lower.includes('unauthorized')) {
|
||||
return 'You don’t have permission to create this here. Pick an existing organization, or ask an admin to invite you.'
|
||||
}
|
||||
if (lower.includes('already') || lower.includes('exist') || lower.includes('conflict') || lower.includes('409')) {
|
||||
return 'That name is taken. Try a different one.'
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Lower-kebab a display name into an org/project slug. */
|
||||
function slugify(s: string): string {
|
||||
return s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40)
|
||||
}
|
||||
|
||||
function shortAddr(a: string): string {
|
||||
return a.length > 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/id-shared",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Shared types + tenant resolver for the Hanzo ID portal. No UI deps.",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
@@ -15,7 +15,8 @@
|
||||
"files": ["src"],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit",
|
||||
"build": "tsc --noEmit"
|
||||
"build": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types 'src/**/*.test.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
+27
-27
@@ -13,31 +13,29 @@ import type { BrandContract } from './types'
|
||||
* Vite plugin or the Express static serves the assets from each pkg's
|
||||
* `assets/` directory at this path).
|
||||
*/
|
||||
export async function loadBrand(brandPackage: string, brandUrl?: string): Promise<BrandContract> {
|
||||
// Browser: try the tenant's brandUrl (e.g. the jsDelivr-hosted brand.json
|
||||
// from config.json) first, then the app-local /brand/<pkg>/brand.json.
|
||||
//
|
||||
// Branding is cosmetic — it must NEVER block the login form. The SPA server
|
||||
// answers unknown paths with index.html (HTTP 200, text/html) for client-
|
||||
// side routing, so a missing brand.json comes back as HTML, not a 404. We
|
||||
// therefore reject any non-JSON response and, if every candidate fails,
|
||||
// return a minimal fallback brand so the app still renders. (Previously this
|
||||
// did `res.json()` on the HTML fallback → "Unexpected token '<'" → blank page.)
|
||||
export async function loadBrand(brandPackage: string): Promise<BrandContract> {
|
||||
// Browser: served by the app from /brand/<pkg>/brand.json. The brand is
|
||||
// purely cosmetic, so a transient fetch failure must NEVER blank the login
|
||||
// form. Retry the (occasionally 502-flaky) asset a few times, then fall back
|
||||
// to a neutral brand so the form always renders.
|
||||
if (typeof window !== 'undefined') {
|
||||
const candidates = [brandUrl, `/brand/${brandPackage}/brand.json`].filter(
|
||||
(u): u is string => typeof u === 'string' && u.length > 0,
|
||||
)
|
||||
for (const url of candidates) {
|
||||
// Flat, encoding-safe path emitted by the Vite brandJsonPlugin:
|
||||
// `@hanzo/brand` -> `/brand/hanzo.json`. A nested `@scope/brand/brand.json`
|
||||
// URL cannot be served by the production static server (literal `@` +
|
||||
// encoded `%2F` miss the on-disk file -> SPA catch-all returns index.html).
|
||||
const slug = brandPackage.replace(/^@/, '').split('/')[0] ?? 'hanzo'
|
||||
const url = `/brand/${slug}.json`
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) continue
|
||||
const ct = res.headers.get('content-type') ?? ''
|
||||
if (!ct.includes('json')) continue // SPA fallback HTML — not our brand.json
|
||||
const raw = (await res.json()) as { brand?: BrandContract }
|
||||
if (raw?.brand) return raw.brand
|
||||
if (res.ok) {
|
||||
const raw = await res.json()
|
||||
return raw.brand as BrandContract
|
||||
}
|
||||
} catch {
|
||||
// try the next candidate
|
||||
// network error — fall through to retry
|
||||
}
|
||||
if (attempt < 2) await new Promise((r) => setTimeout(r, 150 * (attempt + 1)))
|
||||
}
|
||||
return fallbackBrand(brandPackage)
|
||||
}
|
||||
@@ -49,20 +47,22 @@ export async function loadBrand(brandPackage: string, brandUrl?: string): Promis
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal brand used only when no brand.json could be loaded — keeps the login
|
||||
* form rendering. Derives a display name from the package scope
|
||||
* (`@hanzo/brand` → "Hanzo").
|
||||
* Last-resort brand when the asset is unreachable after retries. Keeps the
|
||||
* login form usable (a generic heading) instead of blanking the page. The
|
||||
* display name is derived from the pkg scope (`@hanzo/brand` -> "Hanzo"); the
|
||||
* few tenants whose scope differs from their display name are mapped.
|
||||
*/
|
||||
function fallbackBrand(brandPackage: string): BrandContract {
|
||||
const scope = brandPackage.match(/@([^/]+)\//)?.[1] ?? 'hanzo'
|
||||
const name = scope.charAt(0).toUpperCase() + scope.slice(1)
|
||||
const scope = brandPackage.replace(/^@/, '').split('/')[0] ?? 'hanzo'
|
||||
const overrides: Record<string, string> = { luxfi: 'Lux', zooai: 'Zoo', parsdao: 'Pars' }
|
||||
const name = overrides[scope] ?? scope.charAt(0).toUpperCase() + scope.slice(1)
|
||||
return {
|
||||
name,
|
||||
title: 'Sign in',
|
||||
title: name,
|
||||
description: '',
|
||||
appDomain: '',
|
||||
logoUrl: '',
|
||||
faviconUrl: 'data:,',
|
||||
faviconUrl: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Tenant-resolver tests — run with the Node built-in runner + native TS strip:
|
||||
*
|
||||
* pnpm --filter @hanzo/id-shared test
|
||||
*
|
||||
* Focus: a host that exists ONLY in the runtime catalog (no built-in entry)
|
||||
* must resolve to ITS OWN brand and issuer — never inherit Hanzo's. This is the
|
||||
* osage.id brand-leak regression: the catalog carries `brandUrl`, and the
|
||||
* resolver must map it to `brandPackage` and derive issuer/origin from the host.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { resolveTenant, parseCatalog } from './tenant.ts'
|
||||
|
||||
// Mirrors the K8s ConfigMap shape: entries carry `brandUrl`, not `brandPackage`.
|
||||
const CATALOG = {
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
clientId: 'lux-cloud',
|
||||
appName: 'lux-cloud',
|
||||
brandUrl: 'https://cdn.jsdelivr.net/npm/@luxfi/brand@latest/brand.json',
|
||||
},
|
||||
'osage.id': {
|
||||
orgId: 'osage',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
brandUrl: 'https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json',
|
||||
},
|
||||
}
|
||||
|
||||
test('a built-in host resolves to its own brand with no catalog', () => {
|
||||
const t = resolveTenant('hanzo.id')
|
||||
assert.equal(t.orgId, 'hanzo')
|
||||
assert.equal(t.brandPackage, '@hanzo/brand')
|
||||
assert.equal(t.iamUrl, 'https://hanzo.id')
|
||||
})
|
||||
|
||||
test('a catalog entry overrides clientId/appName but keeps a consistent brand', () => {
|
||||
const t = resolveTenant('lux.id', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'lux')
|
||||
assert.equal(t.clientId, 'lux-cloud')
|
||||
assert.equal(t.brandPackage, '@luxfi/brand')
|
||||
assert.equal(t.iamUrl, 'https://lux.id')
|
||||
assert.equal(t.publicOrigin, 'https://lux.id')
|
||||
})
|
||||
|
||||
test('a catalog-ONLY host does NOT leak the Hanzo brand (osage.id regression)', () => {
|
||||
const t = resolveTenant('osage.id', { catalog: CATALOG })
|
||||
assert.equal(t.orgId, 'osage')
|
||||
assert.equal(t.clientId, 'osage-id-portal')
|
||||
// brandUrl is mapped onto brandPackage, and it is NOT Hanzo's.
|
||||
assert.equal(t.brandPackage, '@osage/brand')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
// issuer + origin are the host itself, never hanzo.id.
|
||||
assert.equal(t.iamUrl, 'https://osage.id')
|
||||
assert.equal(t.iamIssuer, 'https://osage.id')
|
||||
assert.equal(t.publicOrigin, 'https://osage.id')
|
||||
})
|
||||
|
||||
test('pars built-in uses the working pars-console portal app (not the missing pars-id)', () => {
|
||||
const t = resolveTenant('pars.id')
|
||||
assert.equal(t.clientId, 'pars-console')
|
||||
assert.equal(t.brandPackage, '@parsdao/brand')
|
||||
})
|
||||
|
||||
test('osage built-in resolves to Osage even with NO catalog (fallback safety)', () => {
|
||||
const t = resolveTenant('osage.id')
|
||||
assert.equal(t.orgId, 'osage')
|
||||
assert.equal(t.brandPackage, '@osage/brand')
|
||||
assert.notEqual(t.brandPackage, '@hanzo/brand')
|
||||
assert.equal(t.iamUrl, 'https://osage.id')
|
||||
})
|
||||
|
||||
test('an unknown host falls back to the default org but keeps its own origin', () => {
|
||||
const t = resolveTenant('preview.example.com')
|
||||
assert.equal(t.orgId, 'hanzo')
|
||||
assert.equal(t.publicOrigin, 'https://preview.example.com')
|
||||
})
|
||||
|
||||
test('parseCatalog tolerates junk', () => {
|
||||
assert.deepEqual(parseCatalog(undefined), {})
|
||||
assert.deepEqual(parseCatalog(null), {})
|
||||
assert.deepEqual(parseCatalog('not json'), {})
|
||||
assert.deepEqual(parseCatalog('{"osage.id":{"orgId":"osage"}}'), {
|
||||
'osage.id': { orgId: 'osage' },
|
||||
})
|
||||
})
|
||||
+117
-12
@@ -16,10 +16,26 @@ import type { TenantConfig } from './types'
|
||||
|
||||
const TRIM_TRAILING_SLASH = (s: string): string => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Built-in tenants for the four canonical identity hosts.
|
||||
*
|
||||
* `iamUrl` is the per-brand OIDC ISSUER — the host that serves
|
||||
* `/.well-known/openid-configuration` and the `/v1/iam/*` surface. Per
|
||||
* HIP-0111 this is the brand's own `*.id` host (hanzo.id / lux.id / …),
|
||||
* NOT `iam.hanzo.ai`: discovery must be host-relative so the SDK never
|
||||
* resolves to the wrong origin (or the IAM SPA HTML catch-all). The IAM
|
||||
* backend tenant-scopes on the `organization` body param; one backend
|
||||
* serves every brand behind its own issuer host.
|
||||
*
|
||||
* `clientId` is the brand `-id` app registered in `init_data.json`
|
||||
* (`hanzo-id`, `lux-id`, …) so the portal authenticates as that app — the
|
||||
* same app whose enabled providers (password + GitHub + Google + Web3)
|
||||
* `get-app-login` reports.
|
||||
*/
|
||||
const DEFAULT_TENANTS: Record<string, TenantConfig> = {
|
||||
'hanzo.id': {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://iam.hanzo.ai',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-id',
|
||||
appName: 'hanzo-id',
|
||||
@@ -28,7 +44,7 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
|
||||
},
|
||||
'lux.id': {
|
||||
orgId: 'lux',
|
||||
iamUrl: 'https://iam.hanzo.ai',
|
||||
iamUrl: 'https://lux.id',
|
||||
iamIssuer: 'https://lux.id',
|
||||
clientId: 'lux-id',
|
||||
appName: 'lux-id',
|
||||
@@ -37,7 +53,7 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
|
||||
},
|
||||
'zoo.id': {
|
||||
orgId: 'zoo',
|
||||
iamUrl: 'https://iam.hanzo.ai',
|
||||
iamUrl: 'https://zoo.id',
|
||||
iamIssuer: 'https://zoo.id',
|
||||
clientId: 'zoo-id',
|
||||
appName: 'zoo-id',
|
||||
@@ -46,18 +62,53 @@ const DEFAULT_TENANTS: Record<string, TenantConfig> = {
|
||||
},
|
||||
'pars.id': {
|
||||
orgId: 'pars',
|
||||
iamUrl: 'https://iam.hanzo.ai',
|
||||
iamUrl: 'https://pars.id',
|
||||
iamIssuer: 'https://pars.id',
|
||||
clientId: 'pars-id',
|
||||
appName: 'pars-id',
|
||||
// The portal app is `pars-console` (it carries the https://pars.id/callback
|
||||
// redirect); a bare `pars-id` app does not exist in IAM.
|
||||
clientId: 'pars-console',
|
||||
appName: 'pars-console',
|
||||
publicOrigin: 'https://pars.id',
|
||||
brandPackage: '@parsdao/brand',
|
||||
},
|
||||
// Osage is served by this portal too; without a built-in it would fall back
|
||||
// to the Hanzo default and leak the wrong brand if the runtime catalog ever
|
||||
// fails to load. (osage-id-portal is pre-launch — no IAM app yet — but the
|
||||
// brand must read as Osage, never Hanzo.)
|
||||
'osage.id': {
|
||||
orgId: 'osage',
|
||||
iamUrl: 'https://osage.id',
|
||||
iamIssuer: 'https://osage.id',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
publicOrigin: 'https://osage.id',
|
||||
brandPackage: '@osage/brand',
|
||||
},
|
||||
'www.osage.id': {
|
||||
orgId: 'osage',
|
||||
iamUrl: 'https://www.osage.id',
|
||||
iamIssuer: 'https://www.osage.id',
|
||||
clientId: 'osage-id-portal',
|
||||
appName: 'osage-id',
|
||||
publicOrigin: 'https://www.osage.id',
|
||||
brandPackage: '@osage/brand',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A runtime catalog entry as it appears in the K8s ConfigMap / `/config.json`.
|
||||
* It carries the human-authored shape — notably `brandUrl` (a CDN URL), which
|
||||
* this module maps onto the code-facing `brandPackage`. All fields optional;
|
||||
* whatever is present overrides the host-derived base.
|
||||
*/
|
||||
export type CatalogEntry = Partial<TenantConfig> & {
|
||||
/** CDN URL of the brand package, e.g. `…/npm/@osage/brand@latest/brand.json`. */
|
||||
readonly brandUrl?: string
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Optional runtime catalog (parsed from IAM_TENANT_CONFIG_JSON or /config.json). */
|
||||
readonly catalog?: Record<string, Partial<TenantConfig>>
|
||||
readonly catalog?: Record<string, CatalogEntry>
|
||||
/** Default org slug when host has no entry. */
|
||||
readonly defaultOrg?: string
|
||||
}
|
||||
@@ -67,10 +118,11 @@ export function resolveTenant(hostname: string, opts: ResolveOptions = {}): Tena
|
||||
const catalogEntry = opts.catalog?.[host]
|
||||
const builtIn = DEFAULT_TENANTS[host]
|
||||
if (catalogEntry || builtIn) {
|
||||
const merged: TenantConfig = {
|
||||
...(builtIn ?? DEFAULT_TENANTS['hanzo.id']),
|
||||
...(catalogEntry ?? {}),
|
||||
} as TenantConfig
|
||||
// Base = the built-in tenant if one exists, else a skeleton derived from
|
||||
// THIS host. Never another brand's config: a catalog-only host (osage.id,
|
||||
// zoolabs.id) must not inherit Hanzo's issuer or brand package.
|
||||
const base = builtIn ?? hostSkeleton(host)
|
||||
const merged: TenantConfig = { ...base, ...fromCatalog(catalogEntry) } as TenantConfig
|
||||
return normalize(merged)
|
||||
}
|
||||
const defaultOrg = opts.defaultOrg ?? 'hanzo'
|
||||
@@ -78,16 +130,69 @@ export function resolveTenant(hostname: string, opts: ResolveOptions = {}): Tena
|
||||
return normalize({ ...fallback, publicOrigin: `https://${host}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* A host-derived tenant skeleton for a catalog-only host (no built-in entry).
|
||||
* URLs point at the host itself so nothing leaks from another brand; the
|
||||
* catalog entry spread over this supplies orgId / clientId / appName /
|
||||
* brandPackage. brandPackage defaults empty → the brand loader falls back to a
|
||||
* neutral wordmark rather than showing the wrong brand.
|
||||
*/
|
||||
function hostSkeleton(host: string): TenantConfig {
|
||||
return {
|
||||
orgId: '',
|
||||
iamUrl: `https://${host}`,
|
||||
iamIssuer: `https://${host}`,
|
||||
clientId: '',
|
||||
appName: '',
|
||||
publicOrigin: `https://${host}`,
|
||||
brandPackage: '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a catalog entry onto a TenantConfig patch, mapping `brandUrl` →
|
||||
* `brandPackage` (the code-facing field) when an explicit `brandPackage` isn't
|
||||
* given. Only defined string fields are emitted, so the host-derived base shows
|
||||
* through for anything the entry omits.
|
||||
*/
|
||||
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) {
|
||||
const v = entry[k]
|
||||
if (typeof v === 'string' && v.length > 0) out[k] = v
|
||||
}
|
||||
if (!out.brandPackage && typeof entry.brandUrl === 'string') {
|
||||
const pkg = brandPackageFromUrl(entry.brandUrl)
|
||||
if (pkg) out.brandPackage = pkg
|
||||
}
|
||||
return out as Partial<TenantConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the npm package name from a CDN brand URL, e.g.
|
||||
* `https://cdn.jsdelivr.net/npm/@osage/brand@latest/brand.json` → `@osage/brand`.
|
||||
*/
|
||||
function brandPackageFromUrl(url: string): string {
|
||||
const m = /\/npm\/(@[^/]+\/[^@/]+|[^@/]+)(?:@|\/)/.exec(url)
|
||||
return m ? m[1]! : ''
|
||||
}
|
||||
|
||||
function stripPort(h: string): string {
|
||||
return h.replace(/:\d+$/, '')
|
||||
}
|
||||
|
||||
function normalize(t: TenantConfig): TenantConfig {
|
||||
const publicOrigin = TRIM_TRAILING_SLASH(t.publicOrigin)
|
||||
return {
|
||||
...t,
|
||||
iamUrl: TRIM_TRAILING_SLASH(t.iamUrl),
|
||||
iamIssuer: TRIM_TRAILING_SLASH(t.iamIssuer || t.iamUrl),
|
||||
publicOrigin: TRIM_TRAILING_SLASH(t.publicOrigin),
|
||||
publicOrigin,
|
||||
// The social OAuth hop's redirect_uri must hit the provider's registered
|
||||
// callback host. Default to this host; brands sharing a single OAuth client
|
||||
// override it (via the catalog) to that client's registered origin.
|
||||
oauthCallbackOrigin: TRIM_TRAILING_SLASH(t.oauthCallbackOrigin || publicOrigin),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,14 +18,18 @@ export interface TenantConfig {
|
||||
readonly appName: string
|
||||
/** Canonical public origin for the host (used for OIDC discovery rewrites). */
|
||||
readonly publicOrigin: string
|
||||
/**
|
||||
* Origin whose `/callback` is registered as the social OAuth providers'
|
||||
* authorized redirect URI. The shared GitHub/Google OAuth clients are
|
||||
* registered against ONE callback host — the IAM backend (`iam.hanzo.ai`) —
|
||||
* so the provider hop MUST send `redirect_uri=<oauthCallbackOrigin>/callback`
|
||||
* or the provider rejects it with `redirect_uri_mismatch`. That host serves
|
||||
* the same headless `Callback` SPA, which completes the exchange and forwards
|
||||
* back to the originating app. Defaults to `publicOrigin` (per-host clients /
|
||||
* local dev). NO trailing slash. */
|
||||
readonly oauthCallbackOrigin?: string
|
||||
/** npm package name of the brand pkg to load (e.g. `@hanzo/brand`). */
|
||||
readonly brandPackage: string
|
||||
/**
|
||||
* Allow self-service account creation. Defaults to true (undefined = true).
|
||||
* Set false for invite-only / admin-provisioned tenants: the `/signup` route
|
||||
* falls back to `/login` and every "Create account" link is hidden.
|
||||
*/
|
||||
readonly signupEnabled?: boolean
|
||||
/** Optional absolute URL to brand.json (e.g. a jsDelivr-hosted copy from
|
||||
* config.json). Preferred over the app-local /brand/<pkg>/brand.json. */
|
||||
readonly brandUrl?: string
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Generated
+183
-146
@@ -4,10 +4,6 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
esbuild: ^0.28.1
|
||||
uuid: ^11.1.1
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -25,14 +21,17 @@ importers:
|
||||
specifier: ^7.2.4
|
||||
version: 7.3.0(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)(sf-symbols-typescript@2.2.0)
|
||||
'@hanzo/iam':
|
||||
specifier: ^0.9.4
|
||||
version: 0.9.4(react@19.2.7)
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(react@19.2.7)
|
||||
'@hanzo/id-auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkgs/auth
|
||||
'@hanzo/id-idv':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkgs/idv
|
||||
'@hanzo/id-onboarding':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkgs/onboarding
|
||||
'@hanzo/id-shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkgs/shared
|
||||
@@ -74,11 +73,14 @@ importers:
|
||||
pkgs/auth:
|
||||
dependencies:
|
||||
'@hanzo/iam':
|
||||
specifier: ^0.9.4
|
||||
version: 0.9.4(react@19.2.7)
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(react@19.2.7)
|
||||
'@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
|
||||
@@ -112,6 +114,31 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
pkgs/onboarding:
|
||||
dependencies:
|
||||
'@hanzo/iam':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(react@19.2.7)
|
||||
'@hanzo/id-shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.21
|
||||
'@types/react':
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.17
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.7
|
||||
react-dom:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.7(react@19.2.7)
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
pkgs/shared:
|
||||
devDependencies:
|
||||
typescript:
|
||||
@@ -481,158 +508,158 @@ packages:
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
'@esbuild/aix-ppc64@0.27.7':
|
||||
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
|
||||
'@esbuild/android-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
|
||||
'@esbuild/android-arm@0.27.7':
|
||||
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
|
||||
'@esbuild/android-x64@0.27.7':
|
||||
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
|
||||
'@esbuild/darwin-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
|
||||
'@esbuild/darwin-x64@0.27.7':
|
||||
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
|
||||
'@esbuild/freebsd-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
|
||||
'@esbuild/freebsd-x64@0.27.7':
|
||||
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
|
||||
'@esbuild/linux-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
|
||||
'@esbuild/linux-arm@0.27.7':
|
||||
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
|
||||
'@esbuild/linux-ia32@0.27.7':
|
||||
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
|
||||
'@esbuild/linux-loong64@0.27.7':
|
||||
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
|
||||
'@esbuild/linux-mips64el@0.27.7':
|
||||
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
|
||||
'@esbuild/linux-ppc64@0.27.7':
|
||||
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
|
||||
'@esbuild/linux-riscv64@0.27.7':
|
||||
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
|
||||
'@esbuild/linux-s390x@0.27.7':
|
||||
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
|
||||
'@esbuild/linux-x64@0.27.7':
|
||||
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
|
||||
'@esbuild/netbsd-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
|
||||
'@esbuild/netbsd-x64@0.27.7':
|
||||
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
|
||||
'@esbuild/openbsd-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
|
||||
'@esbuild/openbsd-x64@0.27.7':
|
||||
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
|
||||
'@esbuild/openharmony-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
|
||||
'@esbuild/sunos-x64@0.27.7':
|
||||
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
|
||||
'@esbuild/win32-arm64@0.27.7':
|
||||
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
|
||||
'@esbuild/win32-ia32@0.27.7':
|
||||
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
|
||||
'@esbuild/win32-x64@0.27.7':
|
||||
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -823,8 +850,8 @@ packages:
|
||||
react: '>=19'
|
||||
react-native: '*'
|
||||
|
||||
'@hanzo/iam@0.9.4':
|
||||
resolution: {integrity: sha512-IJzjvwZct2TB1PpMaWJvytud/DYLTek14qZ8UBpTjVtFWm/bkgcny46CfmhE7IA8EGKCkV8Fvr4Nu82K6eNqpA==}
|
||||
'@hanzo/iam@0.11.0':
|
||||
resolution: {integrity: sha512-AGTYSl+En0Ci3UTBJs/kJ1MjxIAKOW9pMwLD6QCFLsBZIZCp1JljESepTEs4aBLifK0L3h/j3ya53ZyU3dSXjA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
@@ -1436,6 +1463,10 @@ 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}
|
||||
@@ -1557,79 +1588,66 @@ packages:
|
||||
resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.62.0':
|
||||
resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.62.0':
|
||||
resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.62.0':
|
||||
resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.62.0':
|
||||
resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.62.0':
|
||||
resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.62.0':
|
||||
resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.62.0':
|
||||
resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.62.0':
|
||||
resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.62.0':
|
||||
resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.62.0':
|
||||
resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.62.0':
|
||||
resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.62.0':
|
||||
resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.62.0':
|
||||
resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==}
|
||||
@@ -1712,6 +1730,9 @@ packages:
|
||||
'@types/istanbul-reports@3.0.4':
|
||||
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
|
||||
|
||||
'@types/node@22.19.21':
|
||||
resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==}
|
||||
|
||||
'@types/node@25.9.3':
|
||||
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
|
||||
|
||||
@@ -2074,8 +2095,8 @@ packages:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
esbuild@0.27.7:
|
||||
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2408,6 +2429,9 @@ packages:
|
||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
libphonenumber-js@1.13.7:
|
||||
resolution: {integrity: sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==}
|
||||
|
||||
lighthouse-logger@1.4.2:
|
||||
resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
|
||||
|
||||
@@ -2446,28 +2470,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -3049,6 +3069,9 @@ packages:
|
||||
uid2@0.0.4:
|
||||
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.24.6:
|
||||
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
|
||||
|
||||
@@ -3087,8 +3110,9 @@ packages:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
uuid@11.1.1:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
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).
|
||||
hasBin: true
|
||||
|
||||
validate-npm-package-name@5.0.1:
|
||||
@@ -3692,82 +3716,82 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
'@esbuild/aix-ppc64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
'@esbuild/android-arm64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
'@esbuild/android-arm@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
'@esbuild/android-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
'@esbuild/darwin-arm64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
'@esbuild/darwin-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
'@esbuild/freebsd-arm64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
'@esbuild/freebsd-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
'@esbuild/linux-arm64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
'@esbuild/linux-arm@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
'@esbuild/linux-ia32@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
'@esbuild/linux-loong64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
'@esbuild/linux-mips64el@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
'@esbuild/linux-ppc64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
'@esbuild/linux-riscv64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
'@esbuild/linux-s390x@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
'@esbuild/linux-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
'@esbuild/netbsd-arm64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
'@esbuild/netbsd-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
'@esbuild/openbsd-arm64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
'@esbuild/openbsd-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
'@esbuild/openharmony-arm64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
'@esbuild/sunos-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
'@esbuild/win32-arm64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
'@esbuild/win32-ia32@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
'@esbuild/win32-x64@0.27.7':
|
||||
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)':
|
||||
@@ -4225,9 +4249,10 @@ snapshots:
|
||||
- sf-symbols-typescript
|
||||
- zeego
|
||||
|
||||
'@hanzo/iam@0.9.4(react@19.2.7)':
|
||||
'@hanzo/iam@0.11.0(react@19.2.7)':
|
||||
dependencies:
|
||||
jose: 6.2.3
|
||||
libphonenumber-js: 1.13.7
|
||||
passport-oauth2: 1.8.0
|
||||
optionalDependencies:
|
||||
react: 19.2.7
|
||||
@@ -6107,7 +6132,7 @@ snapshots:
|
||||
'@jest/schemas': 29.6.3
|
||||
'@types/istanbul-lib-coverage': 2.0.6
|
||||
'@types/istanbul-reports': 3.0.4
|
||||
'@types/node': 25.9.3
|
||||
'@types/node': 22.19.21
|
||||
'@types/yargs': 17.0.35
|
||||
chalk: 4.1.2
|
||||
|
||||
@@ -6141,6 +6166,8 @@ 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)':
|
||||
@@ -6401,9 +6428,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/istanbul-lib-report': 3.0.3
|
||||
|
||||
'@types/node@22.19.21':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.9.3':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
optional: true
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||
dependencies:
|
||||
@@ -6642,7 +6674,7 @@ snapshots:
|
||||
|
||||
chrome-launcher@0.15.2:
|
||||
dependencies:
|
||||
'@types/node': 25.9.3
|
||||
'@types/node': 22.19.21
|
||||
escape-string-regexp: 4.0.0
|
||||
is-wsl: 2.2.0
|
||||
lighthouse-logger: 1.4.2
|
||||
@@ -6651,7 +6683,7 @@ snapshots:
|
||||
|
||||
chromium-edge-launcher@0.3.0:
|
||||
dependencies:
|
||||
'@types/node': 25.9.3
|
||||
'@types/node': 22.19.21
|
||||
escape-string-regexp: 4.0.0
|
||||
is-wsl: 2.2.0
|
||||
lighthouse-logger: 1.4.2
|
||||
@@ -6778,34 +6810,34 @@ snapshots:
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
esbuild@0.28.1:
|
||||
esbuild@0.27.7:
|
||||
optionalDependencies:
|
||||
'@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
|
||||
'@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
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
@@ -7069,7 +7101,7 @@ snapshots:
|
||||
jest-util@29.7.0:
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 25.9.3
|
||||
'@types/node': 22.19.21
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
graceful-fs: 4.2.11
|
||||
@@ -7086,7 +7118,7 @@ snapshots:
|
||||
|
||||
jest-worker@29.7.0:
|
||||
dependencies:
|
||||
'@types/node': 25.9.3
|
||||
'@types/node': 22.19.21
|
||||
jest-util: 29.7.0
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
@@ -7113,6 +7145,8 @@ snapshots:
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
libphonenumber-js@1.13.7: {}
|
||||
|
||||
lighthouse-logger@1.4.2:
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
@@ -7841,7 +7875,10 @@ snapshots:
|
||||
|
||||
uid2@0.0.4: {}
|
||||
|
||||
undici-types@7.24.6: {}
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.24.6:
|
||||
optional: true
|
||||
|
||||
unicode-canonical-property-names-ecmascript@2.0.1: {}
|
||||
|
||||
@@ -7868,7 +7905,7 @@ snapshots:
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@11.1.1: {}
|
||||
uuid@7.0.3: {}
|
||||
|
||||
validate-npm-package-name@5.0.1: {}
|
||||
|
||||
@@ -7876,7 +7913,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.28.1
|
||||
esbuild: 0.27.7
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.15
|
||||
@@ -7920,7 +7957,7 @@ snapshots:
|
||||
xcode@3.0.1:
|
||||
dependencies:
|
||||
simple-plist: 1.3.1
|
||||
uuid: 11.1.1
|
||||
uuid: 7.0.3
|
||||
|
||||
xml2js@0.6.0:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user