Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad91418abe | ||
|
|
73f431d54e | ||
|
|
22e0cc72e2 | ||
|
|
f85de5d464 | ||
|
|
d423a077c3 | ||
|
|
9b0e7a1ba9 |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user