mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
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:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.md
|
||||
LICENSE
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.vercel/
|
||||
.wrangler/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
+54
@@ -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"]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export default function Home() {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -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,7 +1,6 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
+8
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2453
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user