mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
fix(portal): root IS the login form (logged-out) / apps launcher (logged-in)
- '/' rendered a static 'Welcome' marketing hero; for an identity portal the
root should BE the login form when signed out, and the org's apps launcher
when signed in. Portal now reads /v1/iam/get-account (same-origin, first-party
cookie) and renders <Login> (anon) or the apps grid (authed).
- Onboarding completion redirected to '/' → the hero, which read as 'looped back
to the beginning' after the last (wallet) step's Skip. It now lands on
'/?signed_in=1' → the authed apps launcher. (The step machine was already
correct: nextStep('wallet')==='done'.)
- Port marketing.ts (appsFor/billingFor) from the restore-design line.
This commit is contained in:
@@ -71,5 +71,5 @@ export function App() {
|
||||
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 brand={brand} />
|
||||
return <Portal client={client} brand={brand} tenant={tenant} />
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -34,7 +34,10 @@ export function Onboarding({ tenant, brand }: { tenant: TenantConfig; brand: Bra
|
||||
)
|
||||
|
||||
function onComplete(_state: OnboardingState) {
|
||||
window.location.replace('/')
|
||||
// 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 (
|
||||
|
||||
@@ -1,18 +1,125 @@
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { BrandContract, TenantConfig } from '@hanzo/id-shared'
|
||||
import type { AuthClient } from '@hanzo/id-auth'
|
||||
import { Login } from './Login'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
import { appsFor, billingFor } from '../marketing'
|
||||
|
||||
type Auth =
|
||||
| { s: 'loading' }
|
||||
| { s: 'anon' }
|
||||
| { s: 'authed'; name?: string; email?: string }
|
||||
|
||||
/**
|
||||
* Root portal (`/`). The portal IS the login surface, not a marketing hero:
|
||||
*
|
||||
* - 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.
|
||||
*
|
||||
* 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,
|
||||
}: {
|
||||
client: AuthClient
|
||||
brand: BrandContract
|
||||
tenant: TenantConfig
|
||||
}) {
|
||||
const [auth, setAuth] = useState<Auth>({ s: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const justSignedIn = new URLSearchParams(window.location.search).get('signed_in') === '1'
|
||||
fetch(new URL('/v1/iam/get-account', tenant.iamUrl).toString(), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((b: Record<string, unknown>) => {
|
||||
if (!alive) return
|
||||
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(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setAuth(justSignedIn ? { s: 'authed' } : { s: 'anon' })
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [tenant.iamUrl])
|
||||
|
||||
if (auth.s === 'loading') {
|
||||
return (
|
||||
<div className="hanzo-id-page" style={{ minHeight: '40vh' }}>
|
||||
<div className="hanzo-id-spinner" style={{ borderTopColor: brand.accentColor ?? '#fff' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Signed out: the root IS the login form (no marketing hero).
|
||||
if (auth.s === 'anon') return <Login client={client} brand={brand} />
|
||||
|
||||
// Signed in: the apps launcher.
|
||||
const apps = appsFor(tenant.orgId)
|
||||
const billingUrl = billingFor(tenant.orgId)
|
||||
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>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{a.name}</span>
|
||||
<span aria-hidden style={{ opacity: 0.5 }}>↗</span>
|
||||
</div>
|
||||
<div style={{ opacity: 0.6, fontSize: 13, marginTop: 4 }}>{a.description}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user