chore(deps): clear Dependabot alerts — drop legacy-nextjs, patch esbuild/uuid (#8)

Resolves all 35 open Dependabot alerts on hanzoai/id:

- Delete legacy-nextjs/ (frozen Next.js predecessor, not wired into any
  build/Dockerfile/workspace glob; docs slated it for deletion after
  v0.1.0 — v0.1.1 has shipped). Removes 31 alerts (next, undici, ws,
  picomatch, postcss, js-yaml, cookie, defu, ...).

- pnpm overrides for the 3 active-workspace transitives:
    esbuild ^0.28.1  (was 0.27.7) — GHSA-gv7w-rqvm-qjhr (high), GHSA-g7r4-m6w7-qqqr (low)
    uuid    ^11.1.1  (was 7.0.3/10/x via [email protected]) — GHSA-w5hq-g745-h8pq (medium)

Verified: pnpm install + typecheck (4/4) + build green (vite 7.3.5,
esbuild 0.28.1, 52 modules); 'pnpm audit' → no known vulnerabilities.

Co-authored-by: Antje Worring <[email protected]>
This commit is contained in:
zooqueen
2026-06-18 12:23:57 -07:00
committed by GitHub
co-authored by Antje Worring
parent e8b0a2bb4a
commit 4bd16bb6c3
39 changed files with 126 additions and 7242 deletions
-54
View File
@@ -1,54 +0,0 @@
FROM node:22-alpine AS base
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# --- Dependencies ---
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
# --- Build ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build args become env vars at build time (for white-label forks)
ARG NEXT_PUBLIC_IAM_URL
ARG NEXT_PUBLIC_ORG
ARG NEXT_PUBLIC_CLIENT_ID
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# --- Production ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Runtime env vars for white-label configuration:
# IAM_ORIGIN — IAM backend URL (default: https://iam.hanzo.ai)
# NEXT_PUBLIC_IAM_URL — Same, for client-side
# NEXT_PUBLIC_ORG — Organization name (default: hanzo)
# NEXT_PUBLIC_CLIENT_ID — Default app client ID
CMD ["node", "server.js"]
-146
View File
@@ -1,146 +0,0 @@
# Hanzo ID - Hosted Login Pages
Configurable, white-label login pages for Hanzo IAM. Each organization can customize their login experience based on their domain (CNAME).
## Architecture
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ hanzo.id │ │ pars.id │ │ lux.id │
│ (CNAME) │ │ (CNAME) │ │ (CNAME) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┴───────────────────┘
│
┌──────▼──────┐
│ Hanzo ID │ ← This repo (frontend)
│ (Next.js) │
└──────┬──────┘
│
┌──────▼──────┐
│ Hanzo IAM │ ← Backend auth services
│ (Go API) │
└─────────────┘
```
## Features
- **Domain-based branding**: Logo, colors, content based on CNAME
- **Configurable auth methods**: Password, code, WebAuthn, Face ID
- **Social providers**: Google, GitHub, and more
- **Customizable content**: Quotes, testimonials, feature highlights
- **Dark mode by default**: Clean, modern design
- **Easy to fork**: Simple structure for white-labeling
## Configuration
Branding can be configured in two ways:
### 1. Static Configuration (for known domains)
Edit `lib/branding.ts` to add your domain:
```typescript
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
'your-domain.com': {
orgId: 'your-org',
orgName: 'Your Organization',
logo: '/logos/your-logo.svg',
colors: {
primary: '#3b82f6',
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Your App',
subtitle: 'Sign in to continue',
},
},
}
```
### 2. Dynamic Configuration (from IAM backend)
The login page fetches branding from IAM API:
```
GET https://api.hanzo.id/api/branding?domain=your-domain.com
```
Response:
```json
{
"orgId": "your-org",
"orgName": "Your Organization",
"logo": "https://...",
"colors": { ... },
"content": { ... },
"links": { ... },
"auth": { ... }
}
```
## Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
```
## Environment Variables
```bash
# IAM backend URL
HANZO_IAM_URL=https://api.hanzo.id
# Public IAM URL (for client-side redirects)
NEXT_PUBLIC_IAM_URL=https://api.hanzo.id
```
## Forking for White-Label
1. Fork this repository
2. Update `lib/branding.ts` with your default branding
3. Add your logo to `public/logos/`
4. Update `app/globals.css` for custom styling
5. Deploy to your infrastructure
## Directory Structure
```
hanzo-id/
├── app/
│ ├── layout.tsx # Root layout with metadata
│ ├── page.tsx # Redirects to /login
│ ├── login/
│ │ └── page.tsx # Main login page
│ ├── signup/ # Sign up page
│ ├── forgot-password # Password reset
│ └── callback/ # OAuth callback handler
├── components/
│ ├── LoginForm.tsx # Login form component
│ └── MarketingPanel.tsx # Right side marketing content
├── lib/
│ └── branding.ts # Branding configuration
├── public/
│ └── logos/ # Organization logos
└── config/ # Additional configuration
```
## License
MIT - Fork and customize freely!
-286
View File
@@ -1,286 +0,0 @@
'use client'
export const runtime = 'edge'
import { useEffect, useState } from 'react'
import { fetchUserInfo } from '@/lib/oauth'
import { getIamUrl, getOrg } from '@/lib/iam'
import { staticBranding, defaultBranding, resolveBrandingDomain, type BrandingConfig } from '@/lib/branding'
interface User {
sub: string
name?: string
displayName?: string
email?: string
avatar?: string
}
// Per-org app links
const orgApps: Record<string, { name: string; href: string; description: string }[]> = {
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://cloud.lux.network', 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' },
],
pars: [
{ name: 'Network', href: 'https://pars.network', description: 'Pars Network' },
{ name: 'Foundation', href: 'https://parsis.foundation', description: 'Parsis Foundation' },
],
}
// Per-org billing URL
function getBillingUrl(org: string): string {
switch (org) {
case 'lux': return 'https://billing.lux.network'
case 'zoo': return 'https://billing.zoo.network'
case 'pars': return 'https://billing.pars.network'
default: return 'https://billing.hanzo.ai'
}
}
export default function AccountPage() {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
const domain = resolveBrandingDomain(host)
const staticConfig = staticBranding[domain]
const branding: BrandingConfig = staticConfig
? { ...defaultBranding, ...staticConfig, domain }
: { ...defaultBranding, domain }
const org = getOrg(host)
const apps = orgApps[org] || orgApps.hanzo
const billingUrl = getBillingUrl(org)
useEffect(() => {
loadUser()
}, [])
async function loadUser() {
const token = localStorage.getItem('hanzo_access_token')
if (!token) {
window.location.href = '/login'
return
}
// Try cached user first
try {
const cached = localStorage.getItem('hanzo_user')
if (cached) {
setUser(JSON.parse(cached))
setIsLoading(false)
}
} catch {}
// Fetch fresh user info — use same-origin proxy to avoid CORS
try {
const userinfoUrl = window.location.origin + '/oauth/userinfo'
const info = await fetchUserInfo(userinfoUrl.replace('/oauth/userinfo', ''), token)
const userData: User = {
sub: info.sub,
name: info.name,
displayName: info.displayName,
email: info.email,
avatar: info.avatar || info.permanentAvatar,
}
// Also try decoding id_token for richer claims
if ((!userData.email || !userData.displayName) && localStorage.getItem('hanzo_id_token')) {
try {
const idToken = localStorage.getItem('hanzo_id_token')!
const p = JSON.parse(atob(idToken.split('.')[1]))
userData.email = userData.email || p.email
userData.displayName = userData.displayName || p.displayName || p.name || p.preferred_username
userData.name = userData.name || p.name || p.preferred_username
userData.avatar = userData.avatar || p.avatar || p.picture || p.permanentAvatar
} catch {}
}
setUser(userData)
localStorage.setItem('hanzo_user', JSON.stringify(userData))
} catch {
// Token expired or invalid
localStorage.removeItem('hanzo_access_token')
localStorage.removeItem('hanzo_user')
window.location.href = '/login'
return
} finally {
setIsLoading(false)
}
}
function handleLogout() {
localStorage.removeItem('hanzo_access_token')
localStorage.removeItem('hanzo_refresh_token')
localStorage.removeItem('hanzo_user')
window.location.href = '/login'
}
const cssVars = {
'--color-primary': branding.colors.primary,
'--color-primary-text': branding.colors.primaryText,
'--color-background': branding.colors.background,
'--color-surface': branding.colors.surface,
'--color-text': branding.colors.text,
'--color-text-muted': branding.colors.textMuted,
'--color-border': branding.colors.border,
'--color-error': branding.colors.error,
} as React.CSSProperties
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div
className="animate-spin w-8 h-8 border-2 border-zinc-700 rounded-full"
style={{ borderTopColor: branding.colors.primary }}
/>
</div>
)
}
return (
<div className="min-h-screen bg-black" style={cssVars}>
{/* Nav */}
<nav className="flex items-center justify-between px-6 md:px-12 py-4 border-b border-zinc-800/50">
<a href="/" className="flex items-center gap-3">
<img src={branding.logo} alt={branding.orgName} className="h-8" />
</a>
<div className="flex items-center gap-4">
<a
href={billingUrl}
className="text-sm text-zinc-400 hover:text-white transition-colors"
>
Billing
</a>
<button
onClick={handleLogout}
className="text-sm text-zinc-400 hover:text-white transition-colors"
>
Sign out
</button>
</div>
</nav>
<div className="max-w-4xl mx-auto px-6 md:px-12 py-12">
{/* Profile header */}
<div className="flex items-center gap-6 mb-12">
{user?.avatar ? (
<img src={user.avatar} alt="" className="w-20 h-20 rounded-full" />
) : (
<div
className="w-20 h-20 rounded-full flex items-center justify-center text-3xl font-bold"
style={{ backgroundColor: branding.colors.primary + '20', color: branding.colors.primary }}
>
{(user?.displayName || user?.name || user?.email || '?')[0].toUpperCase()}
</div>
)}
<div>
<h1 className="text-3xl font-bold text-white">
{user?.displayName || user?.name || 'User'}
</h1>
{user?.email && (
<p className="text-zinc-400 mt-1">{user.email}</p>
)}
</div>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Account info */}
<div className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30">
<h2 className="text-lg font-semibold text-white mb-4">Account</h2>
<div className="space-y-4">
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">User ID</div>
<div className="text-white font-mono text-sm">{user?.sub}</div>
</div>
{user?.name && (
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Username</div>
<div className="text-white">{user.name}</div>
</div>
)}
{user?.email && (
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Email</div>
<div className="text-white">{user.email}</div>
</div>
)}
<div>
<div className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Organization</div>
<div className="text-white">{branding.orgName}</div>
</div>
</div>
</div>
{/* Billing */}
<div className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30">
<h2 className="text-lg font-semibold text-white mb-4">Billing & Usage</h2>
<p className="text-zinc-400 text-sm mb-6">
Manage your subscription, payment methods, and usage.
</p>
<a
href={billingUrl}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm transition-opacity hover:opacity-90"
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
Manage Billing
</a>
</div>
</div>
{/* Apps */}
<div className="mt-8">
<h2 className="text-lg font-semibold text-white mb-4">{branding.orgName} Apps</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{apps.map((app) => (
<a
key={app.name}
href={app.href}
className="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors group"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-white">{app.name}</span>
<svg className="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</div>
<p className="text-sm text-zinc-500">{app.description}</p>
</a>
))}
</div>
</div>
{/* Actions */}
<div className="mt-12 pt-8 border-t border-zinc-800 flex items-center justify-between">
<a
href="/"
className="text-sm text-zinc-500 hover:text-white transition-colors"
>
&larr; Back to {branding.orgName}
</a>
<button
onClick={handleLogout}
className="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-400 hover:text-white hover:border-zinc-500 transition-colors text-sm"
>
Sign out
</button>
</div>
</div>
</div>
)
}
-157
View File
@@ -1,157 +0,0 @@
/**
* OAuth bridge callback for downstream apps (Platform, MPC, etc.)
*
* Handles the code exchange on behalf of apps that need IAM tokens.
* Decodes the state param to determine the redirect target.
*
* Usage:
* GET /api/auth/bridge?code=...&state=base64({redirect,clientId,app})
*
* The state is a base64-encoded JSON object:
* { redirect: "https://platform.hanzo.ai/login", clientId: "...", app: "platform" }
*/
import { NextRequest, NextResponse } from 'next/server'
import { getIamUrl } from '@/lib/iam'
export const runtime = 'edge'
// Allowed redirect origins for security
const ALLOWED_ORIGINS = [
'https://platform.hanzo.ai',
'https://console.hanzo.ai',
'https://cloud.hanzo.ai',
'https://mpc.hanzo.ai',
'https://mpc.lux.network',
'https://mpc.zoo.network',
'https://mpc.pars.network',
'https://commerce.hanzo.ai',
'https://billing.hanzo.ai',
'https://analytics.hanzo.ai',
'https://insights.hanzo.ai',
'https://hanzo.ai',
'https://lux.id',
'https://zoo.id',
'https://pars.id',
'https://hanzo.id',
...(process.env.NODE_ENV !== 'production' ? [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:4000',
'http://localhost:5173',
] : []),
]
function validateRedirectOrigin(redirect: string, fallback: string): string {
try {
const url = new URL(redirect)
if (ALLOWED_ORIGINS.some(o => url.origin === new URL(o).origin)) {
return redirect
}
} catch {}
return fallback
}
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const directAccessToken = url.searchParams.get('access_token')
const directRefreshToken = url.searchParams.get('refresh_token')
const host = url.hostname
const iamOrigin = getIamUrl(host)
const iamHost = new URL(iamOrigin).host
const defaultRedirect = `${url.origin}/login`
// Reject oversized state
if (state && state.length > 4096) {
return NextResponse.redirect(`${url.origin}/login?error=invalid_state`)
}
// Decode state for redirect target and client info
let redirect = defaultRedirect
let clientId = process.env.NEXT_PUBLIC_CLIENT_ID || 'hanzo-id'
let codeVerifier = ''
try {
const decoded = JSON.parse(atob(state || ''))
if (decoded.redirect) {
redirect = validateRedirectOrigin(decoded.redirect, defaultRedirect)
}
if (decoded.clientId) {
clientId = decoded.clientId
}
if (decoded.code_verifier) {
codeVerifier = decoded.code_verifier
}
} catch {}
const redirectUrl = new URL(redirect)
// Direct token passthrough (from implicit flow / password login)
if (directAccessToken) {
redirectUrl.searchParams.set('access_token', directAccessToken)
redirectUrl.searchParams.set('refresh_token', directRefreshToken || '')
redirectUrl.searchParams.set('provider', 'hanzo')
redirectUrl.searchParams.set('status', '200')
return NextResponse.redirect(redirectUrl.toString())
}
// Authorization code exchange
if (!code) {
redirectUrl.searchParams.set('error', 'no_code')
return NextResponse.redirect(redirectUrl.toString())
}
const callbackUri = `${url.origin}/api/auth/bridge`
const tokenPayload: Record<string, string> = {
grant_type: 'authorization_code',
code,
client_id: clientId,
redirect_uri: callbackUri,
}
// Forward PKCE code_verifier if provided (prevents authorization code interception)
if (codeVerifier) {
tokenPayload.code_verifier = codeVerifier
}
const clientSecret = process.env.IAM_CLIENT_SECRET || process.env.HANZO_IAM_CLIENT_SECRET
if (clientSecret) {
tokenPayload.client_secret = clientSecret
}
const tokenRes = await fetch(`${iamOrigin}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Host': iamHost,
},
body: JSON.stringify(tokenPayload),
})
const tokens = await tokenRes.json().catch(() => ({} as Record<string, unknown>))
if (tokens.access_token) {
redirectUrl.searchParams.set('access_token', tokens.access_token as string)
redirectUrl.searchParams.set('refresh_token', (tokens.refresh_token as string) || '')
redirectUrl.searchParams.set(
'expires_at',
tokens.expires_in
? String(Math.floor(Date.now() / 1000) + Number(tokens.expires_in))
: '0',
)
redirectUrl.searchParams.set('provider', 'hanzo')
redirectUrl.searchParams.set('status', '200')
} else {
redirectUrl.searchParams.set('error', (tokens.error as string) || 'token_exchange_failed')
redirectUrl.searchParams.set(
'error_description',
(tokens.error_description as string) || (tokens.message as string) || 'Failed to exchange code',
)
}
return NextResponse.redirect(redirectUrl.toString())
}
@@ -1,193 +0,0 @@
/**
* Server-side social OAuth callback handler.
*
* When a user logs in via Google/GitHub/etc, the social provider redirects
* back to /callback with ?code=&state=. The IAM SPA callback relies on
* sessionStorage which breaks through our proxy layer, so we handle the
* full exchange server-side.
*
* Flow:
* 1. Decode state (base64 query string or JSON) to extract app/org/provider
* 2. Read _oauth_ctx cookie as fallback context
* 3. POST to IAM /api/login with type:'token' to complete the login
* 4. Redirect to the original redirect_uri with tokens
*/
import { NextRequest, NextResponse } from 'next/server'
import { resolveClient } from '@/lib/clients'
import { getIamUrl } from '@/lib/iam'
export const runtime = 'edge'
// Allowed redirect origins — must match bridge handler allowlist
const ALLOWED_ORIGINS = [
'https://platform.hanzo.ai',
'https://console.hanzo.ai',
'https://cloud.hanzo.ai',
'https://mpc.hanzo.ai',
'https://mpc.lux.network',
'https://mpc.zoo.network',
'https://mpc.pars.network',
'https://commerce.hanzo.ai',
'https://billing.hanzo.ai',
'https://analytics.hanzo.ai',
'https://insights.hanzo.ai',
'https://hanzo.ai',
'https://lux.id',
'https://zoo.id',
'https://pars.id',
'https://hanzo.id',
...(process.env.NODE_ENV !== 'production' ? [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:4000',
'http://localhost:5173',
] : []),
]
function validateRedirectOrigin(redirect: string, fallback: string): string {
try {
const url = new URL(redirect)
if (ALLOWED_ORIGINS.some(o => url.origin === new URL(o).origin)) {
return redirect
}
} catch {}
return fallback
}
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const host = url.hostname
const iamOrigin = getIamUrl(host)
const iamHost = new URL(iamOrigin).host
if (!code || !state) {
return NextResponse.redirect(new URL('/login?error=missing_code_or_state', url.origin))
}
// Reject oversized state to prevent abuse
if (state.length > 4096) {
return NextResponse.redirect(new URL('/login?error=invalid_state', url.origin))
}
// Decode state — IAM encodes as base64 query string or JSON
let stateParams = new URLSearchParams()
let stateObj: Record<string, string> = {}
try {
const decoded = atob(state)
if (decoded.startsWith('?') || decoded.includes('=')) {
stateParams = new URLSearchParams(decoded)
} else {
stateObj = JSON.parse(decoded)
}
} catch {
return NextResponse.redirect(new URL('/login?error=invalid_state', url.origin))
}
// Read _oauth_ctx cookie as fallback
const cookieHeader = request.headers.get('cookie') || ''
let oauthCtx: Record<string, string> = {}
const ctxMatch = cookieHeader.match(/_oauth_ctx=([^;]+)/)
if (ctxMatch) {
try {
oauthCtx = JSON.parse(atob(decodeURIComponent(ctxMatch[1])))
} catch {}
}
// Resolve context from state (primary), cookie (secondary), JSON (tertiary)
const application = stateParams.get('application') || oauthCtx.application || stateObj.application || ''
const provider = stateParams.get('provider') || oauthCtx.provider || stateObj.provider || ''
const method = stateParams.get('method') || stateObj.method || 'link'
const stateClientId = stateParams.get('client_id') || oauthCtx.clientId || ''
const originalRedirectUri = stateParams.get('redirect_uri') || oauthCtx.redirectUri || stateObj.redirectUri || ''
// Resolve organization from client map — ALWAYS prefer client map over untrusted sources
// to prevent cross-tenant org bypass attacks
let organization = ''
if (stateClientId) {
const client = resolveClient(stateClientId)
if (client) organization = client.organization
}
// Only fall back to cookie/state if no client map match, and validate it's a known org
if (!organization) {
const KNOWN_ORGS = ['hanzo', 'lux', 'zoo', 'pars', 'zen', 'adnexus']
const candidateOrg = oauthCtx.organization || stateObj.organization || ''
if (KNOWN_ORGS.includes(candidateOrg)) {
organization = candidateOrg
}
}
// Call IAM to complete the social login
// type:'token' because our IAM version has a bug where type:'code'
// maps to an empty grant_type and fails
const loginRes = await fetch(`${iamOrigin}/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Cookie': cookieHeader,
'Host': iamHost,
},
body: JSON.stringify({
type: 'token',
code,
state: 'hanzo',
redirectUri: `${url.origin}/callback`,
application,
organization,
provider,
method,
}),
})
const loginData = await loginRes.json().catch(() => ({} as Record<string, unknown>))
// Clear the oauth context cookie
const clearCookie = '_oauth_ctx=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'
if (loginData.status === 'ok' && loginData.data) {
// Validate redirect URI against allowlist to prevent open redirect
const candidateRedirect = originalRedirectUri
? originalRedirectUri.replaceAll(iamHost, host)
: `${url.origin}/login`
const targetRedirectUri = validateRedirectOrigin(candidateRedirect, `${url.origin}/login`)
const targetUrl = new URL(targetRedirectUri)
targetUrl.searchParams.set('access_token', loginData.data as string)
targetUrl.searchParams.set('refresh_token', (loginData.data2 as string) || '')
targetUrl.searchParams.set('provider', 'hanzo')
targetUrl.searchParams.set('status', '200')
return new NextResponse(null, {
status: 302,
headers: {
'Location': targetUrl.toString(),
'Set-Cookie': clearCookie,
},
})
}
// Error path — never include sensitive debug info in redirect params
if (originalRedirectUri) {
const candidateError = originalRedirectUri.replaceAll(iamHost, host)
const safeErrorRedirect = validateRedirectOrigin(candidateError, `${url.origin}/login`)
const errorUrl = new URL(safeErrorRedirect)
errorUrl.searchParams.set('error', (loginData.msg as string) || 'social_login_failed')
return new NextResponse(null, {
status: 302,
headers: {
'Location': errorUrl.toString(),
'Set-Cookie': clearCookie,
},
})
}
return new NextResponse(null, {
status: 302,
headers: {
'Location': `${url.origin}/login?error=${encodeURIComponent((loginData.msg as string) || 'social_login_failed')}`,
'Set-Cookie': clearCookie,
},
})
}
-50
View File
@@ -1,50 +0,0 @@
/**
* Server-side logout handler.
*
* Calls IAM to invalidate the session, clears cookies,
* and redirects to the login page.
*/
import { NextRequest, NextResponse } from 'next/server'
import { getIamUrl } from '@/lib/iam'
export const runtime = 'edge'
export async function GET(request: NextRequest) {
const url = new URL(request.url)
const host = url.hostname
const iamOrigin = getIamUrl(host)
const iamHost = new URL(iamOrigin).host
const idTokenHint = url.searchParams.get('id_token_hint') || ''
const postLogoutRedirectUri = url.searchParams.get('post_logout_redirect_uri') || `${url.origin}/login?prompt=login`
const state = url.searchParams.get('state') || ''
// Call IAM logout
const logoutUrl = new URL('/v1/iam/logout', iamOrigin)
logoutUrl.searchParams.set('id_token_hint', idTokenHint)
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri)
logoutUrl.searchParams.set('state', state)
try {
await fetch(logoutUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Host': iamHost,
},
})
} catch {}
return new NextResponse(null, {
status: 302,
headers: {
Location: '/login?prompt=login',
'Set-Cookie': 'iam_session_id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax',
},
})
}
export async function POST(request: NextRequest) {
return GET(request)
}
-177
View File
@@ -1,177 +0,0 @@
'use client'
export const runtime = 'edge'
import { Suspense, useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { exchangeCode } from '@/lib/oauth'
import { getIamUrl, getDefaultClientId } from '@/lib/iam'
/**
* Claim a referral code after successful login/signup.
* Fire-and-forget: never blocks redirect on failure.
*/
function claimReferral(accessToken: string, userId: string, email: string) {
const refCode = sessionStorage.getItem('hanzo_ref_code')
if (!refCode) return
sessionStorage.removeItem('hanzo_ref_code')
fetch('https://commerce.hanzo.ai/api/v1/referral/claim', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ code: refCode, userId, email }),
}).catch(() => {})
}
function CallbackHandler() {
const searchParams = useSearchParams()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
handleCallback()
}, [])
async function handleCallback() {
const errorParam = searchParams.get('error')
if (errorParam) {
setError(searchParams.get('error_description') || errorParam)
return
}
// Token passthrough from social login / bridge callback
const accessToken = searchParams.get('access_token')
if (accessToken) {
localStorage.setItem('hanzo_access_token', accessToken)
const refreshToken = searchParams.get('refresh_token')
if (refreshToken) {
localStorage.setItem('hanzo_refresh_token', refreshToken)
}
const idToken = searchParams.get('id_token')
if (idToken) {
localStorage.setItem('hanzo_id_token', idToken)
}
// Extract user info: prefer id_token (has full claims), fall back to access_token
try {
const idPayload = idToken
? JSON.parse(atob(idToken.split('.')[1]))
: null
const atPayload = JSON.parse(atob(accessToken.split('.')[1]))
const p = idPayload || atPayload
localStorage.setItem('hanzo_user', JSON.stringify({
sub: p.sub || atPayload.sub || atPayload.name,
name: p.name || p.preferred_username || atPayload.name,
displayName: p.displayName || p.name || p.preferred_username,
email: p.email || atPayload.email,
avatar: p.avatar || p.picture || p.permanentAvatar,
}))
claimReferral(accessToken, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
} catch {}
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
if (postLoginRedirect) {
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
window.location.href = postLoginRedirect
} else {
window.location.href = '/account'
}
return
}
// PKCE authorization code flow
const code = searchParams.get('code')
const state = searchParams.get('state')
if (!code || !state) {
setError('Missing authorization code or state')
return
}
try {
const host = window.location.hostname
const iamUrl = getIamUrl(host)
const clientId = getDefaultClientId(host)
const redirectUri = `${window.location.origin}/callback`
const tokens = await exchangeCode({
iamUrl,
code,
state,
clientId,
redirectUri,
})
localStorage.setItem('hanzo_access_token', tokens.access_token)
if (tokens.refresh_token) {
localStorage.setItem('hanzo_refresh_token', tokens.refresh_token)
}
if (tokens.id_token) {
localStorage.setItem('hanzo_id_token', tokens.id_token)
}
// Extract user info: prefer id_token (has full claims), fall back to access_token
try {
const idPayload = tokens.id_token
? JSON.parse(atob(tokens.id_token.split('.')[1]))
: null
const atPayload = JSON.parse(atob(tokens.access_token.split('.')[1]))
const p = idPayload || atPayload
localStorage.setItem('hanzo_user', JSON.stringify({
sub: p.sub || atPayload.sub || atPayload.name,
name: p.name || p.preferred_username || atPayload.name,
displayName: p.displayName || p.name || p.preferred_username,
email: p.email || atPayload.email,
avatar: p.avatar || p.picture || p.permanentAvatar,
}))
claimReferral(tokens.access_token, p.sub || atPayload.sub || atPayload.name, p.email || atPayload.email)
} catch {}
const postLoginRedirect = sessionStorage.getItem('hanzo_auth_post_login_redirect')
if (postLoginRedirect) {
sessionStorage.removeItem('hanzo_auth_post_login_redirect')
window.location.href = postLoginRedirect
} else {
window.location.href = '/account'
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Authentication failed')
}
}
if (error) {
return (
<div className="login-card max-w-md w-full p-8 text-center">
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
<a href="/login" className="link text-sm">Back to login</a>
</div>
)
}
return (
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
<p className="text-zinc-400 text-sm">Completing sign in...</p>
</div>
)
}
export default function CallbackPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<Suspense fallback={
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-zinc-700 border-t-white rounded-full mx-auto mb-4" />
<p className="text-zinc-400 text-sm">Loading...</p>
</div>
}>
<CallbackHandler />
</Suspense>
</div>
)
}
-109
View File
@@ -1,109 +0,0 @@
'use client'
export const runtime = 'edge'
import { useState } from 'react'
import { getIamUrl, getOrg } from '@/lib/iam'
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
const iamUrl = getIamUrl(host)
const org = getOrg(host)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
if (!email) throw new Error('Please enter your email address')
const res = await fetch(`${iamUrl}/api/send-verification-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dest: email,
type: 'reset',
organization: org,
applicationId: `admin/${org}`,
}),
})
const data = await res.json()
if (data.status === 'error') throw new Error(data.msg || 'Failed to send reset email')
setSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-black p-8">
<div className="login-card w-full max-w-md p-8">
<h1 className="text-2xl font-bold text-white mb-2">Reset password</h1>
<p className="text-zinc-400 text-sm mb-6">
{sent
? 'Check your email for a password reset link.'
: 'Enter your email and we\'ll send you a reset link.'}
</p>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
{sent ? (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/20 text-green-400 text-sm">
If an account exists for {email}, you will receive a password reset email shortly.
</div>
<a href="/login" className="block text-center link text-sm">
Back to login
</a>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full pl-10 py-3 rounded-lg"
autoComplete="email"
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50"
>
{isLoading ? 'Sending...' : 'Send reset link'}
</button>
<p className="text-center text-sm text-zinc-500">
Remember your password?{' '}
<a href="/login" className="link">Sign in</a>
</p>
</form>
)}
</div>
</div>
)
}
-68
View File
@@ -1,68 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Default Hanzo colors - overridden by branding config */
--color-primary: #e4e4e7;
--color-primary-text: #09090b;
--color-background: #000000;
--color-surface: #0a0a0a;
--color-text: #ffffff;
--color-text-muted: #a1a1aa;
--color-border: #27272a;
--color-error: #dc2626;
}
body {
background-color: var(--color-background);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Login card styling */
.login-card {
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
}
/* Button styling */
.btn-primary {
background-color: var(--color-primary);
color: var(--color-primary-text);
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
/* Input styling */
.input {
background-color: var(--color-background);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.input:focus {
border-color: var(--color-primary);
outline: none;
box-shadow: 0 0 0 2px rgba(228, 228, 231, 0.2);
}
/* Link styling */
.link {
color: var(--color-primary);
}
.link:hover {
text-decoration: underline;
}
/* Quote card styling */
.quote-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
border-radius: 12px;
padding: 24px;
}
-32
View File
@@ -1,32 +0,0 @@
export const runtime = 'edge'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { staticBranding, defaultBranding, resolveBrandingDomain } from '@/lib/branding'
import './globals.css'
export async function generateMetadata(): Promise<Metadata> {
const headersList = await headers()
const host = headersList.get('host') || 'hanzo.id'
const domain = resolveBrandingDomain(host)
const staticConfig = staticBranding[domain]
const orgName = staticConfig?.orgName || defaultBranding.orgName
return {
title: `${orgName} ID`,
description: `Secure identity for ${orgName}. Sign in, manage your account, and access all ${orgName} services.`,
}
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
)
}
-64
View File
@@ -1,64 +0,0 @@
import { Suspense } from 'react'
import { headers } from 'next/headers'
import { getBranding, staticBranding, defaultBranding, resolveBrandingDomain, BrandingConfig } from '@/lib/branding'
import LoginForm from '@/components/LoginForm'
import MarketingPanel from '@/components/MarketingPanel'
import LanguageDropdown from '@/components/LanguageDropdown'
export const runtime = 'edge'
async function getBrandingForDomain(): Promise<BrandingConfig> {
const headersList = await headers()
const host = headersList.get('host') || 'hanzo.id'
const domain = resolveBrandingDomain(host)
// First check static configs, then fetch from IAM
const staticConfig = staticBranding[domain]
if (staticConfig) {
return { ...defaultBranding, ...staticConfig, domain }
}
return getBranding(domain)
}
export default async function LoginPage() {
const branding = await getBrandingForDomain()
// Generate CSS variables from branding
const cssVars = {
'--color-primary': branding.colors.primary,
'--color-primary-text': branding.colors.primaryText,
'--color-background': branding.colors.background,
'--color-surface': branding.colors.surface,
'--color-text': branding.colors.text,
'--color-text-muted': branding.colors.textMuted,
'--color-border': branding.colors.border,
'--color-error': branding.colors.error,
} as React.CSSProperties
return (
<div className="min-h-screen flex" style={cssVars}>
{/* Left side - Login Form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
<div className="login-card w-full max-w-md p-8">
{/* Logo */}
<div className="flex items-center justify-between mb-8">
<img
src={branding.logo}
alt={branding.orgName}
className="h-10"
/>
<Suspense><LanguageDropdown /></Suspense>
</div>
<Suspense><LoginForm branding={branding} /></Suspense>
</div>
</div>
{/* Right side - Marketing Panel */}
<div className="hidden lg:flex w-1/2 items-center justify-center p-12 bg-gradient-to-br from-black via-zinc-900 to-black">
<MarketingPanel branding={branding} />
</div>
</div>
)
}
-15
View File
@@ -1,15 +0,0 @@
export const runtime = 'edge'
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="text-center">
<h1 className="text-6xl font-bold text-white mb-4">404</h1>
<p className="text-zinc-400 mb-8">Page not found</p>
<a href="/login" className="text-sm text-zinc-500 hover:text-white transition-colors">
Go to login
</a>
</div>
</div>
)
}
-470
View File
@@ -1,470 +0,0 @@
import { headers } from 'next/headers'
import Link from 'next/link'
import { staticBranding, defaultBranding, resolveBrandingDomain, type BrandingConfig } from '@/lib/branding'
export const runtime = 'edge'
// Per-org landing page content
interface LandingContent {
headline: string
description: string
features: { title: string; description: string; icon: string }[]
standards: { label: string; value: string }[]
cta: string
secondaryCta?: { label: string; href: string }
}
const landingContent: Record<string, LandingContent> = {
lux: {
headline: 'Your Identity on Lux',
description: 'Decentralized identity anchored on high-performance blockchain infrastructure. Own your credentials, prove who you are without exposing what you are, and authenticate across the entire Lux ecosystem with one login.',
features: [
{
title: 'Decentralized Identifiers (DIDs)',
description: 'W3C-standard DIDs anchored on Lux Network. Your identity is portable, censorship-resistant, and fully under your control. No central authority can revoke or freeze your credentials.',
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
},
{
title: 'Verifiable Credentials',
description: 'Issue and present tamper-proof credentials — KYC attestations, membership proofs, reputation scores — without revealing unnecessary personal data. Selective disclosure by default.',
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
},
{
title: 'Cross-Chain Single Sign-On',
description: 'One identity across Lux mainnet, subnets, the bridge, DEX, and every ecosystem dApp. OAuth2/OIDC compliant — works with traditional apps too.',
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
},
{
title: 'Post-Quantum Cryptography',
description: 'Forward-looking key management with lattice-based and hash-based signatures. Your identity stays secure against quantum computing threats — today and tomorrow.',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
},
{
title: 'Key Recovery & Social Recovery',
description: 'Lost your keys? Recover your identity through trusted guardians, multi-sig recovery, or hardware backup — no single point of failure.',
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
},
{
title: 'Privacy-Preserving Auth',
description: 'Zero-knowledge proofs let you prove eligibility, age, membership, or accreditation without revealing the underlying data. Your privacy is non-negotiable.',
icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
},
],
standards: [
{ label: 'W3C DID', value: 'Core v1.0' },
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'WebAuthn', value: 'L2' },
{ label: 'FIDO2', value: 'Passkeys' },
],
cta: 'Create Your Lux ID',
secondaryCta: { label: 'Explore Lux Network', href: 'https://lux.network' },
},
pars: {
headline: 'Your Identity on Pars',
description: 'Self-sovereign identity for the next generation of decentralized infrastructure. Own your data, control your credentials, and authenticate across the Pars ecosystem with confidence.',
features: [
{
title: 'Self-Sovereign Identity',
description: 'Your identity belongs to you — not a corporation, not a government. W3C DID-compliant identifiers give you full ownership and portability.',
icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z',
},
{
title: 'Verifiable Credentials',
description: 'Carry tamper-proof digital credentials — academic degrees, professional certifications, KYC attestations — verified on-chain, shared on your terms.',
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
},
{
title: 'Ecosystem-Wide Access',
description: 'One account for all Pars applications, governance, staking, and partner integrations. Standards-based SSO that just works.',
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
},
{
title: 'Privacy by Design',
description: 'Zero-knowledge proofs and selective disclosure — share only what you choose. Prove you\'re eligible without revealing why.',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
},
{
title: 'Multi-Factor Security',
description: 'Hardware keys, biometrics, TOTP, passkeys — layer security however you need. Enterprise-grade protection for every user.',
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
},
{
title: 'Open Standards',
description: 'Built on OAuth 2.0, OpenID Connect, W3C DIDs, and Verifiable Credentials. No vendor lock-in, interoperable with any standards-compliant system.',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
},
],
standards: [
{ label: 'W3C DID', value: 'Core v1.0' },
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'WebAuthn', value: 'L2' },
{ label: 'VC', value: 'Data Model' },
],
cta: 'Create Your Pars ID',
secondaryCta: { label: 'Explore Pars Network', href: 'https://pars.network' },
},
zoo: {
headline: 'Your Identity on Zoo',
description: 'Verifiable research identity for the open AI research network. Collaborate on decentralized science, participate in governance, and build reputation across the Zoo ecosystem.',
features: [
{
title: 'Research Identity',
description: 'A verifiable, portable identity for researchers, contributors, and AI practitioners. Link your publications, models, and contributions to a cryptographic identity you own.',
icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
},
{
title: 'Governance & ZIPs',
description: 'Participate in Zoo Improvement Proposals (ZIPs) with a verified identity. Vote on protocol upgrades, fund allocation, and research priorities.',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
},
{
title: 'Cross-Network Reputation',
description: 'Build reputation that travels with you. Contributions to Zoo, Hanzo, and partner networks all feed into a unified, verifiable reputation graph.',
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
},
{
title: 'Decentralized Science (DeSci)',
description: 'Credential your research contributions on-chain. Peer review, data sharing, and reproducibility — all backed by verifiable credentials.',
icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
},
{
title: 'Privacy-First',
description: 'Selective disclosure lets you prove qualifications without exposing personal data. Research anonymously when you need to.',
icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
},
{
title: 'Open & Interoperable',
description: 'W3C DID, OAuth 2.0, OIDC — standards-based identity that works with ORCID, institutional logins, and any research platform.',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
},
],
standards: [
{ label: 'W3C DID', value: 'Core v1.0' },
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'VC', value: 'Data Model' },
{ label: 'DeSci', value: 'ZIPs' },
],
cta: 'Create Your Zoo ID',
secondaryCta: { label: 'Explore Zoo Network', href: 'https://zoo.ngo' },
},
hanzo: {
headline: 'Your AI Identity',
description: 'One identity across the entire Hanzo AI ecosystem. Secure, standards-based authentication for developers building the future of AI.',
features: [
{
title: 'Unified AI Access',
description: 'Single sign-in to Console, Chat, Cloud, Gateway, and every Hanzo service. One identity, one API key namespace, one billing account.',
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
},
{
title: 'Developer-First Auth',
description: 'OAuth 2.0, OpenID Connect, PKCE, API keys, service tokens — all RFC-standard. SDKs in Python, TypeScript, Go, and Rust. No vendor lock-in.',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
},
{
title: 'Enterprise Security',
description: 'SSO with SAML/OIDC, hardware-backed MFA, fine-grained RBAC, audit logs, and SOC 2 compliance. Built for teams that ship.',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
},
{
title: 'Multi-Tenant Organizations',
description: 'Create organizations, invite team members, assign roles, and scope API keys — all from a single identity. White-label ready for your own domains.',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
},
{
title: 'Passkeys & Biometrics',
description: 'FIDO2 passkeys, Face ID, Touch ID, hardware security keys — passwordless authentication that\'s both more secure and more convenient.',
icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
},
{
title: 'Web3 + Traditional',
description: 'Connect with MetaMask, WalletConnect, or hardware wallets alongside traditional email/password and social login. Bridge Web2 and Web3 seamlessly.',
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
},
],
standards: [
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'WebAuthn', value: 'L2' },
{ label: 'SAML', value: '2.0' },
{ label: 'FIDO2', value: 'Passkeys' },
],
cta: 'Get Started',
secondaryCta: { label: 'Read the Docs', href: 'https://docs.hanzo.ai' },
},
zen: {
headline: 'Your Zen Identity',
description: 'Access frontier AI models with a single identity. Zen LM powers the next generation of language models — your identity unlocks them all.',
features: [
{
title: 'Model Access',
description: 'Authenticate once to access all Zen LM models — from 600M to 480B parameters. Inference, fine-tuning, and evaluation with one API key.',
icon: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
},
{
title: 'Usage & Billing',
description: 'Track model usage, manage API keys, set spending limits, and control team access — all from your Zen ID dashboard.',
icon: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
},
{
title: 'Open Standards',
description: 'OAuth 2.0 / OIDC compliant — integrate with any platform, CI/CD pipeline, or workflow. SDKs for every major language.',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
},
{
title: 'Developer Experience',
description: 'CLI login, API key management, scoped tokens, and seamless integration with development tools. Built for AI engineers.',
icon: 'M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
},
{
title: 'Team Management',
description: 'Create organizations, invite collaborators, and share model access with fine-grained permissions.',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
},
{
title: 'Cross-Ecosystem',
description: 'Your Zen ID works across Hanzo, Lux, Zoo, and partner platforms. One identity, every AI service.',
icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
},
],
standards: [
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'PKCE', value: 'RFC 7636' },
{ label: 'WebAuthn', value: 'L2' },
{ label: 'FIDO2', value: 'Passkeys' },
{ label: 'JWT', value: 'RFC 7519' },
],
cta: 'Get Started with Zen',
secondaryCta: { label: 'Explore Models', href: 'https://zenlm.org' },
},
adnexus: {
headline: 'Your Ad Nexus Identity',
description: 'Secure identity for the programmatic advertising platform. Manage campaigns, analytics, and integrations with enterprise-grade authentication.',
features: [
{
title: 'Campaign Access',
description: 'Single sign-on to all Ad Nexus tools — campaign manager, analytics dashboard, creative studio, and billing.',
icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
},
{
title: 'Team Permissions',
description: 'Role-based access control for agencies and brands. Scoped permissions for campaign managers, analysts, and billing admins.',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
},
{
title: 'Enterprise SSO',
description: 'SAML, OIDC, and OAuth 2.0 federation. Connect your existing identity provider for seamless onboarding.',
icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z',
},
],
standards: [
{ label: 'OAuth 2.0', value: 'RFC 6749' },
{ label: 'OIDC', value: 'Core 1.0' },
{ label: 'SAML', value: '2.0' },
{ label: 'PKCE', value: 'RFC 7636' },
],
cta: 'Get Started',
secondaryCta: { label: 'Learn More', href: 'https://ad.nexus' },
},
}
const defaultLanding = landingContent.hanzo
async function getBrandingForDomain() {
const headersList = await headers()
const host = headersList.get('host') || 'hanzo.id'
const domain = resolveBrandingDomain(host)
const staticConfig = staticBranding[domain]
const branding: BrandingConfig = staticConfig
? { ...defaultBranding, ...staticConfig, domain }
: { ...defaultBranding, domain }
return branding
}
export default async function Home() {
const branding = await getBrandingForDomain()
const content = landingContent[branding.orgId] || defaultLanding
const cssVars = {
'--color-primary': branding.colors.primary,
'--color-primary-text': branding.colors.primaryText,
'--color-background': branding.colors.background,
'--color-surface': branding.colors.surface,
'--color-text': branding.colors.text,
'--color-text-muted': branding.colors.textMuted,
'--color-border': branding.colors.border,
'--color-error': branding.colors.error,
} as React.CSSProperties
return (
<div className="min-h-screen flex flex-col" style={cssVars}>
{/* Nav */}
<nav className="flex items-center justify-between px-6 md:px-12 py-4 border-b border-zinc-800/50">
<div className="flex items-center gap-3">
<img src={branding.logo} alt={branding.orgName} className="h-8" />
</div>
<div className="flex items-center gap-4">
<Link href="/login" className="text-sm text-zinc-400 hover:text-white transition-colors">
Sign In
</Link>
<Link
href="/signup"
className="text-sm px-4 py-2 rounded-lg font-medium transition-opacity hover:opacity-90"
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
>
Get Started
</Link>
</div>
</nav>
{/* Hero */}
<section className="px-6 md:px-12 py-20 md:py-32">
<div className="max-w-4xl mx-auto text-center">
<div
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-sm mb-8"
style={{ borderColor: branding.colors.primary + '40', color: branding.colors.primary }}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Decentralized Identity
</div>
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-white mb-6 leading-tight tracking-tight">
{content.headline}
</h1>
<p className="text-lg md:text-xl text-zinc-400 max-w-2xl mx-auto mb-12 leading-relaxed">
{content.description}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8">
<Link
href="/signup"
className="px-8 py-3.5 rounded-lg font-medium text-lg transition-opacity hover:opacity-90 w-full sm:w-auto"
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
>
{content.cta}
</Link>
{content.secondaryCta ? (
<a
href={content.secondaryCta.href}
className="px-8 py-3.5 rounded-lg font-medium text-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors w-full sm:w-auto text-center"
>
{content.secondaryCta.label}
</a>
) : (
<Link
href="/login"
className="px-8 py-3.5 rounded-lg font-medium text-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors w-full sm:w-auto text-center"
>
Sign In
</Link>
)}
</div>
</div>
</section>
{/* Standards bar */}
<section className="border-y border-zinc-800/50 px-6 md:px-12 py-6">
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-6 md:gap-10">
{content.standards.map((s, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="text-zinc-500">{s.label}</span>
<span className="text-zinc-300 font-mono text-xs px-1.5 py-0.5 rounded bg-zinc-800">{s.value}</span>
</div>
))}
</div>
</section>
{/* Features */}
<section className="px-6 md:px-12 py-20 md:py-28">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Built for the future of identity
</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">
Standards-compliant, privacy-preserving, and designed for decentralized ecosystems.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{content.features.map((feature, i) => (
<div key={i} className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/30 hover:bg-zinc-900/60 transition-colors">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
style={{ backgroundColor: branding.colors.primary + '15' }}
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
style={{ color: branding.colors.primary }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={feature.icon} />
</svg>
</div>
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
<p className="text-sm text-zinc-400 leading-relaxed">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="px-6 md:px-12 py-20">
<div className="max-w-4xl mx-auto text-center">
<div className="p-12 rounded-2xl border border-zinc-800 bg-gradient-to-br from-zinc-900/80 to-zinc-900/30">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Ready to own your identity?
</h2>
<p className="text-zinc-400 mb-8 max-w-lg mx-auto">
Create your {branding.orgName} ID in seconds. Free, open, and yours forever.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
href="/signup"
className="px-8 py-3.5 rounded-lg font-medium text-lg transition-opacity hover:opacity-90"
style={{ backgroundColor: branding.colors.primary, color: branding.colors.primaryText }}
>
{content.cta}
</Link>
<Link
href="/login"
className="text-zinc-400 hover:text-white transition-colors"
>
Already have an account? Sign in
</Link>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-zinc-800/50 px-6 md:px-12 py-8">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-zinc-500">
<div className="flex items-center gap-3">
<img src={branding.logo} alt={branding.orgName} className="h-5 opacity-50" />
<span>&copy; {new Date().getFullYear()} {branding.orgName}</span>
</div>
<div className="flex gap-6">
{branding.links.terms && <a href={branding.links.terms} className="hover:text-zinc-300 transition-colors">Terms</a>}
{branding.links.privacy && <a href={branding.links.privacy} className="hover:text-zinc-300 transition-colors">Privacy</a>}
{branding.links.docs && <a href={branding.links.docs} className="hover:text-zinc-300 transition-colors">Documentation</a>}
{branding.links.home && <a href={branding.links.home} className="hover:text-zinc-300 transition-colors">{branding.orgName}</a>}
</div>
</div>
</footer>
</div>
)
}
-56
View File
@@ -1,56 +0,0 @@
import { Suspense } from 'react'
import { headers } from 'next/headers'
import { getBranding, staticBranding, defaultBranding, resolveBrandingDomain, BrandingConfig } from '@/lib/branding'
import SignUpForm from '@/components/SignUpForm'
import MarketingPanel from '@/components/MarketingPanel'
export const runtime = 'edge'
async function getBrandingForDomain(): Promise<BrandingConfig> {
const headersList = await headers()
const host = headersList.get('host') || 'hanzo.id'
const domain = resolveBrandingDomain(host)
const staticConfig = staticBranding[domain]
if (staticConfig) {
return { ...defaultBranding, ...staticConfig, domain }
}
return getBranding(domain)
}
export default async function SignUpPage() {
const branding = await getBrandingForDomain()
const cssVars = {
'--color-primary': branding.colors.primary,
'--color-primary-text': branding.colors.primaryText,
'--color-background': branding.colors.background,
'--color-surface': branding.colors.surface,
'--color-text': branding.colors.text,
'--color-text-muted': branding.colors.textMuted,
'--color-border': branding.colors.border,
'--color-error': branding.colors.error,
} as React.CSSProperties
return (
<div className="min-h-screen flex" style={cssVars}>
<div className="w-full lg:w-1/2 flex items-center justify-center p-8">
<div className="login-card w-full max-w-md p-8">
<div className="flex items-center justify-between mb-8">
<img
src={branding.logo}
alt={branding.logoAlt || branding.orgName}
className="h-10"
/>
</div>
<Suspense><SignUpForm branding={branding} /></Suspense>
</div>
</div>
<div className="hidden lg:flex w-1/2 items-center justify-center p-12 bg-gradient-to-br from-black via-zinc-900 to-black">
<MarketingPanel branding={branding} />
</div>
</div>
)
}
@@ -1,148 +0,0 @@
'use client'
import { useState, useRef, useEffect } from 'react'
const LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' },
{ code: 'zh-TW', label: '繁體中文' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'es', label: 'Español' },
{ code: 'fr', label: 'Français' },
{ code: 'de', label: 'Deutsch' },
{ code: 'pt', label: 'Português' },
{ code: 'pt-BR', label: 'Português (BR)' },
{ code: 'it', label: 'Italiano' },
{ code: 'nl', label: 'Nederlands' },
{ code: 'pl', label: 'Polski' },
{ code: 'cs', label: 'Čeština' },
{ code: 'sk', label: 'Slovenčina' },
{ code: 'hu', label: 'Magyar' },
{ code: 'ro', label: 'Română' },
{ code: 'bg', label: 'Български' },
{ code: 'hr', label: 'Hrvatski' },
{ code: 'sr', label: 'Српски' },
{ code: 'sl', label: 'Slovenščina' },
{ code: 'uk', label: 'Українська' },
{ code: 'ru', label: 'Русский' },
{ code: 'el', label: 'Ελληνικά' },
{ code: 'tr', label: 'Türkçe' },
{ code: 'ar', label: 'العربية' },
{ code: 'fa', label: 'فارسی' },
{ code: 'he', label: 'עברית' },
{ code: 'hi', label: 'हिन्दी' },
{ code: 'bn', label: 'বাংলা' },
{ code: 'ta', label: 'தமிழ்' },
{ code: 'te', label: 'తెలుగు' },
{ code: 'mr', label: 'मराठी' },
{ code: 'gu', label: 'ગુજરાતી' },
{ code: 'kn', label: 'ಕನ್ನಡ' },
{ code: 'ml', label: 'മലയാളം' },
{ code: 'pa', label: 'ਪੰਜਾਬੀ' },
{ code: 'ur', label: 'اردو' },
{ code: 'th', label: 'ไทย' },
{ code: 'vi', label: 'Tiếng Việt' },
{ code: 'id', label: 'Bahasa Indonesia' },
{ code: 'ms', label: 'Bahasa Melayu' },
{ code: 'tl', label: 'Filipino' },
{ code: 'sw', label: 'Kiswahili' },
{ code: 'am', label: 'አማርኛ' },
{ code: 'ha', label: 'Hausa' },
{ code: 'yo', label: 'Yorùbá' },
{ code: 'ig', label: 'Igbo' },
{ code: 'zu', label: 'isiZulu' },
{ code: 'af', label: 'Afrikaans' },
{ code: 'sv', label: 'Svenska' },
{ code: 'da', label: 'Dansk' },
{ code: 'no', label: 'Norsk' },
{ code: 'fi', label: 'Suomi' },
{ code: 'et', label: 'Eesti' },
{ code: 'lv', label: 'Latviešu' },
{ code: 'lt', label: 'Lietuvių' },
{ code: 'ca', label: 'Català' },
{ code: 'eu', label: 'Euskara' },
{ code: 'gl', label: 'Galego' },
{ code: 'ka', label: 'ქართული' },
{ code: 'hy', label: 'Հայերեն' },
{ code: 'az', label: 'Azərbaycan' },
{ code: 'uz', label: 'Oʻzbek' },
{ code: 'kk', label: 'Қазақ' },
{ code: 'mn', label: 'Монгол' },
{ code: 'my', label: 'မြန်မာ' },
{ code: 'km', label: 'ភាសាខ្មែរ' },
{ code: 'lo', label: 'ລາວ' },
{ code: 'ne', label: 'नेपाली' },
{ code: 'si', label: 'සිංහල' },
]
export default function LanguageDropdown() {
const [open, setOpen] = useState(false)
const [lang, setLang] = useState('en')
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
// Read from localStorage or browser language
const saved = localStorage.getItem('hanzo_lang')
if (saved) {
setLang(saved)
} else {
const browserLang = navigator.language.split('-')[0]
const match = LANGUAGES.find(l => l.code === browserLang)
if (match) setLang(match.code)
}
}, [])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelect = (code: string) => {
setLang(code)
localStorage.setItem('hanzo_lang', code)
setOpen(false)
// IAM uses ?lang= param for locale
const url = new URL(window.location.href)
url.searchParams.set('lang', code)
window.location.href = url.toString()
}
const current = LANGUAGES.find(l => l.code === lang) || LANGUAGES[0]
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors"
aria-label="Language"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<span className="text-xs">{current.label}</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-40 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl z-50 py-1 max-h-64 overflow-y-auto">
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => handleSelect(l.code)}
className={`w-full text-left px-3 py-2 text-sm hover:bg-zinc-800 transition-colors ${
l.code === lang ? 'text-white' : 'text-zinc-400'
}`}
>
{l.label}
</button>
))}
</div>
)}
</div>
)
}
-431
View File
@@ -1,431 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import type { BrandingConfig } from '@/lib/branding'
import { passwordLogin, startAuthorize } from '@/lib/oauth'
import { getIamUrl, getOrg, getDefaultClientId } from '@/lib/iam'
import { CLIENT_APP_MAP } from '@/lib/clients'
interface LoginFormProps {
branding: BrandingConfig
}
type AuthMethod = 'password' | 'code' | 'webauthn' | 'faceid'
export default function LoginForm({ branding }: LoginFormProps) {
const searchParams = useSearchParams()
const [authMethod, setAuthMethod] = useState<AuthMethod>('password')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [autoSignIn, setAutoSignIn] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
const iamUrl = getIamUrl(host)
const org = getOrg(host)
const defaultClientId = getDefaultClientId(host)
// OAuth params from query string (when redirected from a client app)
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId') ?? defaultClientId
const redirectUri = searchParams.get('redirect_uri') ?? searchParams.get('redirectUri')
const responseType = searchParams.get('response_type') ?? searchParams.get('responseType')
const scope = searchParams.get('scope')
const state = searchParams.get('state')
const codeChallenge = searchParams.get('code_challenge')
const codeChallengeMethod = searchParams.get('code_challenge_method')
const isOAuthFlow = !!(redirectUri && responseType)
// Resolve the IAM application name from clientId.
// IAM's /api/login expects the application NAME (e.g. "app-hanzobot"),
// not the OAuth client_id (e.g. "hanzobot-client-id").
const [appName, setAppName] = useState<string | null>(null)
// Capture referral code from URL and persist through redirects
useEffect(() => {
if (typeof window === 'undefined') return
const ref = searchParams.get('ref')
if (ref) {
sessionStorage.setItem('hanzo_ref_code', ref)
}
}, [searchParams])
useEffect(() => {
if (!clientId) return
const params = new URLSearchParams({
clientId,
type: 'code',
responseType: responseType || 'code',
redirectUri: redirectUri || `${window.location.origin}/callback`,
scope: scope || 'openid profile email',
state: state || '',
})
fetch(`/v1/iam/get-app-login?${params}`)
.then(r => r.json())
.then(data => {
// IAM returns the app data even when status is "error"
// (e.g. redirect URI validation fails but app info is still present)
if (data?.data?.name) {
setAppName(data.data.name)
} else {
// Fallback to static client map
const client = CLIENT_APP_MAP[clientId]
if (client) setAppName(client.application)
}
})
.catch(() => {
// API unreachable — use static client map
const client = CLIENT_APP_MAP[clientId]
if (client) setAppName(client.application)
})
}, [clientId, responseType, redirectUri, scope, state])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
if (!email || !password) {
throw new Error('Please enter your email and password')
}
// Resolve application name: API result > static map > raw clientId
const resolvedApp = appName || CLIENT_APP_MAP[clientId]?.application || clientId
if (isOAuthFlow) {
// OAuth flow: direct code grant via /v1/iam/login with PKCE.
// Pass OAuth params (including code_challenge) as query params so IAM
// binds the authorization code to the PKCE challenge.
const loginParams = new URLSearchParams({
clientId,
responseType: responseType!,
redirectUri: redirectUri!,
...(scope ? { scope } : {}),
...(state ? { state } : {}),
...(codeChallenge ? { code_challenge: codeChallenge } : {}),
...(codeChallengeMethod ? { code_challenge_method: codeChallengeMethod } : {}),
})
const res = await fetch(`/v1/iam/login?${loginParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: responseType === 'token' ? 'token' : 'code',
organization: org,
username: email,
password,
application: resolvedApp,
clientId,
redirectUri: redirectUri!,
state: state || '',
// PKCE: pass in body too (Casdoor may read from body, not just query params)
...(codeChallenge ? { codeChallenge } : {}),
...(codeChallengeMethod ? { codeChallengeMethod } : {}),
}),
})
const data = await res.json()
if (data.status !== 'ok') throw new Error(data.msg || 'Login failed')
// Redirect to the client's callback with the authorization code
const redirect = new URL(redirectUri!)
redirect.searchParams.set('code', data.data)
if (state) redirect.searchParams.set('state', state)
window.location.href = redirect.toString()
} else {
// Direct login: get token, store, redirect to account
// Use origin (same-domain) so the request goes through middleware proxy
const result = await passwordLogin({
iamUrl: window.location.origin,
org,
username: email,
password,
application: resolvedApp,
})
// Store token
localStorage.setItem('hanzo_access_token', result.token)
// Fetch full user profile from userinfo endpoint (access token JWT has limited claims)
try {
const res = await fetch('/oauth/userinfo', {
headers: { Authorization: `Bearer ${result.token}` },
})
if (res.ok) {
const info = await res.json()
localStorage.setItem('hanzo_user', JSON.stringify({
sub: info.sub,
name: info.name || info.preferred_username,
displayName: info.displayName || info.name || info.preferred_username,
email: info.email,
avatar: info.avatar || info.picture || info.permanentAvatar,
}))
}
} catch {}
// Fallback: decode JWT for basic info
if (!localStorage.getItem('hanzo_user')) {
try {
const payload = JSON.parse(atob(result.token.split('.')[1]))
localStorage.setItem('hanzo_user', JSON.stringify({
sub: payload.sub || payload.name,
name: payload.name,
displayName: payload.displayName || payload.name,
email: payload.email || email,
avatar: payload.avatar,
}))
} catch {}
}
// Redirect to account or home
window.location.href = '/account'
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setIsLoading(false)
}
}
const handleSSOLogin = async () => {
const callbackUri = `${window.location.origin}/callback`
await startAuthorize({
iamUrl,
clientId,
redirectUri: callbackUri,
scope: scope ?? 'openid profile email',
})
}
const authTabs = [
{ key: 'password' as const, label: 'Password', enabled: branding.auth.passwordEnabled },
{ key: 'code' as const, label: 'Code', enabled: branding.auth.codeEnabled },
{ key: 'webauthn' as const, label: 'WebAuthn', enabled: branding.auth.webauthnEnabled },
{ key: 'faceid' as const, label: 'Face ID', enabled: branding.auth.faceIdEnabled },
].filter(t => t.enabled)
return (
<div>
{/* Auth method tabs */}
{authTabs.length > 1 && (
<div className="flex gap-4 mb-6 border-b border-zinc-800">
{authTabs.map((tab) => (
<button
key={tab.key}
onClick={() => { setAuthMethod(tab.key); setError(null) }}
className={`pb-3 text-sm font-medium transition-colors ${
authMethod === tab.key
? 'text-white border-b-2'
: 'text-zinc-500 hover:text-zinc-300'
}`}
style={{
borderColor: authMethod === tab.key ? branding.colors.primary : 'transparent'
}}
>
{tab.label}
</button>
))}
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Email/Username */}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input
type="text"
placeholder="Email or username"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full pl-10 py-3 rounded-lg"
autoComplete="email"
disabled={isLoading}
/>
</div>
{/* Password */}
{authMethod === 'password' && (
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input
type={showPassword ? 'text' : 'password'}
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full pl-10 pr-12 py-3 rounded-lg"
autoComplete="current-password"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-zinc-500 hover:text-zinc-300"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{showPassword ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
) : (
<>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</>
)}
</svg>
</button>
</div>
)}
{/* Auto sign in & Forgot password */}
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={autoSignIn}
onChange={(e) => setAutoSignIn(e.target.checked)}
className="w-4 h-4 rounded"
style={{ accentColor: branding.colors.primary }}
/>
<span className="text-zinc-400">Remember me</span>
</label>
<a
href="/forgot-password"
className="link text-sm"
style={{ color: branding.colors.primary }}
>
Forgot password?
</a>
</div>
{/* Sign in button */}
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50 transition-opacity"
style={{ backgroundColor: branding.colors.primary }}
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
{/* Sign up link */}
<p className="text-center text-sm text-zinc-500">
No account?{' '}
<a
href={`/signup${typeof window !== 'undefined' ? window.location.search : ''}`}
className="link"
style={{ color: branding.colors.primary }}
>
Sign up now
</a>
</p>
{/* Social providers / SSO */}
{branding.auth.socialProviders.length > 0 && (
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-zinc-900 text-zinc-500">Or continue with</span>
</div>
</div>
<div className="mt-4 space-y-3">
{/* Connect Wallet — always first */}
{branding.auth.socialProviders.includes('metamask') && (
<button
type="button"
onClick={() => window.location.href = `/oauth/authorize?provider=metamask&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
className="flex items-center justify-center gap-2 w-full py-3 px-4 border rounded-lg font-medium transition-colors"
style={{ borderColor: branding.colors.primary, color: branding.colors.primary }}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="6" width="20" height="14" rx="2" />
<path d="M16 14h.01" />
<path d="M2 10h20" />
</svg>
Connect Wallet
</button>
)}
<div className="grid grid-cols-2 gap-3">
{branding.auth.socialProviders.includes('google') && (
<button
type="button"
onClick={() => window.location.href = `/oauth/authorize?provider=google&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google
</button>
)}
{branding.auth.socialProviders.includes('github') && (
<button
type="button"
onClick={() => window.location.href = `/oauth/authorize?provider=github&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</button>
)}
{branding.auth.socialProviders.includes('apple') && (
<button
type="button"
onClick={() => window.location.href = `/oauth/authorize?provider=apple&client_id=${clientId}&redirect_uri=${encodeURIComponent(window.location.origin + '/callback')}&scope=openid+email+profile&response_type=code`}
className="flex items-center justify-center gap-2 py-2 px-4 border border-zinc-700 rounded-lg hover:bg-zinc-800 transition-colors"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M17.543 12.72c-.026-2.757 2.248-4.08 2.352-4.143-1.282-1.876-3.278-2.134-3.988-2.165-1.698-.173-3.315 1.001-4.179 1.001-.864 0-2.195-.976-3.608-.95-1.858.028-3.57 1.078-4.528 2.74-1.931 3.347-.493 8.293 1.388 11.008.918 1.328 2.012 2.821 3.445 2.77 1.38-.057 1.902-.894 3.57-.894 1.668 0 2.137.894 3.604.867 1.489-.027 2.434-1.355 3.343-2.686 1.054-1.547 1.489-3.044 1.515-3.122-.034-.015-2.908-1.117-2.914-4.426zM14.829 4.793c.76-.92 1.272-2.199 1.131-3.469-1.093.045-2.415.728-3.2 1.648-.706.818-1.323 2.119-1.157 3.362 1.214.093 2.454-.623 3.226-1.541z"/>
</svg>
Apple
</button>
)}
</div>
</div>
</div>
)}
</form>
{/* Footer links */}
<div className="mt-8 pt-6 border-t border-zinc-800 flex justify-center gap-4 text-xs text-zinc-500">
{branding.links.terms && (
<a href={branding.links.terms} className="hover:text-zinc-300">Terms</a>
)}
{branding.links.privacy && (
<a href={branding.links.privacy} className="hover:text-zinc-300">Privacy</a>
)}
{branding.links.support && (
<a href={branding.links.support} className="hover:text-zinc-300">Support</a>
)}
</div>
</div>
)
}
-142
View File
@@ -1,142 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import type { BrandingConfig } from '@/lib/branding'
interface MarketingPanelProps {
branding: BrandingConfig
}
export default function MarketingPanel({ branding }: MarketingPanelProps) {
const [currentQuote, setCurrentQuote] = useState(0)
const quotes = branding.content.quotes || []
// Auto-rotate quotes
useEffect(() => {
if (quotes.length <= 1) return
const interval = setInterval(() => {
setCurrentQuote((prev) => (prev + 1) % quotes.length)
}, 5000)
return () => clearInterval(interval)
}, [quotes.length])
return (
<div className="max-w-md space-y-8">
{/* Badge */}
{branding.content.tagline && (
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-zinc-700 text-sm">
<span className="text-yellow-400">✦</span>
<span className="text-zinc-300">{branding.content.tagline}</span>
</div>
)}
{/* Title */}
{branding.content.title && (
<h2 className="text-4xl font-bold text-white">
{branding.content.title}
</h2>
)}
{/* Subtitle */}
{branding.content.subtitle && (
<p className="text-zinc-400 text-lg">
{branding.content.subtitle}
</p>
)}
{/* Interactive prompt (optional feature showcase) */}
{branding.content.features && branding.content.features.length > 0 && (
<div className="bg-zinc-900/50 rounded-xl p-4 border border-zinc-800">
<div className="text-xs text-zinc-500 mb-2 flex items-center gap-1">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: branding.colors.primary }} />
TRY SOMETHING LIKE
</div>
<p className="text-white">
{branding.content.features[0].description}
</p>
<div className="mt-3 flex items-center gap-2">
<button className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-white">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
</button>
<button className="p-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-white">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
</button>
<div className="flex-1" />
<button
className="flex items-center gap-2 px-4 py-2 rounded-lg font-medium"
style={{
backgroundColor: branding.colors.primary,
color: branding.colors.primaryText
}}
>
<span>✦</span>
Generate
</button>
</div>
</div>
)}
{/* Testimonials/Quotes */}
{quotes.length > 0 && (
<div className="quote-card">
<blockquote className="text-white mb-4">
<span className="text-2xl text-zinc-600">"</span>
{quotes[currentQuote].text}
<span className="text-2xl text-zinc-600">"</span>
</blockquote>
<div className="flex items-center gap-3">
{quotes[currentQuote].avatar ? (
<img
src={quotes[currentQuote].avatar}
alt={quotes[currentQuote].author}
className="w-10 h-10 rounded-full"
/>
) : (
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-medium"
style={{ backgroundColor: branding.colors.primary }}
>
{quotes[currentQuote].author.split(' ').map(n => n[0]).join('').slice(0, 2)}
</div>
)}
<div>
<div className="font-medium text-white">
{quotes[currentQuote].author}
</div>
{(quotes[currentQuote].role || quotes[currentQuote].company) && (
<div className="text-sm text-zinc-500">
{[quotes[currentQuote].role, quotes[currentQuote].company].filter(Boolean).join(', ')}
</div>
)}
</div>
</div>
{/* Quote indicators */}
{quotes.length > 1 && (
<div className="flex justify-center gap-2 mt-4">
{quotes.map((_, i) => (
<button
key={i}
onClick={() => setCurrentQuote(i)}
className={`w-2 h-2 rounded-full transition-colors ${
i === currentQuote ? 'bg-white' : 'bg-zinc-600'
}`}
style={{
backgroundColor: i === currentQuote ? branding.colors.primary : undefined
}}
/>
))}
</div>
)}
</div>
)}
</div>
)
}
-238
View File
@@ -1,238 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import type { BrandingConfig } from '@/lib/branding'
import { getIamUrl, getOrg, getDefaultClientId } from '@/lib/iam'
interface SignUpFormProps {
branding: BrandingConfig
}
export default function SignUpForm({ branding }: SignUpFormProps) {
const searchParams = useSearchParams()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const host = typeof window !== 'undefined' ? window.location.hostname : 'hanzo.id'
const iamUrl = getIamUrl(host)
const org = getOrg(host)
const clientId = searchParams.get('client_id') ?? searchParams.get('clientId') ?? getDefaultClientId(host)
// Resolve the IAM application name from clientId.
// IAM's /api/signup expects the application NAME (e.g. "app-hanzobot"),
// not the OAuth client_id (e.g. "hanzobot-client-id").
const [appName, setAppName] = useState<string | null>(null)
// Capture referral code from URL and persist through redirects
useEffect(() => {
if (typeof window === 'undefined') return
const ref = searchParams.get('ref')
if (ref) {
sessionStorage.setItem('hanzo_ref_code', ref)
}
}, [searchParams])
useEffect(() => {
if (!clientId) return
const params = new URLSearchParams({
clientId,
type: 'code',
responseType: 'code',
redirectUri: `${window.location.origin}/callback`,
scope: 'openid profile email',
state: '',
})
fetch(`/api/get-app-login?${params}`)
.then(r => r.json())
.then(data => {
// IAM returns the app data even when status is "error"
// (e.g. redirect URI validation fails but app info is still present)
if (data?.data?.name) {
setAppName(data.data.name)
}
})
.catch(() => {})
}, [clientId])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
if (!email || !password) {
throw new Error('Please fill in all required fields')
}
if (password.length < 8) {
throw new Error('Password must be at least 8 characters')
}
const username = email.split('@')[0]
const res = await fetch(`${iamUrl}/api/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
organization: org,
application: appName || clientId,
username,
name: username,
displayName: name || username,
email,
password,
}),
})
const data = await res.json()
if (data.status !== 'ok') {
throw new Error(data.msg || 'Sign up failed')
}
// Redirect to login with success message
const loginUrl = new URL('/login', window.location.origin)
// Preserve OAuth params and referral code
const params = ['client_id', 'clientId', 'redirect_uri', 'redirectUri', 'response_type', 'responseType', 'scope', 'state', 'ref']
for (const p of params) {
const v = searchParams.get(p)
if (v) loginUrl.searchParams.set(p, v)
}
loginUrl.searchParams.set('registered', '1')
window.location.href = loginUrl.toString()
} catch (err) {
setError(err instanceof Error ? err.message : 'Sign up failed')
} finally {
setIsLoading(false)
}
}
return (
<div>
<h2 className="text-2xl font-bold text-white mb-2">Create account</h2>
<p className="text-zinc-400 text-sm mb-6">
Sign up for {branding.orgName}
</p>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input
type="text"
placeholder="Full name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input w-full pl-10 py-3 rounded-lg"
autoComplete="name"
disabled={isLoading}
/>
</div>
{/* Email */}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full pl-10 py-3 rounded-lg"
autoComplete="email"
disabled={isLoading}
required
/>
</div>
{/* Password */}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input
type={showPassword ? 'text' : 'password'}
placeholder="Password (min 8 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full pl-10 pr-12 py-3 rounded-lg"
autoComplete="new-password"
disabled={isLoading}
required
minLength={8}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-zinc-500 hover:text-zinc-300"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{showPassword ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
) : (
<>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</>
)}
</svg>
</button>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full py-3 rounded-lg font-medium disabled:opacity-50 transition-opacity"
style={{ backgroundColor: branding.colors.primary }}
>
{isLoading ? 'Creating account...' : 'Create Account'}
</button>
<p className="text-center text-sm text-zinc-500">
Already have an account?{' '}
<a
href={`/login${typeof window !== 'undefined' ? window.location.search : ''}`}
className="link"
style={{ color: branding.colors.primary }}
>
Sign in
</a>
</p>
{/* Terms */}
{(branding.links.terms || branding.links.privacy) && (
<p className="text-center text-xs text-zinc-500 mt-4">
By creating an account, you agree to our{' '}
{branding.links.terms && (
<a href={branding.links.terms} className="hover:text-zinc-300">Terms</a>
)}
{branding.links.terms && branding.links.privacy && ' and '}
{branding.links.privacy && (
<a href={branding.links.privacy} className="hover:text-zinc-300">Privacy Policy</a>
)}
</p>
)}
</form>
</div>
)
}
-838
View File
@@ -1,838 +0,0 @@
/**
* Branding configuration fetched from IAM backend based on domain
*
* Each organization can customize:
* - Logo (URL or base64)
* - Colors (primary, secondary, background, text)
* - Login page content (quotes, testimonials)
* - Links (terms, privacy, support)
* - Features (which auth methods to show)
*/
export interface BrandingConfig {
// Organization info
orgId: string
orgName: string
domain: string
// Visual branding
logo: string
logoAlt?: string
favicon?: string
// Color scheme
colors: {
primary: string // Button color, accents
primaryText: string // Text on primary color
background: string // Page background
surface: string // Card/form background
text: string // Primary text
textMuted: string // Secondary text
border: string // Borders
error: string // Error states
}
// Login page content
content: {
title?: string // Main heading
subtitle?: string // Subheading
tagline?: string // Marketing tagline
quotes?: Quote[] // Testimonials/quotes
features?: Feature[] // Feature highlights
}
// Links
links: {
terms?: string
privacy?: string
support?: string
docs?: string
home?: string
}
// Auth features
auth: {
passwordEnabled: boolean
codeEnabled: boolean // Email/SMS code
webauthnEnabled: boolean // Passkeys
faceIdEnabled: boolean
socialProviders: string[] // google, github, etc
}
}
export interface Quote {
text: string
author: string
role?: string
company?: string
avatar?: string
}
export interface Feature {
title: string
description: string
icon?: string
}
// Default Hanzo branding (fallback)
export const defaultBranding: BrandingConfig = {
orgId: 'hanzo',
orgName: 'Hanzo',
domain: 'hanzo.id',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (monochrome white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000', // Pure black
surface: '#0a0a0a', // Near black
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Start building in seconds',
subtitle: 'Describe your idea and watch AI bring it to life instantly',
tagline: 'AI-powered development',
quotes: [
{
text: 'Hanzo is amazing! It\'s revolutionizing how we build and deploy applications.',
author: 'Developer',
role: 'Software Engineer',
}
],
},
links: {
terms: 'https://hanzo.ai/terms',
privacy: 'https://hanzo.ai/privacy',
support: 'https://hanzo.ai/support',
docs: 'https://docs.hanzo.ai',
home: 'https://hanzo.ai',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
}
// Fetch branding from IAM backend based on domain
export async function getBranding(domain: string): Promise<BrandingConfig> {
const iamUrl = process.env.HANZO_IAM_URL || 'https://api.hanzo.id'
try {
const res = await fetch(`${iamUrl}/api/branding?domain=${encodeURIComponent(domain)}`, {
next: { revalidate: 300 }, // Cache for 5 minutes
})
if (!res.ok) {
console.warn(`Failed to fetch branding for ${domain}, using defaults`)
return defaultBranding
}
const data = await res.json()
return { ...defaultBranding, ...data }
} catch (error) {
console.error(`Error fetching branding for ${domain}:`, error)
return defaultBranding
}
}
// Static branding configs for known domains (can be overridden by IAM)
// Supports both {org}.id format (e.g. lux.id) and id.{domain} format (e.g. id.ad.nexus)
export const staticBranding: Record<string, Partial<BrandingConfig>> = {
'hanzo.id': {
orgId: 'hanzo',
orgName: 'Hanzo',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (monochrome white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
},
'id.hanzo.ai': {
orgId: 'hanzo',
orgName: 'Hanzo',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (monochrome white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
},
'pars.id': {
orgId: 'pars',
orgName: 'Pars Network',
logo: '/logos/pars.svg',
colors: {
primary: '#3b82f6', // Blue
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Pars',
subtitle: 'The decentralized network for the next generation',
},
links: {
terms: 'https://pars.network/terms',
privacy: 'https://pars.network/privacy',
support: 'https://pars.network/support',
docs: 'https://pars.network/docs',
home: 'https://pars.network',
},
},
'id.pars.network': {
orgId: 'pars',
orgName: 'Pars Network',
logo: '/logos/pars.svg',
colors: {
primary: '#3b82f6', // Blue
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Pars',
subtitle: 'The decentralized network for the next generation',
},
links: {
terms: 'https://pars.network/terms',
privacy: 'https://pars.network/privacy',
support: 'https://pars.network/support',
docs: 'https://pars.network/docs',
home: 'https://pars.network',
},
},
'lux.id': {
orgId: 'lux',
orgName: 'Lux Network',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (clean white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
tagline: 'Lux-powered infrastructure',
quotes: [
{
text: "Lux is fast. We deploy chains in minutes, not weeks.",
author: 'Validator',
role: 'Node Operator',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux.network',
},
},
'id.lux.cloud': {
orgId: 'lux',
orgName: 'Lux Network',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
tagline: 'Lux-powered infrastructure',
quotes: [
{
text: "Lux is fast. We deploy chains in minutes, not weeks.",
author: 'Validator',
role: 'Node Operator',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux.network',
},
},
'id.lux.network': {
orgId: 'lux',
orgName: 'Lux Network',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7', // Zinc-200 (clean white)
primaryText: '#09090b', // Zinc-950 (dark text on light buttons)
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Start deploying in seconds',
subtitle: 'High-performance blockchain infrastructure for the Lux ecosystem',
tagline: 'Lux-powered infrastructure',
quotes: [
{
text: "Lux is fast. We deploy chains in minutes, not weeks.",
author: 'Validator',
role: 'Node Operator',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux.network',
},
},
'id.lux-dev.network': {
orgId: 'lux',
orgName: 'Lux Network (Devnet)',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Lux Devnet',
subtitle: 'Development network — unstable, reset frequently',
tagline: 'Lux devnet infrastructure',
quotes: [
{
text: "Break things fast. Devnet resets nightly.",
author: 'Engineer',
role: 'Infra',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux-dev.network',
},
},
'id.lux-test.network': {
orgId: 'lux',
orgName: 'Lux Network (Testnet)',
logo: '/logos/lux.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Lux Testnet',
subtitle: 'Test the full stack before mainnet deploy',
tagline: 'Lux testnet infrastructure',
quotes: [
{
text: "Validator stability tested here for 48h before mainnet promotion.",
author: 'Validator',
role: 'Operator',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://lux.network/terms',
privacy: 'https://lux.network/privacy',
support: 'https://lux.network/support',
docs: 'https://docs.lux.network',
home: 'https://lux-test.network',
},
},
'id-dev.hanzo.ai': {
orgId: 'hanzo',
orgName: 'Hanzo (Dev)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Hanzo Dev',
subtitle: 'Development environment',
tagline: 'AI-powered development',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
},
'id-test.hanzo.ai': {
orgId: 'hanzo',
orgName: 'Hanzo (Test)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Hanzo Test',
subtitle: 'Test environment',
tagline: 'AI-powered development',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
},
'zoolabs.id': {
orgId: 'zoo',
orgName: 'Zoo Labs',
logo: '/logos/zoo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Build the future of DeAI',
subtitle: 'Open AI research + decentralized science for everyone',
tagline: 'Open AI research network',
quotes: [
{
text: "Zoo is where bleeding-edge DeAI experiments actually ship.",
author: 'Researcher',
role: 'ML Engineer',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://zoo.ngo/terms',
privacy: 'https://zoo.ngo/privacy',
support: 'https://zoo.ngo/support',
docs: 'https://zoo.ngo/docs',
home: 'https://zoo.ngo',
},
},
'id.zoo.network': {
orgId: 'zoo',
orgName: 'Zoo Labs',
logo: '/logos/zoo.svg',
colors: {
primary: '#22c55e', // Green
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Build the future of DeAI',
subtitle: 'Open AI research + decentralized science for everyone',
tagline: 'Open AI research network',
quotes: [
{
text: "Zoo is where bleeding-edge DeAI experiments actually ship.",
author: 'Researcher',
role: 'ML Engineer',
}
],
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
terms: 'https://zoo.ngo/terms',
privacy: 'https://zoo.ngo/privacy',
support: 'https://zoo.ngo/support',
docs: 'https://zoo.ngo/docs',
home: 'https://zoo.ngo',
},
},
'id.hanzo.network': {
orgId: 'hanzo',
orgName: 'Hanzo (Network)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Hanzo Network',
subtitle: 'Hanzo network identity',
tagline: 'AI-powered infrastructure',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://hanzo.network',
},
},
'id.hanzo-dev.network': {
orgId: 'hanzo',
orgName: 'Hanzo (Devnet)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Hanzo Devnet',
subtitle: 'Hanzo development network — resets nightly',
tagline: 'Development environment',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://hanzo-dev.network',
},
},
'id.hanzo-test.network': {
orgId: 'hanzo',
orgName: 'Hanzo (Testnet)',
logo: '/logos/hanzo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Hanzo Testnet',
subtitle: 'Hanzo test network — staging before mainnet',
tagline: 'Test environment',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://hanzo-test.network',
},
},
'id.zoo-dev.network': {
orgId: 'zoo',
orgName: 'Zoo Labs (Devnet)',
logo: '/logos/zoo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Zoo Devnet',
subtitle: 'Zoo development network',
tagline: 'Zoo devnet',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://zoo-dev.network',
},
},
'id.zoo-test.network': {
orgId: 'zoo',
orgName: 'Zoo Labs (Testnet)',
logo: '/logos/zoo.svg',
colors: {
primary: '#e4e4e7',
primaryText: '#09090b',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Zoo Testnet',
subtitle: 'Zoo test network',
tagline: 'Zoo testnet',
},
auth: {
passwordEnabled: true,
codeEnabled: true,
webauthnEnabled: true,
faceIdEnabled: true,
socialProviders: ['metamask', 'google', 'github'],
},
links: {
home: 'https://zoo-test.network',
},
},
'zen.id': {
orgId: 'zen',
orgName: 'Zen LM',
logo: '/logos/zen.svg',
colors: {
primary: '#a855f7', // Purple (Zen violet)
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Zen',
subtitle: 'Frontier AI models for everyone',
},
links: {
terms: 'https://zenlm.org/terms',
privacy: 'https://zenlm.org/privacy',
support: 'https://zenlm.org/support',
docs: 'https://zenlm.org/docs',
home: 'https://zenlm.org',
},
},
'id.ad.nexus': {
orgId: 'adnexus',
orgName: 'Ad Nexus',
logo: '/logos/adnexus.svg',
logoAlt: 'Ad Nexus',
colors: {
primary: '#8b5cf6', // Purple
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Ad Nexus',
subtitle: 'Programmatic advertising platform',
},
links: {
terms: 'https://ad.nexus/terms',
privacy: 'https://ad.nexus/privacy',
support: 'https://ad.nexus/support',
home: 'https://ad.nexus',
},
},
'ad.nexus': {
orgId: 'adnexus',
orgName: 'Ad Nexus',
logo: '/logos/adnexus.svg',
logoAlt: 'Ad Nexus',
colors: {
primary: '#8b5cf6', // Purple
primaryText: '#ffffff',
background: '#000000',
surface: '#0a0a0a',
text: '#ffffff',
textMuted: '#a1a1aa',
border: '#27272a',
error: '#dc2626',
},
content: {
title: 'Welcome to Ad Nexus',
subtitle: 'Programmatic advertising platform',
},
links: {
terms: 'https://ad.nexus/terms',
privacy: 'https://ad.nexus/privacy',
support: 'https://ad.nexus/support',
home: 'https://ad.nexus',
},
},
}
// Resolve domain to branding key
// Handles: exact match, id.{domain} → {domain}, {sub}.{domain} patterns
// Runtime-extensible tenants: deployments can ship additional tenant branding via
// TENANT_BRANDING_JSON env var (a JSON object: { domain: BrandingConfig, ... }).
// Downstream tenants and other white-label deployments can override/add tenants
// without modifying this source.
const ENV_TENANTS: Record<string, Partial<BrandingConfig>> = (() => {
try {
const raw = process.env.TENANT_BRANDING_JSON
if (!raw) return {}
const parsed = JSON.parse(raw)
return typeof parsed === 'object' && parsed !== null ? parsed : {}
} catch {
return {}
}
})()
// Merge static (compile-time) + env (runtime) tenants. Env wins.
Object.assign(staticBranding, ENV_TENANTS)
export function resolveBrandingDomain(host: string): string {
const domain = host.split(':')[0]
// Exact match first
if (staticBranding[domain]) return domain
// Try stripping 'id.' prefix: id.ad.nexus → ad.nexus
if (domain.startsWith('id.')) {
const stripped = domain.slice(3)
if (staticBranding[stripped]) return stripped
}
return domain
}
-57
View File
@@ -1,57 +0,0 @@
/**
* Client ID → application/organization map.
*
* Used for resolving the correct IAM application from a client_id,
* e.g. during social login callbacks where we need to know which
* app and org the login belongs to.
*/
export interface ClientInfo {
application: string
organization: string
}
export const CLIENT_APP_MAP: Record<string, ClientInfo> = {
// Hanzo org
'hanzo-platform-client-id': { application: 'app-platform', organization: 'hanzo' },
'hanzo-app-client-id': { application: 'hanzo-id', organization: 'hanzo' },
'hanzo-id': { application: 'hanzo-id', organization: 'hanzo' },
'hanzo-console-client-id': { application: 'app-console', organization: 'hanzo' },
'hanzo-cloud-client-id': { application: 'app-cloud', organization: 'hanzo' },
'kms-client': { application: 'app-kms', organization: 'hanzo' },
'hanzo-kms-client-id': { application: 'app-kms', organization: 'hanzo' },
'hanzo-commerce-client-id': { application: 'app-commerce', organization: 'hanzo' },
'hanzo-team-client-id': { application: 'app-team', organization: 'hanzo' },
'hanzobot-client-id': { application: 'app-hanzobot', organization: 'hanzo' },
'chat-app': { application: 'app-chat', organization: 'hanzo' },
'hanzo-chat-client-id': { application: 'app-hanzo-chat', organization: 'hanzo' },
'hanzo-web3': { application: 'app-hanzo-web3', organization: 'hanzo' },
'app-analytics': { application: 'app-analytics', organization: 'hanzo' },
'app-insights': { application: 'app-insights', organization: 'hanzo' },
'bootnode-web': { application: 'app-bootnode', organization: 'hanzo' },
'zt-console': { application: 'app-zt-console', organization: 'hanzo' },
'hanzo-storage-client-id': { application: 'app-storage', organization: 'hanzo' },
'hanzo-auto-client-id': { application: 'app-auto', organization: 'hanzo' },
'hanzo-flow-client-id': { application: 'app-flow', organization: 'hanzo' },
// Adnexus org
'adnexus-app-client-id': { application: 'app-adnexus', organization: 'adnexus' },
// Lux org
'lux-app-client-id': { application: 'app-lux', organization: 'lux' },
'lux-chat-client-id': { application: 'app-lux-chat', organization: 'lux' },
'lux-kms-client': { application: 'app-lux-kms', organization: 'lux' },
'lux-web3': { application: 'app-lux-web3', organization: 'lux' },
'lux-mpc': { application: 'app-lux-mpc', organization: 'lux' },
// Zoo org
'zoo-app-client-id': { application: 'app-zoo', organization: 'zoo' },
'zoo-web3': { application: 'app-zoo-web3', organization: 'zoo' },
'zoo-mpc': { application: 'app-zoo-mpc', organization: 'zoo' },
// Pars org
'pars-app-client-id': { application: 'app-pars', organization: 'pars' },
'pars-mpc': { application: 'app-pars-mpc', organization: 'pars' },
// Zen org
'zen-app-client-id': { application: 'app-zen', organization: 'zen' },
}
export function resolveClient(clientId: string): ClientInfo | undefined {
return CLIENT_APP_MAP[clientId]
}
-77
View File
@@ -1,77 +0,0 @@
/**
* IAM backend URL resolution.
*
* Maps the login portal domain to the correct IAM backend.
* Configurable via env vars for self-hosted deployments.
*/
// Default domain → IAM URL mapping
const IAM_URLS: Record<string, string> = {
'hanzo.id': 'https://iam.hanzo.ai',
'id.hanzo.ai': 'https://iam.hanzo.ai',
'lux.id': 'https://iam.lux.network',
'id.lux.network': 'https://iam.lux.network',
'zoo.id': 'https://iam.zoo.network',
'id.zoo.network': 'https://iam.zoo.network',
'pars.id': 'https://iam.pars.network',
'id.pars.network': 'https://iam.pars.network',
'zen.id': 'https://iam.hanzo.ai',
'id.ad.nexus': 'https://iam.hanzo.ai',
}
// Default domain → org mapping
const ORG_MAP: Record<string, string> = {
'hanzo.id': 'hanzo',
'id.hanzo.ai': 'hanzo',
'lux.id': 'lux',
'id.lux.network': 'lux',
'zoo.id': 'zoo',
'id.zoo.network': 'zoo',
'pars.id': 'pars',
'id.pars.network': 'pars',
'zen.id': 'zen',
'id.ad.nexus': 'adnexus',
}
// Default domain → default app clientId
const APP_MAP: Record<string, string> = {
'hanzo.id': 'hanzo-id',
'id.hanzo.ai': 'hanzo-id',
'lux.id': 'app-lux',
'id.lux.network': 'app-lux',
'zoo.id': 'app-zoo',
'id.zoo.network': 'app-zoo',
'pars.id': 'app-pars',
'id.pars.network': 'app-pars',
'zen.id': 'app-zen',
'id.ad.nexus': 'app-adnexus',
}
export function getIamUrl(host: string): string {
const domain = host.split(':')[0]
// 1. Check env override (for self-hosted / K8s)
if (typeof process !== 'undefined') {
const envUrl = process.env.NEXT_PUBLIC_IAM_URL || process.env.HANZO_IAM_URL
if (envUrl) return envUrl
}
// 2. Static map
return IAM_URLS[domain] ?? 'https://iam.hanzo.ai'
}
export function getOrg(host: string): string {
const domain = host.split(':')[0]
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_ORG) {
return process.env.NEXT_PUBLIC_ORG
}
return ORG_MAP[domain] ?? 'hanzo'
}
export function getDefaultClientId(host: string): string {
const domain = host.split(':')[0]
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_CLIENT_ID) {
return process.env.NEXT_PUBLIC_CLIENT_ID
}
return APP_MAP[domain] ?? 'hanzo-id'
}
-193
View File
@@ -1,193 +0,0 @@
/**
* OAuth2 / OIDC client with PKCE (RFC 7636)
*
* Works against Hanzo IAM backend via the tenant's iamOrigin.
*/
// --- PKCE helpers ---
function generateRandomString(length: number): string {
const array = new Uint8Array(length)
crypto.getRandomValues(array)
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('').slice(0, length)
}
async function sha256(plain: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder()
return crypto.subtle.digest('SHA-256', encoder.encode(plain))
}
function base64urlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let str = ''
for (const b of bytes) str += String.fromCharCode(b)
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export async function generatePKCE() {
const verifier = generateRandomString(64)
const hashed = await sha256(verifier)
const challenge = base64urlEncode(hashed)
return { verifier, challenge }
}
// --- Types ---
export interface TokenResponse {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
id_token?: string
scope?: string
}
export interface UserInfo {
sub: string
name?: string
displayName?: string
preferred_username?: string
email?: string
avatar?: string
permanentAvatar?: string
picture?: string
owner?: string
}
// --- Token storage (sessionStorage for PKCE, localStorage for session) ---
const PREFIX = 'hanzo_auth_'
export function storeSession(key: string, value: string) {
sessionStorage.setItem(PREFIX + key, value)
}
export function retrieveSession(key: string): string | null {
const val = sessionStorage.getItem(PREFIX + key)
sessionStorage.removeItem(PREFIX + key)
return val
}
// --- Core flows ---
/**
* Password login against IAM /api/login.
* Returns JWT token directly.
*/
export async function passwordLogin(params: {
iamUrl: string
org: string
username: string
password: string
application: string
clientId?: string
redirectUri?: string
}): Promise<{ token: string; code?: string }> {
const url = new URL('/v1/iam/login', params.iamUrl)
// If OAuth params provided, pass as query params (camelCase — IAM convention)
if (params.clientId && params.redirectUri) {
url.searchParams.set('clientId', params.clientId)
url.searchParams.set('responseType', 'code')
url.searchParams.set('redirectUri', params.redirectUri)
url.searchParams.set('scope', 'openid profile email')
}
const res = await fetch(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'token',
organization: params.org,
username: params.username,
password: params.password,
application: params.application,
...(params.clientId ? { clientId: params.clientId } : {}),
...(params.redirectUri ? { redirectUri: params.redirectUri } : {}),
}),
})
const data = await res.json()
if (data.status !== 'ok') {
throw new Error(data.msg || 'Login failed')
}
return { token: data.data }
}
/**
* Start OAuth authorize redirect with PKCE.
*/
export async function startAuthorize(params: {
iamUrl: string
clientId: string
redirectUri: string
scope?: string
}) {
const { verifier, challenge } = await generatePKCE()
const state = generateRandomString(32)
storeSession('pkce_verifier', verifier)
storeSession('oauth_state', state)
const url = new URL('/oauth/authorize', params.iamUrl)
url.searchParams.set('client_id', params.clientId)
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', params.redirectUri)
url.searchParams.set('scope', params.scope ?? 'openid profile email')
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', challenge)
url.searchParams.set('code_challenge_method', 'S256')
window.location.href = url.toString()
}
/**
* Exchange authorization code for tokens.
*/
export async function exchangeCode(params: {
iamUrl: string
code: string
state: string
clientId: string
redirectUri: string
}): Promise<TokenResponse> {
const savedState = retrieveSession('oauth_state')
if (savedState !== params.state) {
throw new Error('OAuth state mismatch')
}
const verifier = retrieveSession('pkce_verifier')
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
...(verifier ? { code_verifier: verifier } : {}),
})
const res = await fetch(`${params.iamUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!res.ok) {
throw new Error(`Token exchange failed: ${res.status}`)
}
return res.json()
}
/**
* Fetch user info.
*/
export async function fetchUserInfo(iamUrl: string, accessToken: string): Promise<UserInfo> {
const res = await fetch(`${iamUrl}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) throw new Error(`Userinfo failed: ${res.status}`)
return res.json()
}
-495
View File
@@ -1,495 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* Next.js middleware — the core proxy layer for Hanzo ID.
*
* Handles:
* 1. Multi-tenant hostname → org/IAM resolution
* 2. RFC 6749/OIDC path normalization (standard → IAM backend paths)
* 3. Social provider redirect with _oauth_ctx cookie
* 4. OIDC discovery body rewriting
* 5. Location header rewriting
*
* This is a white-label login portal. Any domain pointing here gets a
* working OIDC/OAuth2 provider experience. Configure via env vars for
* self-hosted deployments, or use the built-in tenant map.
*/
// --- Tenant configuration ---
interface TenantConfig {
org: string
iamOrigin: string
publicOrigin: string
}
const TENANTS: Record<string, TenantConfig> = {
'hanzo.id': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://hanzo.id',
},
'id.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo.ai',
},
'lux.id': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://lux.id',
},
'iam.lux.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://iam.lux.network',
},
'id.lux.cloud': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux.cloud',
},
'id.lux.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux.network',
},
'id.lux-dev.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux-dev.network',
},
'id.lux-test.network': {
org: 'lux',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.lux-test.network',
},
'id-dev.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id-dev.hanzo.ai',
},
'id-test.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id-test.hanzo.ai',
},
'zoolabs.id': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://zoolabs.id',
},
'id.zoo.network': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.zoo.network',
},
'id.zoo-dev.network': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.zoo-dev.network',
},
'id.zoo-test.network': {
org: 'zoo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.zoo-test.network',
},
'id.hanzo.network': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo.network',
},
'id.hanzo-dev.network': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo-dev.network',
},
'id.hanzo-test.network': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.hanzo-test.network',
},
'pars.id': {
org: 'pars',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://pars.id',
},
'id.pars.network': {
org: 'pars',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.pars.network',
},
'zen.id': {
org: 'zen',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://zen.id',
},
'id.ad.nexus': {
org: 'adnexus',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://id.ad.nexus',
},
'auth.hanzo.ai': {
org: 'hanzo',
iamOrigin: 'https://iam.hanzo.ai',
publicOrigin: 'https://auth.hanzo.ai',
},
}
function getTenant(hostname: string): TenantConfig {
const host = hostname.split(':')[0]
const tenant = TENANTS[host]
if (tenant) return tenant
// Env override for self-hosted / fork deployments
const envIamOrigin = process.env.IAM_ORIGIN || process.env.NEXT_PUBLIC_IAM_URL
return {
org: process.env.NEXT_PUBLIC_ORG || 'hanzo',
iamOrigin: envIamOrigin || 'https://iam.hanzo.ai',
publicOrigin: `https://${host}`,
}
}
// --- RFC path normalization ---
// PATH_REWRITES collapse RFC-standard OAuth/OIDC paths onto IAM's canonical
// surface. IAM exposes `/v1/iam/*` natively — `/api/*` is legacy and not
// part of the canonical surface. Every standard RFC alias funnels into a
// `/v1/iam/*` target, exactly one way.
const PATH_REWRITES: Record<string, string> = {
// NOTE: /oauth/authorize is handled explicitly in middleware() — NOT here.
// RFC 6749 — Token (exchange, refresh, client_credentials all use this)
'/oauth/token': '/v1/iam/login/oauth/access_token',
// RFC 7662 — Token Introspection
'/oauth/introspect': '/v1/iam/login/oauth/introspect',
// RFC 7009 — Token Revocation
'/oauth/revoke': '/v1/iam/login/oauth/revoke',
// OIDC Core — UserInfo
'/oauth/userinfo': '/v1/iam/userinfo',
// OIDC — Logout
'/oauth/logout': '/v1/iam/logout',
// RFC 8628 — Device Authorization
'/oauth/device': '/v1/iam/login/oauth/device',
// JWKS — standard /.well-known/jwks.json → IAM's /.well-known/jwks
'/.well-known/jwks.json': '/.well-known/jwks',
// RFC 8414 — OAuth metadata
'/.well-known/oauth-authorization-server': '/.well-known/openid-configuration',
}
// Paths to proxy to IAM backend (prefix match). `/v1/iam/` is the canonical
// IAM surface — everything else here is RFC-spec aliasing that lands at IAM
// after PATH_REWRITES normalization.
const IAM_PATH_PREFIXES = [
'/v1/iam/',
'/oauth/',
'/login/oauth/',
'/.well-known/',
'/cas/',
'/scim/',
]
// Paths handled by the Next.js app (login UI)
const APP_PATHS = [
'/login',
'/signup',
'/callback',
'/account',
'/forgot-password',
'/logout',
]
function shouldProxyToIAM(pathname: string): boolean {
// Don't proxy Next.js internal paths or our own API routes
if (pathname.startsWith('/_next/')) return false
if (pathname.startsWith('/api/auth/')) return false
if (pathname.startsWith('/api/logout')) return false
// Don't proxy paths handled by the Next.js app
// But DO proxy /login/oauth/* (IAM backend paths rewritten from /oauth/*)
if (pathname.startsWith('/login/oauth/')) return true
for (const p of APP_PATHS) {
if (pathname === p || pathname.startsWith(p + '/')) return false
}
return IAM_PATH_PREFIXES.some(p => pathname.startsWith(p))
}
// --- Social provider redirect handling ---
/**
* When /login/oauth/authorize or /oauth/authorize is called with a ?provider=
* param, we need to:
* 1. Resolve the app/org from the client_id
* 2. Set an _oauth_ctx cookie so the callback handler knows context
* 3. Proxy to IAM which redirects to the social provider
*/
async function handleSocialProviderRedirect(
request: NextRequest,
url: URL,
pathname: string,
tenant: TenantConfig,
): Promise<NextResponse | null> {
if (!url.searchParams.has('provider')) return null
const provider = url.searchParams.get('provider')!
const clientId = url.searchParams.get('client_id') || ''
const iamHost = new URL(tenant.iamOrigin).host
// Resolve app/org via IAM API, fall back to client map
let appName = ''
let appOwner = ''
if (clientId) {
// Dynamic import to keep middleware lean
const { resolveClient } = await import('@/lib/clients')
try {
const loginParams = new URLSearchParams({
clientId,
type: 'code',
responseType: url.searchParams.get('response_type') || 'code',
redirectUri: url.searchParams.get('redirect_uri') || `${url.origin}/callback`,
scope: url.searchParams.get('scope') || 'openid profile email',
state: url.searchParams.get('state') || '',
})
const appLoginRes = await fetch(`${tenant.iamOrigin}/v1/iam/get-app-login?${loginParams}`)
const appLoginData = await appLoginRes.json()
if (appLoginData?.status === 'ok' && appLoginData.data) {
appName = appLoginData.data.name || ''
appOwner = appLoginData.data.owner || appLoginData.data.organization || ''
}
} catch {}
if (!appName) {
const client = resolveClient(clientId)
if (client) {
appName = client.application
appOwner = client.organization
}
}
}
// Store OAuth context in cookie for the callback handler
const oauthContext = JSON.stringify({
application: appName,
organization: appOwner,
provider,
redirectUri: url.searchParams.get('redirect_uri') || `${url.origin}/callback`,
clientId,
})
const oauthContextCookie = `_oauth_ctx=${encodeURIComponent(btoa(oauthContext))}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`
// Proxy to IAM — it manages the full social OAuth flow
const iamUrl = new URL('/login/oauth/authorize' + url.search, tenant.iamOrigin)
const headers = new Headers(request.headers)
headers.set('Host', iamHost)
headers.delete('connection')
const iamResponse = await fetch(iamUrl.toString(), {
method: request.method,
headers,
body: request.body,
redirect: 'manual',
})
const responseHeaders = new Headers(iamResponse.headers)
responseHeaders.append('Set-Cookie', oauthContextCookie)
// Rewrite IAM redirects to our domain
const location = responseHeaders.get('location')
if (location) {
responseHeaders.set('location', location.replaceAll(tenant.iamOrigin, tenant.publicOrigin))
}
return new NextResponse(iamResponse.body, {
status: iamResponse.status,
statusText: iamResponse.statusText,
headers: responseHeaders,
})
}
// --- Callback routing ---
/**
* Route /callback to the right handler:
* - If ?code=&state= present and state looks like a social callback → server-side handler
* - Otherwise → let the Next.js callback page handle it (PKCE flow)
*/
function isSocialCallback(url: URL): boolean {
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
if (!code || !state) return false
// IAM social callbacks have base64-encoded state starting with "?"
try {
const decoded = atob(state)
if (decoded.startsWith('?') || decoded.includes('application=')) return true
} catch {}
return false
}
// --- Main middleware ---
export async function middleware(request: NextRequest) {
const url = new URL(request.url)
let pathname = url.pathname
const hostname = url.hostname
const tenant = getTenant(hostname)
// Route social callbacks to server-side handler
if (pathname === '/callback' && isSocialCallback(url)) {
const socialUrl = new URL('/api/auth/social-callback' + url.search, url.origin)
return NextResponse.rewrite(socialUrl)
}
// Handle /oauth/authorize and /login/oauth/authorize
if (pathname === '/oauth/authorize' || pathname === '/login/oauth/authorize') {
// Social login: proxy to IAM with ?provider= param
if (url.searchParams.has('provider')) {
const socialResponse = await handleSocialProviderRedirect(request, url, pathname, tenant)
if (socialResponse) return socialResponse
}
// If user already has an IAM session, try to proxy to IAM to complete OAuth authorize.
// IAM will auto-authorize and redirect (3xx) if the session is valid for this app.
// If IAM returns 200 (its built-in login page), fall through to our own login UI.
const sessionCookie = request.cookies.get('iam_session_id')?.value
if (sessionCookie) {
const iamUrl = new URL('/login/oauth/authorize' + url.search, tenant.iamOrigin)
const iamHost = new URL(tenant.iamOrigin).host
const headers = new Headers(request.headers)
headers.set('Host', iamHost)
headers.set('Cookie', `iam_session_id=${sessionCookie}`)
headers.delete('connection')
const iamResponse = await fetch(iamUrl.toString(), {
method: 'GET',
headers,
redirect: 'manual',
})
// Only use IAM's response if it's a redirect (auto-authorize succeeded).
// If IAM returns 200 (its login page), fall through to our own login UI.
if (iamResponse.status >= 300 && iamResponse.status < 400) {
const response = new NextResponse(iamResponse.body, {
status: iamResponse.status,
statusText: iamResponse.statusText,
headers: iamResponse.headers,
})
const location = response.headers.get('location')
if (location) {
response.headers.set(
'location',
location.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
)
}
return response
}
// IAM didn't auto-authorize — show our own login UI below
}
// Show our own login UI with OAuth context
// (Don't proxy to IAM's built-in SPA — hanzo.id IS the login UI)
const loginUrl = new URL('/login' + url.search, url.origin)
return NextResponse.redirect(loginUrl)
}
// Apply RFC path normalization
const rewrittenPath = PATH_REWRITES[pathname]
if (rewrittenPath) {
pathname = rewrittenPath
}
// Proxy IAM paths to backend
if (shouldProxyToIAM(pathname)) {
const iamUrl = new URL(pathname + url.search, tenant.iamOrigin)
const iamHost = new URL(tenant.iamOrigin).host
const headers = new Headers(request.headers)
headers.set('Host', iamHost)
headers.delete('connection')
const iamResponse = await fetch(iamUrl.toString(), {
method: request.method,
headers,
body: request.body,
redirect: 'manual',
})
// Rewrite OIDC discovery documents.
//
// IAM advertises a mix of canonical (`/v1/iam/login/oauth/*`, `/v1/iam/userinfo`)
// and OAuth2-spec (`/login/oauth/*`, `/oauth/*`) endpoints. The public RFC
// shape on this domain is `/oauth/*` — collapse both legacy `/api/*` and
// canonical `/v1/iam/*` rewrites onto `/oauth/*` so OIDC clients see the
// standard surface. PATH_REWRITES handles the inbound direction
// (RFC → canonical `/v1/iam/*` for proxying).
const isDiscovery = pathname === '/.well-known/openid-configuration'
|| pathname === '/.well-known/oauth-authorization-server'
if (isDiscovery && iamResponse.ok) {
const contentType = iamResponse.headers.get('content-type') || ''
if (contentType.includes('json')) {
try {
let body = await iamResponse.text()
// Rewrite IAM backend origin to public tenant origin
body = body.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
// Normalize IAM backend paths (both canonical /v1/iam/* and
// legacy /api/*) to RFC standard /oauth/* surface.
body = body.replaceAll('/v1/iam/login/oauth/authorize', '/oauth/authorize')
body = body.replaceAll('/v1/iam/login/oauth/access_token', '/oauth/token')
body = body.replaceAll('/v1/iam/login/oauth/refresh_token', '/oauth/token')
body = body.replaceAll('/v1/iam/login/oauth/introspect', '/oauth/introspect')
body = body.replaceAll('/v1/iam/login/oauth/revoke', '/oauth/revoke')
body = body.replaceAll('/v1/iam/login/oauth/device', '/oauth/device')
body = body.replaceAll('/v1/iam/userinfo', '/oauth/userinfo')
body = body.replaceAll('/v1/iam/logout', '/oauth/logout')
body = body.replaceAll('/login/oauth/authorize', '/oauth/authorize')
body = body.replaceAll('/login/oauth/logout', '/oauth/logout')
return new NextResponse(body, {
status: iamResponse.status,
headers: iamResponse.headers,
})
} catch {}
}
}
// Clone response and rewrite redirect Location headers
const response = new NextResponse(iamResponse.body, {
status: iamResponse.status,
statusText: iamResponse.statusText,
headers: iamResponse.headers,
})
const location = response.headers.get('location')
if (location) {
response.headers.set(
'location',
location.replaceAll(tenant.iamOrigin, tenant.publicOrigin)
)
}
return response
}
return NextResponse.next()
}
export const config = {
matcher: [
// /v1/iam/* is the canonical IAM surface — this must match so the
// middleware proxies it to IAM_ORIGIN. Without this, CF Pages returns
// 405 for POST /v1/iam/login because the static SPA has no POST handler.
'/v1/iam/:path*',
'/oauth/:path*',
'/login/oauth/:path*',
'/.well-known/:path*',
'/callback',
'/cas/:path*',
'/scim/:path*',
],
}
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-15
View File
@@ -1,15 +0,0 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
}
export default nextConfig
-30
View File
@@ -1,30 +0,0 @@
{
"name": "@hanzo/id",
"version": "0.1.0",
"private": true,
"description": "White-label login portal for Hanzo IAM - forkable, multi-tenant, RFC-compliant OAuth2/OIDC",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"pages:build": "npx @cloudflare/next-on-pages 2>&1 || true && node scripts/patch-not-found.mjs && npx @cloudflare/next-on-pages --skip-build",
"deploy": "pnpm pages:build && wrangler pages deploy .vercel/output/static --project-name hanzo-id --commit-dirty=true",
"deploy:docker": "docker build -t hanzo-id . && docker push ghcr.io/hanzoai/id:latest"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"typescript": "^5.0.0",
"wrangler": "^3.0.0"
}
}
-2453
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config
-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#8b5cf6">Ad Nexus</text>
</svg>

Before

Width:  |  Height:  |  Size: 226 B

-14
View File
@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 32" fill="none">
<!-- H mark -->
<g transform="translate(0,0) scale(0.478)">
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/>
<path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="#DDDDDD"/>
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/>
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/>
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/>
<path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="#DDDDDD"/>
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/>
</g>
<!-- Wordmark -->
<text x="42" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Inter', sans-serif" font-size="24" font-weight="600" fill="white">Hanzo</text>
</svg>

Before

Width:  |  Height:  |  Size: 829 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#e4e4e7">Lux</text>
</svg>

Before

Width:  |  Height:  |  Size: 221 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#3b82f6">Pars</text>
</svg>

Before

Width:  |  Height:  |  Size: 222 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#a855f7">Zen</text>
</svg>

Before

Width:  |  Height:  |  Size: 221 B

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 32" fill="none">
<text x="0" y="24" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" font-weight="700" fill="#22c55e">Zoo</text>
</svg>

Before

Width:  |  Height:  |  Size: 221 B

-31
View File
@@ -1,31 +0,0 @@
/**
* Workaround for @cloudflare/next-on-pages + Next.js 15
*
* Next.js 15 generates /_not-found as a Node.js function even when
* the root layout exports `runtime = 'edge'`. This script removes
* the _not-found function and its route from the Vercel build output
* so next-on-pages can proceed. The middleware handles 404s anyway.
*/
import { rmSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
const outDir = join(process.cwd(), '.vercel', 'output')
const funcDir = join(outDir, 'functions')
// Remove _not-found function directories
for (const name of ['_not-found.func', '_not-found.rsc.func']) {
try {
rmSync(join(funcDir, name), { recursive: true, force: true })
console.log(`Removed ${name}`)
} catch {}
}
// Remove _not-found route from config.json
const configPath = join(outDir, 'config.json')
const config = JSON.parse(readFileSync(configPath, 'utf8'))
config.routes = (config.routes || []).filter(
(r) => !String(r.dest || '').includes('/_not-found')
)
writeFileSync(configPath, JSON.stringify(config, null, 2))
console.log('Patched config.json — removed _not-found routes')
-20
View File
@@ -1,20 +0,0 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: 'var(--color-primary)',
background: 'var(--color-background)',
surface: 'var(--color-surface)',
},
},
},
plugins: [],
}
export default config
-40
View File
@@ -1,40 +0,0 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+116 -113
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
esbuild: ^0.28.1
uuid: ^11.1.1
importers:
.:
@@ -477,158 +481,158 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/[email protected]7.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
'@esbuild/[email protected]8.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
@@ -2070,8 +2074,8 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
[email protected]7.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
[email protected]8.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
hasBin: true
@@ -3083,9 +3087,8 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@7.0.3:
resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
uuid@11.1.1:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
[email protected]:
@@ -3689,82 +3692,82 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@esbuild/[email protected]7.7':
'@esbuild/[email protected]8.1':
optional: true
'@expo/[email protected](@expo/[email protected])([email protected]([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected])))([email protected]([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected])([email protected]([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])([email protected])':
@@ -6775,34 +6778,34 @@ snapshots:
[email protected]: {}
[email protected]7.7:
[email protected]8.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
'@esbuild/aix-ppc64': 0.28.1
'@esbuild/android-arm': 0.28.1
'@esbuild/android-arm64': 0.28.1
'@esbuild/android-x64': 0.28.1
'@esbuild/darwin-arm64': 0.28.1
'@esbuild/darwin-x64': 0.28.1
'@esbuild/freebsd-arm64': 0.28.1
'@esbuild/freebsd-x64': 0.28.1
'@esbuild/linux-arm': 0.28.1
'@esbuild/linux-arm64': 0.28.1
'@esbuild/linux-ia32': 0.28.1
'@esbuild/linux-loong64': 0.28.1
'@esbuild/linux-mips64el': 0.28.1
'@esbuild/linux-ppc64': 0.28.1
'@esbuild/linux-riscv64': 0.28.1
'@esbuild/linux-s390x': 0.28.1
'@esbuild/linux-x64': 0.28.1
'@esbuild/netbsd-arm64': 0.28.1
'@esbuild/netbsd-x64': 0.28.1
'@esbuild/openbsd-arm64': 0.28.1
'@esbuild/openbsd-x64': 0.28.1
'@esbuild/openharmony-arm64': 0.28.1
'@esbuild/sunos-x64': 0.28.1
'@esbuild/win32-arm64': 0.28.1
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
[email protected]: {}
@@ -7865,7 +7868,7 @@ snapshots:
[email protected]: {}
uuid@7.0.3: {}
uuid@11.1.1: {}
[email protected]: {}
@@ -7873,7 +7876,7 @@ snapshots:
[email protected](@types/[email protected])([email protected])([email protected])([email protected]):
dependencies:
esbuild: 0.27.7
esbuild: 0.28.1
fdir: 6.5.0([email protected])
picomatch: 4.0.4
postcss: 8.5.15
@@ -7917,7 +7920,7 @@ snapshots:
[email protected]:
dependencies:
simple-plist: 1.3.1
uuid: 7.0.3
uuid: 11.1.1
[email protected]:
dependencies:
+10
View File
@@ -1,3 +1,13 @@
packages:
- "apps/*"
- "pkgs/*"
allowBuilds:
esbuild: true
# Security overrides: force patched transitives (Dependabot alerts).
# esbuild <0.28.1 → GHSA-gv7w-rqvm-qjhr (high), GHSA-g7r4-m6w7-qqqr (low)
# uuid <11.1.1 → GHSA-w5hq-g745-h8pq (medium); only pulled by [email protected]
overrides:
esbuild: "^0.28.1"
uuid: "^11.1.1"