Compare commits

...
Author SHA1 Message Date
Antje Worring ad91418abe merge: origin/main into restore-design — main infra + apps-launcher Portal
Combines main's infra fixes (real IAM clientId <org>-id #7, /v1/iam/get-app-login
path #10, resilient brand loader #14, hanzoai/spa serving #11-13, social/SMS login
#9) with the restore-design UX (split-view login + apps-launcher Portal that detects
the session via ?signed_in=1 / get-account and links to Console).

Fixes the live hanzo.id login LOOP: the stale 0.1.17 image had an org-select →
connect-wallet → skip onboarding where 'skip' bounced back to sign-in and the org
click was a no-op (never reached console). The current flow is: login → /?signed_in=1
→ Portal apps launcher (Console → console.hanzo.ai). No org-select/wallet/skip exist.

Conflicts resolved: Dockerfile/locks/brand.ts = main; client.ts get-app-login = /v1
(main); types.ts = union (signupEnabled + brandUrl); app.css = redesign apps-launcher
styles. Verified: pnpm build green.
2026-06-21 11:53:45 -07:00
Antje Worring 73f431d54e feat(id): restore split-view login + apps launcher (ported from legacy-nextjs)
Login/Signup are now the polished two-column split — auth card on the left,
per-org marketing/branding panel on the right — and the Portal shows the
apps launcher grid for signed-in users, both ported from the frozen
legacy-nextjs design. Auth wiring is untouched: LoginForm from @hanzo/id-auth
still drives the /v1/iam/login + responseType=code flow with a type=text
username input.

- apps/web/src/marketing.ts: per-org marketing copy + app links + billing
  URLs keyed by tenant.orgId (ported from legacy staticBranding.content +
  orgApps). Keeps the brand-neutral BrandContract visual-only.
- apps/web/src/components/MarketingPanel.tsx: rotating-quote branding panel.
- apps/web/src/components/BrandLogo.tsx: brand.logoUrl with self-hosted
  /brand/<pkg>/assets/logo/logo.svg fallback (fixes the broken-logo CDN 404).
- Portal detects auth via /v1/iam/get-account OR the signed_in=1 marker the
  bare-login flow now returns (cross-proxy get-account does not echo the
  session). pkgs/auth response shaping only — wire contract unchanged.
- app.css: split-view + marketing + apps-launcher styles (no Tailwind).
- LLM.md: document the UI surfaces.

Deployed registry.digitalocean.com/hanzo/id:0.1.10. Verified on hanzo.id and
lux.id: split-view renders, apps launcher shows post-login, sign-in still
works (authenticated, no Unable to sign in).
2026-06-19 11:07:01 -07:00
Antje Worring 22e0cc72e2 fix(id): emit per-brand brand.json in build (ESM require) + harden loadBrand
Root cause of the post-cutover white-screen: vite.config.ts is loaded as a
native ESM module where `require` is undefined, so the brandJsonPlugin's
`require.resolve('<pkg>/brand.json')` threw and the silent catch skipped
emitting EVERY brand.json. The SPA then served index.html (HTTP 200,
text/html) for /brand/<pkg>/brand.json, loadBrand did res.json() on HTML,
threw "Unexpected token '<'", and App's catch rendered that error as the
whole page — no login form ever mounted.

- vite.config.ts: build a createRequire(import.meta.url) resolver so the
  brand.json subpath resolves under ESM. Build now emits all four
  dist/brand/<pkg>/brand.json (verified: @hanzo brand.name = "Hanzo").
- brand.ts: read the response as text and JSON.parse explicitly; on
  non-JSON throw a precise diagnostic instead of a cryptic SyntaxError,
  and require the "brand" key.
- apps/web 0.1.8 -> 0.1.9.
2026-06-19 10:39:53 -07:00
Antje Worring f85de5d464 fix(id): rebuild SPA so login POSTs /v1/iam/login + serve via hanzoai/spa
The deployed hanzo.id SPA (id:0.1.7, 26d old) POSTed login to /api/login
with the implicit-flow body type:"token". That path hit the SPA static
catch-all -> index.html (HTML 200) -> JSON.parse threw -> "Unable to sign
in", and even reaching IAM, type:"token" is rejected (implicit flow
disabled). Current source already POSTs /v1/iam/login with
responseType=code; a fresh build of HEAD fixes it.

- Dockerfile: final stage ghcr.io/hanzoai/static:0.4.1 -> hanzoai/spa:1.2.0.
  static's default CSP is `default-src 'none'` (no script-src/connect-src)
  which blanks the SPA and blocks fetch() to IAM; spa serves /public on
  :3000 with index.html fallback and an SPA-safe CSP (frame-ancestors
  'none'). Matches the live deploy contract (containerPort 3000, /health
  probe) exactly.
- LoginForm identifier input stays type="text" (accepts `z` and
  `z@hanzo.ai`); forgot/signup keep type="email" (semantically correct).
- Adds social-provider + email/SMS-code login UI (best-effort appLogin),
  and a signupEnabled tenant flag for invite-only tenants.
- Tenant clientId hanzo-id (verified: POST /v1/iam/login app=hanzo-id
  org=hanzo user=z -> {"status":"ok","data":"hanzo/z"}).
- Pin @hanzo/iam to ^0.9.4 (published patch line).
- apps/web 0.1.1 -> 0.1.8.
2026-06-19 10:32:01 -07:00
Antje WorringandClaude Opus 4.8 d423a077c3 docs: tidy LLM.md indexes; CLAUDE.md -> LLM.md symlink convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:40:39 -07:00
hanzo-devandClaude Opus 4.8 9b0e7a1ba9 chore(decomplect): deploy + per-host catalog live in universe, not the app repo
The id repo is brand-neutral (zero brand data bundled), but apps/web/k8s/
carried the per-host brand catalog + Deployment/Ingress — duplicated in
hanzoai/universe infra/k8s/id/ (the single deploy source-of-truth). Removed
the overlay so app (brand-neutral image) and deploy (per-host catalog +
manifests, in universe) are separated. One way, one place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:05:11 -07:00
16 changed files with 686 additions and 59 deletions
+24 -1
View File
@@ -1,4 +1,4 @@
# LLM.md — Hanzo ID
# Hanzo ID
## What this is
@@ -63,6 +63,29 @@ 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
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@hanzo/id-web",
"private": true,
"version": "0.1.1",
"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.",
"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.",
"type": "module",
"scripts": {
"dev": "vite",
+7 -5
View File
@@ -36,10 +36,12 @@ 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} />
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 client={client} brand={brand} />
return <Portal brand={brand} />
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} />
}
+161
View File
@@ -167,3 +167,164 @@ form input:focus { outline: 2px solid var(--brand); outline-offset: -1px; }
border-radius: 8px;
font-size: 14px;
}
/* ============================================================
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; }
/* 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; }
+4 -3
View File
@@ -1,10 +1,11 @@
import type { BrandContract } from '@hanzo/id-shared'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { BrandLogo } from './BrandLogo'
export function BrandHeader({ brand }: { brand: BrandContract }) {
export function BrandHeader({ brand, tenant }: { brand: BrandContract; tenant: TenantConfig }) {
return (
<header className="hanzo-id-brand-header">
<a href="/" aria-label={brand.name}>
<img src={brand.logoUrl} alt={brand.name} height={32} />
<BrandLogo brand={brand} tenant={tenant} height={32} />
</a>
</header>
)
+34
View File
@@ -0,0 +1,34 @@
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)
}}
/>
)
}
@@ -0,0 +1,73 @@
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>
)
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Per-org marketing content + app launcher links.
*
* The brand-neutral `BrandContract` (from `loadBrand`) carries only the
* visual essentials (name, logo, accent). The split-view login's marketing
* panel and the post-login apps launcher need richer, org-specific copy —
* ported verbatim from the frozen `legacy-nextjs` design (`staticBranding`
* content + `orgApps`). Keyed by `tenant.orgId` so it stays decoupled from
* hostname switches; unknown orgs fall back to `hanzo`.
*/
export interface Quote {
readonly text: string
readonly author: string
readonly role?: string
}
export interface Marketing {
/** Pill above the hero ("✦ <tagline>"). */
readonly tagline?: string
/** Hero heading. */
readonly title: string
/** Hero subheading. */
readonly subtitle: string
/** Rotating testimonials. */
readonly quotes: readonly Quote[]
}
export interface AppLink {
readonly name: string
readonly href: string
readonly description: string
}
const MARKETING: Record<string, Marketing> = {
hanzo: {
tagline: 'AI-powered development',
title: 'Start building in seconds',
subtitle: 'Describe your idea and watch AI bring it to life instantly.',
quotes: [
{ text: 'Hanzo is amazing. It is revolutionizing how we build and deploy applications.', author: 'Developer', role: 'Software Engineer' },
],
},
lux: {
tagline: 'Lux-powered infrastructure',
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem.',
quotes: [
{ text: 'Lux is fast. We deploy chains in minutes, not weeks.', author: 'Validator', role: 'Node Operator' },
],
},
zoo: {
tagline: 'Open AI research network',
title: 'Build the future of DeAI',
subtitle: 'Open AI research and decentralized science for everyone.',
quotes: [
{ text: 'Zoo is where bleeding-edge DeAI experiments actually ship.', author: 'Researcher', role: 'ML Engineer' },
],
},
pars: {
tagline: 'Sovereign digital identity',
title: 'Welcome to Pars',
subtitle: 'The decentralized network for the next generation.',
quotes: [
{ text: 'Pars gives our community a sovereign, verifiable identity layer.', author: 'Member', role: 'Community Lead' },
],
},
}
const APPS: Record<string, readonly AppLink[]> = {
hanzo: [
{ name: 'Console', href: 'https://console.hanzo.ai', description: 'Observability & traces' },
{ name: 'Chat', href: 'https://hanzo.chat', description: 'AI chat interface' },
{ name: 'Cloud', href: 'https://cloud.hanzo.ai', description: 'AI model API' },
{ name: 'Analytics', href: 'https://analytics.hanzo.ai', description: 'Web analytics' },
{ name: 'Platform', href: 'https://platform.hanzo.ai', description: 'PaaS deployments' },
{ name: 'Storage', href: 'https://s3.hanzo.ai', description: 'S3-compatible storage' },
],
lux: [
{ name: 'Bridge', href: 'https://bridge.lux.network', description: 'Cross-chain bridge' },
{ name: 'Exchange', href: 'https://lux.exchange', description: 'DEX trading' },
{ name: 'Cloud', href: 'https://lux.cloud', description: 'Lux Cloud' },
{ name: 'Explorer', href: 'https://explore.lux.network', description: 'Block explorer' },
],
zoo: [
{ name: 'Network', href: 'https://zoo.ngo', description: 'Zoo Labs Foundation' },
{ name: 'ZIPs', href: 'https://zips.zoo.ngo', description: 'Improvement proposals' },
{ name: 'Chat', href: 'https://chat.zoo.ngo', description: 'DeAI chat interface' },
],
pars: [
{ name: 'Network', href: 'https://pars.network', description: 'Pars Network' },
{ name: 'Vote', href: 'https://pars.vote', description: 'Governance & proposals' },
],
}
const BILLING: Record<string, string> = {
hanzo: 'https://billing.hanzo.ai',
lux: 'https://billing.lux.network',
zoo: 'https://billing.zoo.network',
pars: 'https://billing.pars.network',
}
export function marketingFor(orgId: string): Marketing {
return MARKETING[orgId] ?? MARKETING.hanzo
}
export function appsFor(orgId: string): readonly AppLink[] {
return APPS[orgId] ?? APPS.hanzo
}
export function billingFor(orgId: string): string {
return BILLING[orgId] ?? BILLING.hanzo
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import type { AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Callback({ client, brand }: { client: AuthClient; brand: BrandContract }) {
export function Callback({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const sp = new URLSearchParams(window.location.search)
@@ -31,7 +31,7 @@ export function Callback({ client, brand }: { client: AuthClient; brand: BrandCo
return (
<div className="hanzo-id-page hanzo-id-callback">
<BrandHeader brand={brand} />
<BrandHeader brand={brand} tenant={tenant} />
<main>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : <p>Completing sign-in</p>}
</main>
+3 -3
View File
@@ -1,11 +1,11 @@
import type { BrandContract } from '@hanzo/id-shared'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { ForgotForm, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
export function Forgot({ client, brand }: { client: AuthClient; brand: BrandContract }) {
export function Forgot({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
return (
<div className="hanzo-id-page hanzo-id-forgot">
<BrandHeader brand={brand} />
<BrandHeader brand={brand} tenant={tenant} />
<main>
<h1>Reset your {brand.name} password</h1>
<ForgotForm client={client} />
+55 -17
View File
@@ -1,27 +1,65 @@
import type { BrandContract } from '@hanzo/id-shared'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { LoginForm, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
import { BrandLogo } from '../components/BrandLogo'
import { MarketingPanel } from '../components/MarketingPanel'
import { marketingFor } from '../marketing'
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
/**
* 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
}) {
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
return (
<div className="hanzo-id-page hanzo-id-login">
<BrandHeader brand={brand} />
<main>
<h1>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> · <a href="/signup">Create account</a>
</p>
</main>
<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>
)
}
+162 -12
View File
@@ -1,18 +1,168 @@
import type { BrandContract } from '@hanzo/id-shared'
import { BrandHeader } from '../components/BrandHeader'
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 { 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 }
/**
* Post-login portal. Detects the IAM session via the same-origin
* `/v1/iam/get-account` proxy (cookie-scoped, `credentials: 'include'`):
*
* - 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.
*
* 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.
*/
export function Portal({
client,
brand,
tenant,
signupEnabled,
}: {
client: AuthClient
brand: BrandContract
tenant: TenantConfig
signupEnabled: boolean
}) {
const [auth, setAuth] = useState<AuthState>({ status: '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(), {
credentials: 'include',
headers: { Accept: 'application/json' },
})
.then((r) => r.json())
.then((body: 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: '' } })
} else {
setAuth({ status: 'anon' })
}
})
.catch(() => {
if (!alive) return
setAuth(justSignedIn ? { status: 'authed', user: { id: '' } } : { status: 'anon' })
})
return () => {
alive = false
}
}, [tenant.publicOrigin])
if (auth.status === 'loading') {
return (
<div className="hanzo-id-loading">
<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>
)
}
const { user } = auth
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')
export function Portal({ brand }: { brand: BrandContract }) {
return (
<div className="hanzo-id-page hanzo-id-portal">
<BrandHeader brand={brand} />
<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>
<a className="hanzo-id-btn" href="/signup">Create account</a>
<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>
</main>
</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' }}
>
{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>
<p className="hanzo-id-app-desc">{app.description}</p>
</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>
</div>
</div>
)
}
+29 -12
View File
@@ -1,20 +1,37 @@
import type { BrandContract } from '@hanzo/id-shared'
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
import { SignupForm, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
import { BrandLogo } from '../components/BrandLogo'
import { MarketingPanel } from '../components/MarketingPanel'
import { marketingFor } from '../marketing'
export function Signup({ client, brand }: { client: AuthClient; brand: BrandContract }) {
/** Split-view signup, mirroring the restored login layout. */
export function Signup({ client, brand, tenant }: { client: AuthClient; brand: BrandContract; tenant: TenantConfig }) {
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
return (
<div className="hanzo-id-page hanzo-id-signup">
<BrandHeader brand={brand} />
<main>
<h1>Create your {brand.name} account</h1>
<SignupForm client={client} inviteCode={inviteCode} />
<p className="hanzo-id-footer-links">
Already have an account? <a href="/login">Sign in</a>
</p>
</main>
<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>
)
}
+6
View File
@@ -2,6 +2,12 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
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)
/**
* Per-brand brand.json copy plugin.
+4 -1
View File
@@ -312,8 +312,11 @@ 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.
if (!req?.redirectUri) {
return { redirectUrl: '/' }
return { redirectUrl: '/?signed_in=1' }
}
// Fallback: a nested token payload (future direct-token IAM responses).
+6
View File
@@ -20,6 +20,12 @@ export interface TenantConfig {
readonly publicOrigin: 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