diff --git a/.env.example b/.env.example index a329078..e559d41 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,19 @@ # NextAuth NEXTAUTH_URL=https://app.pieced.ch -NEXTAUTH_SECRET= # openssl rand -base64 32 +NEXTAUTH_SECRET= # openssl rand -base64 32 # ZITADEL OIDC ZITADEL_ISSUER=https://auth.pieced.ch ZITADEL_CLIENT_ID= ZITADEL_CLIENT_SECRET= +# ZITADEL Management API (service account PAT for Option B registration) +ZITADEL_SA_PAT= +ZITADEL_PROJECT_ID=367435120493199793 + # LiteLLM (in-cluster) LITELLM_INTERNAL_URL=http://litellm.inference.svc:4000 LITELLM_MASTER_KEY= + +# Portal Database (CloudNativePG) +DATABASE_URL=postgresql://portal:${PORTAL_DB_PASSWORD}@portal-db-rw.pieced-system.svc:5432/portal diff --git a/buildanddeploy.sh b/buildanddeploy.sh new file mode 100644 index 0000000..9a8f9d4 --- /dev/null +++ b/buildanddeploy.sh @@ -0,0 +1,5 @@ +npm install +docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.4 . +docker push registry.c5ai.ch/pieced/pieced-portal:0.1.4 +kubectl rollout restart deployment pieced-portal -n pieced-system + diff --git a/next.config.mjs b/next.config.mjs index f839215..79770af 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -5,6 +5,7 @@ const withNextIntl = createNextIntlPlugin(); /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", + serverExternalPackages: ["pg"], }; export default withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index ca552f1..3fda7c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.1.0", "dependencies": { "@kubernetes/client-node": "^1.4.0", + "@types/pg": "^8.20.0", "next": "^15.5.15", "next-auth": "^5.0.0-beta.30", "next-intl": "^4.9.0", + "pg": "^8.20.0", "react": "^19.1.0", "react-dom": "^19.1.0", "zod": "^3.24.0" @@ -2013,6 +2015,17 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -6073,6 +6086,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6137,6 +6239,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/preact": { "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", @@ -6690,6 +6831,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -7434,6 +7584,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index f8a1820..278808a 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,11 @@ }, "dependencies": { "@kubernetes/client-node": "^1.4.0", + "@types/pg": "^8.20.0", "next": "^15.5.15", "next-auth": "^5.0.0-beta.30", "next-intl": "^4.9.0", + "pg": "^8.20.0", "react": "^19.1.0", "react-dom": "^19.1.0", "zod": "^3.24.0" diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx index ac2f3c6..6c88a7e 100644 --- a/src/app/[locale]/dashboard/page.tsx +++ b/src/app/[locale]/dashboard/page.tsx @@ -2,9 +2,11 @@ import { getSessionUser } from "@/lib/session"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; import { listTenants } from "@/lib/k8s"; +import { getTenantRequestByOrgId } from "@/lib/db"; import { Card, CardHeader } from "@/components/ui/card"; import { StatusBadge } from "@/components/ui/status-badge"; import { UsageDisplay } from "@/components/dashboard/usage-display"; +import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import Link from "next/link"; export default async function DashboardPage() { @@ -136,17 +138,13 @@ export default async function DashboardPage() { (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId ); + // No tenant → check for existing request, show onboarding flow if (!myTenant) { + const existingRequest = await getTenantRequestByOrgId(user.orgId); + const initialState = existingRequest?.status ?? "no_request"; + return (
-
- - Session Debug - -
-    {JSON.stringify(user, null, 2)}
-  
-

{t("title")} @@ -156,16 +154,11 @@ export default async function DashboardPage() {

-
-
-
-
-

- {t("noInstance")} -

-

- {t("noInstanceDescription")} -

+
+
); @@ -227,14 +220,6 @@ export default async function DashboardPage() { > {t("manage")} -
- - Session Debug - -
-    {JSON.stringify(user, null, 2)}
-  
-
); } diff --git a/src/app/[locale]/login/page.tsx b/src/app/[locale]/login/page.tsx index dcdc215..2864100 100644 --- a/src/app/[locale]/login/page.tsx +++ b/src/app/[locale]/login/page.tsx @@ -2,6 +2,7 @@ import { signIn } from "next-auth/react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; export default function LoginPage() { const t = useTranslations("login"); @@ -49,6 +50,16 @@ export default function LoginPage() { > {t("button")} + +

+ {t("noAccount")}{" "} + + {t("register")} + +

diff --git a/src/app/[locale]/register/page.tsx b/src/app/[locale]/register/page.tsx new file mode 100644 index 0000000..bc50ad9 --- /dev/null +++ b/src/app/[locale]/register/page.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { Card } from "@/components/ui/card"; + +type FormState = "idle" | "submitting" | "success" | "error"; + +export default function RegisterPage() { + const t = useTranslations("register"); + const tCommon = useTranslations("common"); + const router = useRouter(); + + const [form, setForm] = useState({ + companyName: "", + givenName: "", + familyName: "", + email: "", + }); + const [state, setState] = useState("idle"); + const [error, setError] = useState(""); + + const handleChange = (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setState("submitting"); + + try { + const res = await fetch("/api/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + companyName: form.companyName, + givenName: form.givenName, + familyName: form.familyName, + email: form.email, + }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Registration failed"); + } + + setState("success"); + } catch (err: any) { + setError(err.message); + setState("error"); + } + }; + + if (state === "success") { + return ( +

+ +
+ + + +
+

+ {t("successTitle")} +

+

+ {t("successDescription")} +

+ +
+
+ ); + } + + return ( +
+
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + +
+ {/* Company name */} +
+ + +
+ + {/* Name row */} +
+
+ + +
+
+ + +
+
+ + {/* Email */} +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ {t("hasAccount")}{" "} + + {tCommon("login")} + +

+
+ +

+ {t("footer")} +

+
+
+ ); +} diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts new file mode 100644 index 0000000..ca0d68d --- /dev/null +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { requirePlatformRole } from "@/lib/session"; +import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db"; +import { createTenant } from "@/lib/k8s"; + +/** + * POST /api/admin/requests/[id]/approve + * Approve a tenant request: create the PiecedTenant CR and update status. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json().catch(() => ({})); + const adminNotes = body.adminNotes as string | undefined; + + const tenantRequest = await getTenantRequestById(id); + if (!tenantRequest) { + return NextResponse.json( + { error: "Request not found" }, + { status: 404 } + ); + } + + if (tenantRequest.status !== "pending") { + return NextResponse.json( + { error: `Request is already ${tenantRequest.status}` }, + { status: 400 } + ); + } + + // Derive tenant name from company name: lowercase, alphanumeric + hyphens + const tenantName = tenantRequest.companyName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`; + + try { + // Create the PiecedTenant CR + await createTenant( + tenantName, + { + displayName: tenantRequest.companyName, + agentName: tenantRequest.agentName, + packages: tenantRequest.packages, + workspaceFiles: tenantRequest.soulMd + ? { "SOUL.md": tenantRequest.soulMd } + : undefined, + }, + { + "pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId, + } + ); + + // Update request status + const updated = await updateTenantRequestStatus(id, "provisioning", { + adminNotes, + tenantName, + }); + + return NextResponse.json({ + message: "Tenant approved and provisioning started.", + request: updated, + tenantName, + }); + } catch (e: any) { + console.error("Failed to create tenant:", e); + return NextResponse.json( + { error: `Failed to create tenant: ${e.message}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/requests/[id]/reject/route.ts b/src/app/api/admin/requests/[id]/reject/route.ts new file mode 100644 index 0000000..bcfe71e --- /dev/null +++ b/src/app/api/admin/requests/[id]/reject/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { requirePlatformRole } from "@/lib/session"; +import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db"; + +/** + * POST /api/admin/requests/[id]/reject + * Reject a tenant request. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { id } = await params; + const body = await request.json().catch(() => ({})); + const adminNotes = body.adminNotes as string | undefined; + + const tenantRequest = await getTenantRequestById(id); + if (!tenantRequest) { + return NextResponse.json({ error: "Request not found" }, { status: 404 }); + } + + if (tenantRequest.status !== "pending") { + return NextResponse.json( + { error: `Request is already ${tenantRequest.status}` }, + { status: 400 } + ); + } + + const updated = await updateTenantRequestStatus(id, "rejected", { + adminNotes, + }); + + return NextResponse.json({ + message: "Request rejected.", + request: updated, + }); +} diff --git a/src/app/api/admin/requests/route.ts b/src/app/api/admin/requests/route.ts new file mode 100644 index 0000000..b81ee0d --- /dev/null +++ b/src/app/api/admin/requests/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { requirePlatformRole } from "@/lib/session"; +import { listTenantRequests } from "@/lib/db"; + +/** + * GET /api/admin/requests + * List all tenant requests. Optionally filter by ?status=pending + */ +export async function GET(request: Request) { + try { + await requirePlatformRole(); + } catch { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const status = searchParams.get("status") as any; + + const requests = await listTenantRequests(status || undefined); + return NextResponse.json(requests); +} diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts new file mode 100644 index 0000000..5a05b80 --- /dev/null +++ b/src/app/api/onboarding/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from "next/server"; +import { getSessionUser } from "@/lib/session"; +import { + createTenantRequest, + getTenantRequestByOrgId, +} from "@/lib/db"; +import { getTenant, listTenants } from "@/lib/k8s"; +import type { OnboardingInput } from "@/types"; +import { z } from "zod"; + +const onboardingSchema = z.object({ + agentName: z.string().min(1).max(50), + soulMd: z.string().max(10_000).optional(), + packages: z.array(z.string()).optional(), + billingAddress: z.object({ + company: z.string().optional(), + street: z.string().optional(), + city: z.string().optional(), + postalCode: z.string().optional(), + country: z.string().optional(), + }), + billingNotes: z.string().max(2000).optional(), +}); + +/** + * GET /api/onboarding + * Returns the current onboarding status for the logged-in user's org. + * Used by the wizard/provisioning UI to poll state. + */ +export async function GET() { + const user = await getSessionUser(); + if (!user) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + // Check if tenant already exists + const allTenants = await listTenants(); + const myTenant = allTenants.find( + (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId + ); + + if (myTenant) { + return NextResponse.json({ + state: "provisioned", + tenant: { + name: myTenant.metadata.name, + phase: myTenant.status?.phase ?? "Pending", + message: myTenant.status?.message, + conditions: myTenant.status?.conditions, + }, + }); + } + + // Check if there's a pending request + const request = await getTenantRequestByOrgId(user.orgId); + + if (!request) { + return NextResponse.json({ state: "no_request" }); + } + + // If approved and tenant_name set, check provisioning status + if ( + request.status === "provisioning" && + request.tenantName + ) { + const tenant = await getTenant(request.tenantName); + if (tenant) { + return NextResponse.json({ + state: "provisioning", + request, + tenant: { + name: tenant.metadata.name, + phase: tenant.status?.phase ?? "Pending", + message: tenant.status?.message, + conditions: tenant.status?.conditions, + }, + }); + } + } + + return NextResponse.json({ + state: request.status, + request, + }); +} + +/** + * POST /api/onboarding + * Submit the onboarding wizard. Creates a tenant_request with status "pending". + * The actual PiecedTenant CR is NOT created yet — admin approval required. + */ +export async function POST(request: Request) { + const user = await getSessionUser(); + if (!user) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + // Check for existing request + const existing = await getTenantRequestByOrgId(user.orgId); + if (existing) { + return NextResponse.json( + { error: "Onboarding request already submitted.", request: existing }, + { status: 409 } + ); + } + + // Check for existing tenant + const allTenants = await listTenants(); + const myTenant = allTenants.find( + (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId + ); + if (myTenant) { + return NextResponse.json( + { error: "Tenant already exists." }, + { status: 409 } + ); + } + + const body = await request.json(); + const parsed = onboardingSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const input: OnboardingInput = parsed.data; + + const tenantRequest = await createTenantRequest({ + zitadelOrgId: user.orgId, + zitadelUserId: user.id, + companyName: user.orgName, + contactName: user.name || user.email, + contactEmail: user.email, + agentName: input.agentName, + soulMd: input.soulMd, + packages: input.packages ?? [], + billingAddress: input.billingAddress, + billingNotes: input.billingNotes, + }); + + return NextResponse.json( + { message: "Onboarding request submitted.", request: tenantRequest }, + { status: 201 } + ); +} diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts new file mode 100644 index 0000000..163a788 --- /dev/null +++ b/src/app/api/register/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { registerCustomer } from "@/lib/zitadel"; +import type { RegistrationInput } from "@/types"; +import { z } from "zod"; + +const registrationSchema = z.object({ + companyName: z.string().min(2).max(100), + givenName: z.string().min(1).max(100), + familyName: z.string().min(1).max(100), + email: z.string().email(), + preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(), +}); + +export async function POST(request: Request) { + try { + const body = await request.json(); + const parsed = registrationSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const input: RegistrationInput = parsed.data; + + const result = await registerCustomer({ + companyName: input.companyName, + email: input.email, + givenName: input.givenName, + familyName: input.familyName, + preferredLanguage: input.preferredLanguage, + }); + + return NextResponse.json( + { + orgId: result.orgId, + userId: result.userId, + message: "Registration successful. You will receive an invitation email to set your password.", + }, + { status: 201 } + ); + } catch (e: any) { + console.error("Registration failed:", e); + + const zitadelMessage = extractZitadelMessage(e.message); + + return NextResponse.json( + { error: zitadelMessage || "Registration failed. Please try again." }, + { status: e.statusCode || 500 } + ); + } +} + +/** + * ZITADEL errors come as: + * "ZITADEL POST /path: 400 {"code":3, "message":"..."}" + * Extract the human-readable "message" field. + */ +function extractZitadelMessage(errorMsg: string): string | null { + try { + const jsonStart = errorMsg.indexOf("{"); + if (jsonStart === -1) return null; + const json = JSON.parse(errorMsg.slice(jsonStart)); + return json.message || null; + } catch { + return null; + } +} diff --git a/src/components/onboarding/onboarding-flow.tsx b/src/components/onboarding/onboarding-flow.tsx new file mode 100644 index 0000000..0cc5be6 --- /dev/null +++ b/src/components/onboarding/onboarding-flow.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { OnboardingWizard } from "./wizard"; +import { ProvisioningStatus } from "./provisioning-status"; + +interface OnboardingFlowProps { + orgName: string; + initialState: "no_request" | "pending" | "approved" | "provisioning" | "rejected"; +} + +/** + * Orchestrates the onboarding experience: + * - no_request → show wizard + * - pending/approved/provisioning/rejected → show status + * - After wizard submission → switch to status polling + */ +export function OnboardingFlow({ orgName, initialState }: OnboardingFlowProps) { + const [showWizard, setShowWizard] = useState(initialState === "no_request"); + + if (showWizard) { + return ( + setShowWizard(false)} + /> + ); + } + + return ; +} diff --git a/src/components/onboarding/provisioning-status.tsx b/src/components/onboarding/provisioning-status.tsx new file mode 100644 index 0000000..9c8589d --- /dev/null +++ b/src/components/onboarding/provisioning-status.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; +import { StatusBadge } from "@/components/ui/status-badge"; + +interface OnboardingState { + state: string; + request?: { + id: string; + status: string; + companyName: string; + agentName: string; + adminNotes?: string; + }; + tenant?: { + name: string; + phase: string; + message?: string; + conditions?: Array<{ + type: string; + status: string; + reason?: string; + message?: string; + lastTransitionTime?: string; + }>; + }; +} + +export function ProvisioningStatus() { + const t = useTranslations("onboarding"); + const [data, setData] = useState(null); + const [error, setError] = useState(""); + + const poll = useCallback(async () => { + try { + const res = await fetch("/api/onboarding"); + if (!res.ok) throw new Error("Failed to fetch status"); + const json = await res.json(); + setData(json); + } catch (err: any) { + setError(err.message); + } + }, []); + + useEffect(() => { + poll(); + + // Poll every 5 seconds while not in a terminal state + const interval = setInterval(() => { + if ( + data?.state === "provisioned" || + data?.state === "rejected" || + data?.state === "active" + ) { + return; + } + poll(); + }, 5000); + + return () => clearInterval(interval); + }, [poll, data?.state]); + + if (error) { + return ( + +
{error}
+
+ ); + } + + if (!data) { + return ( + +
+
+ {t("loading")} +
+ + ); + } + + // Pending admin approval + if (data.state === "pending") { + return ( + +
+
+ + + +
+

+ {t("pendingTitle")} +

+

+ {t("pendingDescription")} +

+
+
+ ); + } + + // Rejected + if (data.state === "rejected") { + return ( + +
+
+ + + +
+

+ {t("rejectedTitle")} +

+

+ {t("rejectedDescription")} +

+ {data.request?.adminNotes && ( +

+ {data.request.adminNotes} +

+ )} +
+
+ ); + } + + // Provisioning in progress + if ( + data.state === "approved" || + data.state === "provisioning" + ) { + const phase = data.tenant?.phase ?? "Pending"; + const conditions = data.tenant?.conditions ?? []; + + return ( + +
+
+
+
+

+ {t("provisioningTitle")} +

+

+ {t("provisioningDescription")} +

+
+ +
+
+ {t("phase")} + +
+ {conditions.map((c, i) => ( +
+ {c.type} + + {c.reason || c.status} + +
+ ))} +
+ + ); + } + + // Provisioned / Running + if (data.state === "provisioned") { + return ( + +
+
+ + + +
+

+ {t("readyTitle")} +

+

+ {t("readyDescription")} +

+ +
+
+ ); + } + + return null; +} diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx new file mode 100644 index 0000000..2441227 --- /dev/null +++ b/src/components/onboarding/wizard.tsx @@ -0,0 +1,488 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; + +type Step = "welcome" | "configure" | "billing" | "confirm"; + +const STEPS: Step[] = ["welcome", "configure", "billing", "confirm"]; + +const DEFAULT_SOUL = `# AI Assistant + +You are a helpful AI assistant for {company}. You are professional, concise, and friendly. + +## Guidelines +- Answer questions accurately and helpfully +- If you don't know something, say so +- Keep responses clear and to the point +- Respect privacy and confidentiality +`; + +const AVAILABLE_PACKAGES = [ + "telegram", + "discord", + "email", + "web-search", + "document-processing", +]; + +interface WizardProps { + orgName: string; + onComplete: () => void; +} + +export function OnboardingWizard({ orgName, onComplete }: WizardProps) { + const t = useTranslations("onboarding"); + const tCommon = useTranslations("common"); + + const [step, setStep] = useState("welcome"); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const [config, setConfig] = useState({ + agentName: "Assistant", + soulMd: DEFAULT_SOUL.replace("{company}", orgName), + packages: [] as string[], + billingAddress: { + company: orgName, + street: "", + city: "", + postalCode: "", + country: "CH", + }, + billingNotes: "", + }); + + const stepIndex = STEPS.indexOf(step); + + const goNext = () => { + if (stepIndex < STEPS.length - 1) setStep(STEPS[stepIndex + 1]); + }; + + const goBack = () => { + if (stepIndex > 0) setStep(STEPS[stepIndex - 1]); + }; + + const togglePackage = (pkg: string) => { + setConfig((prev) => ({ + ...prev, + packages: prev.packages.includes(pkg) + ? prev.packages.filter((p) => p !== pkg) + : [...prev.packages, pkg], + })); + }; + + const handleSubmit = async () => { + setSubmitting(true); + setError(""); + + try { + const res = await fetch("/api/onboarding", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Submission failed"); + } + + onComplete(); + } catch (err: any) { + setError(err.message); + } finally { + setSubmitting(false); + } + }; + + // Step indicator + const StepIndicator = () => ( +
+ {STEPS.map((s, i) => ( +
+
+ {i < STEPS.length - 1 && ( +
+ )} +
+ ))} +
+ ); + + return ( +
+ + + {/* Step: Welcome */} + {step === "welcome" && ( + +
+
+ + + +
+

+ {t("welcomeTitle")} +

+

+ {t("welcomeDescription")} +

+
+ {["swissHosted", "privacy", "customizable"].map((key) => ( +
+ + + {t(`welcomeFeature_${key}`)} + +
+ ))} +
+
+
+ +
+
+ )} + + {/* Step: Configure */} + {step === "configure" && ( + +

+ {t("configureTitle")} +

+

+ {t("configureDescription")} +

+ +
+
+ + + setConfig((prev) => ({ ...prev, agentName: e.target.value })) + } + className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors" + /> +
+ +
+ +