Files
id/legacy-nextjs/app/forgot-password/page.tsx
zeekayandGitHub 43090cdebf rewrite: Vite + @hanzo/gui monorepo (drops CF Worker + Next.js) (#2)
Replaces the Cloudflare Worker (hanzo.id-worker) and the Next.js portal
with a pnpm monorepo following the ~/work/liquidity/id pattern.

Layout:
  apps/web/        Vite + React 19 SPA, embeds @hanzo/gui shell
    k8s/           Deployment(2) + Service + Ingress (4 hosts, 4 TLS)
  pkgs/shared/     @hanzo/id-shared — TenantConfig, resolveTenant,
                                       loadBrand (browser + node)
  pkgs/auth/       @hanzo/id-auth   — AuthClient (wraps @hanzo/iam REST)
                                       + LoginForm/SignupForm/ForgotForm/OTPForm
  pkgs/idv/        @hanzo/id-idv    — pluggable IDV (stub, persona,
                                       onfido, veriff) behind one
                                       IDVProvider interface
  legacy-nextjs/   Frozen — Next.js predecessor. Delete after v0.1.0 ships.
  Dockerfile       Two-stage: pnpm build → hanzoai/static:0.4.1 serves /spa
  README.md
  LLM.md           Architecture, dev, deploy, cutover plan

Tenant resolution: hostname → TenantConfig (orgId, iamUrl, clientId,
appName, publicOrigin, brandPackage). Built-in defaults for
hanzo.id/lux.id/zoo.id/pars.id; runtime override via
IAM_TENANT_CONFIG_JSON env (served as /config.json at pod startup).

Brand resolution: each per-org brand pkg (@hanzo/brand, @luxfi/brand,
@zooai/brand, @parsdao/brand) ships brand.json. The Vite plugin
brandJsonPlugin emits /brand/<pkg>/brand.json verbatim; the browser
fetches the right one based on the resolved tenant. No bundle bloat.

IDV: stub (default for dev), persona, onfido, veriff. Each adapter
implements `IDVProvider` from pkgs/idv/src/provider.ts. Swap providers
with one registration call at boot — portal code unchanged.

Cutover (separate ops PR — not in this commit):
  1. Tag + push image ghcr.io/hanzoai/id:0.1.0
  2. Apply k8s manifests, cert-manager issues TLS
  3. Remove CF Worker routes for hanzo.id/lux.id/zoo.id/pars.id
  4. CF A records → 129.212.164.5 (hanzo ingress LB)
  5. Archive hanzo.id-worker repo
2026-05-24 06:00:20 -07:00

110 lines
3.7 KiB
TypeScript

'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>
)
}