Deploy Next.js login UI to CF Pages with RFC-compliant OAuth

- Add edge runtime to all routes for CF Pages compatibility
- Add not-found.tsx and build script to work around next-on-pages _not-found bug
- Fix shouldProxyToIAM: /login/oauth/* must proxy to IAM, not match /login APP_PATH
- Add auth.hanzo.ai tenant to middleware TENANTS map
- Remove output:'standalone' from next.config.ts (not needed for CF Pages)
- Add signup, account, forgot-password, callback pages
- Add social-callback and bridge API routes
- Add client resolver and IAM helper libraries
- Configure wrangler.toml for CF Pages deployment

Custom domains active: hanzo.id, auth.hanzo.ai, lux.id, pars.id,
iam.lux.network, id.lux.network, id.zoo.network (zoo.id pending)
This commit is contained in:
2026-03-05 22:49:47 -08:00
parent 31fc2e407e
commit 9c5d2ccc2f
27 changed files with 3935 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
.git
*.md
LICENSE
+9
View File
@@ -0,0 +1,9 @@
node_modules/
.next/
.vercel/
.wrangler/
out/
*.tsbuildinfo
.env
.env.local
.env.production.local
+54
View File
@@ -0,0 +1,54 @@
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"]
+136
View File
@@ -0,0 +1,136 @@
'use client'
export const runtime = 'edge'
import { useEffect, useState } from 'react'
import { fetchUserInfo } from '@/lib/oauth'
import { getIamUrl } from '@/lib/iam'
interface User {
sub: string
name?: string
displayName?: string
email?: string
avatar?: string
}
export default function AccountPage() {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
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
try {
const host = window.location.hostname
const iamUrl = getIamUrl(host)
const info = await fetchUserInfo(iamUrl, token)
const userData: User = {
sub: info.sub,
name: info.name,
displayName: info.displayName,
email: info.email,
avatar: info.avatar,
}
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'
}
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 border-t-white rounded-full" />
</div>
)
}
return (
<div className="min-h-screen bg-black p-8">
<div className="max-w-2xl mx-auto">
<div className="login-card p-8">
<div className="flex items-center gap-4 mb-8">
{user?.avatar ? (
<img src={user.avatar} alt="" className="w-16 h-16 rounded-full" />
) : (
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center text-2xl font-bold text-zinc-400">
{(user?.displayName || user?.name || user?.email || '?')[0].toUpperCase()}
</div>
)}
<div>
<h1 className="text-2xl font-bold text-white">
{user?.displayName || user?.name || 'User'}
</h1>
{user?.email && (
<p className="text-zinc-400">{user.email}</p>
)}
</div>
</div>
<div className="space-y-4">
<div className="p-4 rounded-lg border border-zinc-800">
<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 className="p-4 rounded-lg border border-zinc-800">
<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 className="p-4 rounded-lg border border-zinc-800">
<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="mt-8 pt-6 border-t border-zinc-800">
<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>
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
/**
* 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://analytics.hanzo.ai',
'https://insights.hanzo.ai',
'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`
// Decode state for redirect target and client info
let redirect = defaultRedirect
let clientId = process.env.NEXT_PUBLIC_CLIENT_ID || 'app-hanzo'
try {
const decoded = JSON.parse(atob(state || ''))
if (decoded.redirect) {
redirect = validateRedirectOrigin(decoded.redirect, defaultRedirect)
}
if (decoded.clientId) {
clientId = decoded.clientId
}
} 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,
}
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())
}
+145
View File
@@ -0,0 +1,145 @@
/**
* 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'
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))
}
// 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 {}
// 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
let organization = oauthCtx.organization || stateObj.organization || ''
if (!organization && stateClientId) {
const client = resolveClient(stateClientId)
if (client) organization = client.organization
}
// 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) {
const targetRedirectUri = originalRedirectUri
? originalRedirectUri.replaceAll(iamHost, host)
: `${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
const debugInfo = JSON.stringify({
msg: loginData.msg || 'unknown',
app: application,
org: organization,
prov: provider,
})
if (originalRedirectUri) {
const errorUrl = new URL(originalRedirectUri.replaceAll(iamHost, host))
errorUrl.searchParams.set('error', (loginData.msg as string) || 'social_login_failed')
errorUrl.searchParams.set('debug', debugInfo)
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
@@ -0,0 +1,50 @@
/**
* 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('/api/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)
}
+139
View File
@@ -0,0 +1,139 @@
'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'
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)
}
// Decode JWT for user info
try {
const payload = JSON.parse(atob(accessToken.split('.')[1]))
localStorage.setItem('hanzo_user', JSON.stringify({
sub: payload.sub || payload.name,
name: payload.name,
displayName: payload.displayName,
email: payload.email,
avatar: payload.avatar,
}))
} 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)
}
try {
const payload = JSON.parse(atob(tokens.access_token.split('.')[1]))
localStorage.setItem('hanzo_user', JSON.stringify({
sub: payload.sub || payload.name,
name: payload.name,
displayName: payload.displayName,
email: payload.email,
avatar: payload.avatar,
}))
} 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
@@ -0,0 +1,109 @@
'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>
)
}
+2
View File
@@ -1,3 +1,5 @@
export const runtime = 'edge'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { staticBranding, defaultBranding, resolveBrandingDomain } from '@/lib/branding'
+4 -1
View File
@@ -1,8 +1,11 @@
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'
export const runtime = 'edge'
async function getBrandingForDomain(): Promise<BrandingConfig> {
const headersList = await headers()
const host = headersList.get('host') || 'hanzo.id'
@@ -51,7 +54,7 @@ export default async function LoginPage() {
</button>
</div>
<LoginForm branding={branding} />
<Suspense><LoginForm branding={branding} /></Suspense>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
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>
)
}
+2
View File
@@ -1,5 +1,7 @@
import { redirect } from 'next/navigation'
export const runtime = 'edge'
export default function Home() {
redirect('/login')
}
+56
View File
@@ -0,0 +1,56 @@
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>
)
}
+198
View File
@@ -0,0 +1,198 @@
'use client'
import { useState } 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)
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 res = await fetch(`${iamUrl}/api/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
organization: org,
application: clientId,
name: email.split('@')[0],
displayName: name || email.split('@')[0],
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
const params = ['client_id', 'clientId', 'redirect_uri', 'redirectUri', 'response_type', 'responseType', 'scope', 'state']
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>
)
}
+26
View File
@@ -236,6 +236,32 @@ export const staticBranding: Record<string, Partial<BrandingConfig>> = {
subtitle: 'Open AI research 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',
+57
View File
@@ -0,0 +1,57 @@
/**
* 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: 'app-hanzo', organization: 'hanzo' },
'app-hanzo': { application: 'app-hanzo', 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]
}
+68
View File
@@ -0,0 +1,68 @@
/**
* 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',
'zoo.id': 'https://iam.zoo.network',
'pars.id': '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',
'zoo.id': 'zoo',
'pars.id': 'pars',
'zen.id': 'zen',
'id.ad.nexus': 'adnexus',
}
// Default domain → default app clientId
const APP_MAP: Record<string, string> = {
'hanzo.id': 'app-hanzo',
'id.hanzo.ai': 'app-hanzo',
'lux.id': 'lux-app-client-id',
'zoo.id': 'zoo-app-client-id',
'pars.id': 'pars-app-client-id',
'zen.id': 'zen-app-client-id',
'id.ad.nexus': 'adnexus-app-client-id',
}
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] ?? 'app-hanzo'
}
+188
View File
@@ -0,0 +1,188 @@
/**
* 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
email?: string
avatar?: 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('/api/login', params.iamUrl)
// If OAuth params provided, pass as query params
if (params.clientId && params.redirectUri) {
url.searchParams.set('client_id', params.clientId)
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', 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,
}),
})
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()
}
+2
View File
@@ -145,6 +145,8 @@ function shouldProxyToIAM(pathname: string): boolean {
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
}
+6
View File
@@ -0,0 +1,6 @@
/// <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.
-1
View File
@@ -1,7 +1,6 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
+8 -3
View File
@@ -2,12 +2,15 @@
"name": "@hanzo/id",
"version": "0.1.0",
"private": true,
"description": "Hosted login pages for Hanzo IAM - configurable per organization",
"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"
"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",
@@ -18,8 +21,10 @@
"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"
"typescript": "^5.0.0",
"wrangler": "^3.0.0"
}
}
+2453
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
/**
* 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')
+19 -5
View File
@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -18,9 +22,19 @@
}
],
"paths": {
"@/*": ["./*"]
}
"@/*": [
"./*"
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+18
View File
@@ -0,0 +1,18 @@
#:schema node_modules/wrangler/config-schema.json
name = "hanzo-id"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
# All IAM login domains are configured as custom domains on this CF Pages project.
# The middleware resolves org-specific branding from the request hostname.
# DNS must be proxied through Cloudflare (orange cloud) for each domain.
#
# Custom domains (add via CF dashboard or `wrangler pages project ...`):
# hanzo.id, auth.hanzo.ai, lux.id, pars.id, zoo.id
# id.lux.network, id.zoo.network, iam.lux.network, id.ad.nexus
[vars]
# Default IAM backend — override per fork
IAM_ORIGIN = "https://iam.hanzo.ai"