Session 6.3

This commit is contained in:
2026-04-10 21:56:31 +02:00
parent f20d5f09ae
commit 94bfd25553
24 changed files with 2398 additions and 104 deletions

View File

@@ -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

5
buildanddeploy.sh Normal file
View File

@@ -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

View File

@@ -5,6 +5,7 @@ const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["pg"],
};
export default withNextIntl(nextConfig);

159
package-lock.json generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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 (
<div>
<details className="mt-12 text-xs text-text-muted">
<summary className="cursor-pointer hover:text-text-secondary transition-colors">
Session Debug
</summary>
<pre className="mt-3 bg-surface-2 border border-border rounded-lg p-4 overflow-auto font-mono text-[11px] text-text-secondary">
{JSON.stringify(user, null, 2)}
</pre>
</details>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
@@ -156,16 +154,11 @@ export default async function DashboardPage() {
</p>
</div>
<div className="flex flex-col items-center justify-center py-16 text-center animate-in animate-in-delay-1">
<div className="h-14 w-14 rounded-xl bg-accent/15 flex items-center justify-center mb-4">
<div className="h-8 w-8 rounded-lg bg-accent/40" />
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-1">
{t("noInstance")}
</h2>
<p className="text-sm text-text-secondary mb-6 max-w-sm">
{t("noInstanceDescription")}
</p>
<div className="animate-in animate-in-delay-1">
<OnboardingFlow
orgName={user.orgName}
initialState={initialState as any}
/>
</div>
</div>
);
@@ -227,14 +220,6 @@ export default async function DashboardPage() {
>
<span></span> {t("manage")}
</Link>
<details className="mt-12 text-xs text-text-muted">
<summary className="cursor-pointer hover:text-text-secondary transition-colors">
Session Debug
</summary>
<pre className="mt-3 bg-surface-2 border border-border rounded-lg p-4 overflow-auto font-mono text-[11px] text-text-secondary">
{JSON.stringify(user, null, 2)}
</pre>
</details>
</div>
);
}

View File

@@ -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")}
</button>
<p className="text-xs text-text-muted text-center mt-5">
{t("noAccount")}{" "}
<Link
href="/register"
className="text-accent hover:text-accent-dim transition-colors"
>
{t("register")}
</Link>
</p>
</div>
<p className="text-center text-text-muted text-[11px] mt-6 tracking-wide uppercase">

View File

@@ -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<FormState>("idle");
const [error, setError] = useState("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="max-w-md w-full text-center p-8">
<div className="h-14 w-14 rounded-xl bg-emerald-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("successTitle")}
</h2>
<p className="text-sm text-text-secondary mb-6">
{t("successDescription")}
</p>
<button
onClick={() => router.push("/login")}
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("goToLogin")}
</button>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="text-center mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold text-text-primary mb-1">
{t("title")}
</h1>
<p className="text-sm text-text-secondary">{t("subtitle")}</p>
</div>
<Card className="animate-in animate-in-delay-1">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Company name */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("companyName")}
</label>
<input
name="companyName"
type="text"
required
value={form.companyName}
onChange={handleChange}
placeholder={t("companyNamePlaceholder")}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
{/* Name row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("givenName")}
</label>
<input
name="givenName"
type="text"
required
value={form.givenName}
onChange={handleChange}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("familyName")}
</label>
<input
name="familyName"
type="text"
required
value={form.familyName}
onChange={handleChange}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
</div>
{/* Email */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("email")}
</label>
<input
name="email"
type="email"
required
value={form.email}
onChange={handleChange}
placeholder="you@company.ch"
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<button
type="submit"
disabled={state === "submitting"}
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{state === "submitting" ? tCommon("loading") : t("submit")}
</button>
</form>
<p className="text-xs text-text-muted text-center mt-4">
{t("hasAccount")}{" "}
<a
href="/login"
className="text-accent hover:text-accent-dim transition-colors"
>
{tCommon("login")}
</a>
</p>
</Card>
<p className="text-xs text-text-muted text-center mt-6 animate-in animate-in-delay-2">
{t("footer")}
</p>
</div>
</div>
);
}

View File

@@ -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 }
);
}
}

View File

@@ -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,
});
}

View File

@@ -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);
}

View File

@@ -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 }
);
}

View File

@@ -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;
}
}

View File

@@ -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 (
<OnboardingWizard
orgName={orgName}
onComplete={() => setShowWizard(false)}
/>
);
}
return <ProvisioningStatus />;
}

View File

@@ -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<OnboardingState | null>(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 (
<Card>
<div className="text-xs text-red-400">{error}</div>
</Card>
);
}
if (!data) {
return (
<Card>
<div className="flex items-center gap-2 text-sm text-text-muted">
<div className="h-4 w-4 border-2 border-accent border-t-transparent rounded-full animate-spin" />
{t("loading")}
</div>
</Card>
);
}
// Pending admin approval
if (data.state === "pending") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-amber-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("pendingTitle")}
</h2>
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")}
</p>
</div>
</Card>
);
}
// Rejected
if (data.state === "rejected") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-red-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("rejectedTitle")}
</h2>
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("rejectedDescription")}
</p>
{data.request?.adminNotes && (
<p className="text-xs text-text-muted mt-3 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
{data.request.adminNotes}
</p>
)}
</div>
</Card>
);
}
// Provisioning in progress
if (
data.state === "approved" ||
data.state === "provisioning"
) {
const phase = data.tenant?.phase ?? "Pending";
const conditions = data.tenant?.conditions ?? [];
return (
<Card className="animate-in">
<div className="text-center py-4 mb-4">
<div className="h-14 w-14 rounded-xl bg-accent/15 flex items-center justify-center mx-auto mb-4">
<div className="h-6 w-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("provisioningTitle")}
</h2>
<p className="text-sm text-text-secondary">
{t("provisioningDescription")}
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2">
<span className="text-xs text-text-muted">{t("phase")}</span>
<StatusBadge phase={phase} />
</div>
{conditions.map((c, i) => (
<div
key={i}
className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2"
>
<span className="text-xs text-text-muted">{c.type}</span>
<span
className={`text-xs font-mono ${
c.status === "True"
? "text-emerald-400"
: c.status === "False"
? "text-red-400"
: "text-text-muted"
}`}
>
{c.reason || c.status}
</span>
</div>
))}
</div>
</Card>
);
}
// Provisioned / Running
if (data.state === "provisioned") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-emerald-500/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-emerald-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("readyTitle")}
</h2>
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")}
</p>
<button
onClick={() => window.location.reload()}
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("goToDashboard")}
</button>
</div>
</Card>
);
}
return null;
}

View File

@@ -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<Step>("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 = () => (
<div className="flex items-center justify-center gap-2 mb-8">
{STEPS.map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full transition-colors ${
i <= stepIndex ? "bg-accent" : "bg-border"
}`}
/>
{i < STEPS.length - 1 && (
<div
className={`h-px w-8 transition-colors ${
i < stepIndex ? "bg-accent" : "bg-border"
}`}
/>
)}
</div>
))}
</div>
);
return (
<div className="max-w-xl mx-auto">
<StepIndicator />
{/* Step: Welcome */}
{step === "welcome" && (
<Card className="animate-in">
<div className="text-center py-4">
<div className="h-14 w-14 rounded-xl bg-accent/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-accent"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<h2 className="font-display text-xl font-semibold text-text-primary mb-2">
{t("welcomeTitle")}
</h2>
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-6">
{t("welcomeDescription")}
</p>
<div className="space-y-2 text-left max-w-sm mx-auto mb-6">
{["swissHosted", "privacy", "customizable"].map((key) => (
<div key={key} className="flex items-start gap-2">
<span className="text-accent mt-0.5 text-sm"></span>
<span className="text-sm text-text-secondary">
{t(`welcomeFeature_${key}`)}
</span>
</div>
))}
</div>
</div>
<div className="flex justify-end">
<button
onClick={goNext}
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("getStarted")}
</button>
</div>
</Card>
)}
{/* Step: Configure */}
{step === "configure" && (
<Card className="animate-in">
<h2 className="font-display text-lg font-semibold text-text-primary mb-1">
{t("configureTitle")}
</h2>
<p className="text-sm text-text-secondary mb-6">
{t("configureDescription")}
</p>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("agentName")}
</label>
<input
type="text"
value={config.agentName}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("soulMd")}
</label>
<textarea
value={config.soulMd}
onChange={(e) =>
setConfig((prev) => ({ ...prev, soulMd: e.target.value }))
}
rows={8}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary font-mono text-xs focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
/>
<p className="text-xs text-text-muted mt-1">
{t("soulMdHint")}
</p>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-2">
{t("packages")}
</label>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_PACKAGES.map((pkg) => (
<button
key={pkg}
type="button"
onClick={() => togglePackage(pkg)}
className={`text-left px-3 py-2 border rounded-lg text-xs transition-colors ${
config.packages.includes(pkg)
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40"
}`}
>
{pkg}
</button>
))}
</div>
<p className="text-xs text-text-muted mt-1">
{t("packagesHint")}
</p>
</div>
</div>
<div className="flex justify-between mt-6">
<button
onClick={goBack}
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t("back")}
</button>
<button
onClick={goNext}
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("next")}
</button>
</div>
</Card>
)}
{/* Step: Billing */}
{step === "billing" && (
<Card className="animate-in">
<h2 className="font-display text-lg font-semibold text-text-primary mb-1">
{t("billingTitle")}
</h2>
<p className="text-sm text-text-secondary mb-6">
{t("billingDescription")}
</p>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCompany")}
</label>
<input
type="text"
value={config.billingAddress.company}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
company: 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"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingStreet")}
</label>
<input
type="text"
value={config.billingAddress.street}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
street: 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"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingPostalCode")}
</label>
<input
type="text"
value={config.billingAddress.postalCode}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
postalCode: 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"
/>
</div>
<div className="col-span-2">
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCity")}
</label>
<input
type="text"
value={config.billingAddress.city}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
city: 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"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCountry")}
</label>
<input
type="text"
value={config.billingAddress.country}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
country: 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"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingNotes")}
</label>
<textarea
value={config.billingNotes}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingNotes: e.target.value,
}))
}
rows={3}
placeholder={t("billingNotesPlaceholder")}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
/>
</div>
</div>
<div className="flex justify-between mt-6">
<button
onClick={goBack}
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t("back")}
</button>
<button
onClick={goNext}
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("next")}
</button>
</div>
</Card>
)}
{/* Step: Confirm */}
{step === "confirm" && (
<Card className="animate-in">
<h2 className="font-display text-lg font-semibold text-text-primary mb-1">
{t("confirmTitle")}
</h2>
<p className="text-sm text-text-secondary mb-6">
{t("confirmDescription")}
</p>
<div className="space-y-4">
<div className="bg-surface-2 border border-border rounded-lg p-4 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("agentName")}</span>
<span className="text-text-primary font-mono">
{config.agentName}
</span>
</div>
{config.packages.length > 0 && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("packages")}</span>
<div className="flex flex-wrap gap-1 justify-end">
{config.packages.map((pkg) => (
<span
key={pkg}
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2 py-0.5"
>
{pkg}
</span>
))}
</div>
</div>
)}
{config.billingAddress.company && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("billingCompany")}</span>
<span className="text-text-primary">
{config.billingAddress.company}
</span>
</div>
)}
{config.billingAddress.city && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("billingCity")}</span>
<span className="text-text-primary">
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</span>
</div>
)}
</div>
<p className="text-xs text-text-muted">
{t("confirmNote")}
</p>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-4">
{error}
</div>
)}
<div className="flex justify-between mt-6">
<button
onClick={goBack}
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t("back")}
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? tCommon("loading") : t("submitRequest")}
</button>
</div>
</Card>
)}
</div>
);
}

175
src/lib/db.ts Normal file
View File

@@ -0,0 +1,175 @@
/**
* Database client for the portal-db PostgreSQL database.
*
* Uses the `pg` package directly — no ORM overhead for a single table.
* The tenant_requests table acts as the approval gate between customer
* registration and actual PiecedTenant CR creation.
*
* Connection: via DATABASE_URL env var pointing to CloudNativePG cluster.
*/
import { Pool } from "pg";
import type { TenantRequest, TenantRequestStatus } from "@/types";
// Lazy-init: pool is created on first use, not at module import time.
// This avoids "Invalid URL" errors during Next.js build when env vars
// aren't available yet.
let _pool: Pool | null = null;
function getPool(): Pool {
if (!_pool) {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set");
_pool = new Pool({
connectionString: url,
max: 5,
idleTimeoutMillis: 30_000,
});
}
return _pool;
}
// ---------------------------------------------------------------------------
// Schema migration (idempotent)
// ---------------------------------------------------------------------------
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS tenant_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL UNIQUE,
zitadel_user_id TEXT NOT NULL,
company_name TEXT NOT NULL,
contact_name TEXT NOT NULL,
contact_email TEXT NOT NULL,
agent_name TEXT NOT NULL DEFAULT 'Assistant',
soul_md TEXT,
packages TEXT[] DEFAULT '{}',
billing_address JSONB DEFAULT '{}',
billing_notes TEXT,
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT,
tenant_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
`;
let migrated = false;
export async function ensureSchema(): Promise<void> {
if (migrated) return;
await getPool().query(MIGRATION_SQL);
migrated = true;
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
export async function createTenantRequest(
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt">
): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`INSERT INTO tenant_requests
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
contact_email, agent_name, soul_md, packages, billing_address, billing_notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.companyName,
params.contactName,
params.contactEmail,
params.agentName,
params.soulMd,
params.packages,
JSON.stringify(params.billingAddress),
params.billingNotes,
]
);
return mapRow(result.rows[0]);
}
export async function getTenantRequestByOrgId(
orgId: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM tenant_requests WHERE zitadel_org_id = $1",
[orgId]
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
export async function getTenantRequestById(
id: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM tenant_requests WHERE id = $1",
[id]
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
export async function listTenantRequests(
status?: TenantRequestStatus
): Promise<TenantRequest[]> {
await ensureSchema();
const pool = getPool();
const query = status
? { text: "SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC", values: [status] }
: { text: "SELECT * FROM tenant_requests ORDER BY created_at DESC", values: [] };
const result = await pool.query(query);
return result.rows.map(mapRow);
}
export async function updateTenantRequestStatus(
id: string,
status: TenantRequestStatus,
extra?: { adminNotes?: string; tenantName?: string }
): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query(
`UPDATE tenant_requests
SET status = $1, admin_notes = COALESCE($2, admin_notes),
tenant_name = COALESCE($3, tenant_name), updated_at = now()
WHERE id = $4
RETURNING *`,
[status, extra?.adminNotes ?? null, extra?.tenantName ?? null, id]
);
if (!result.rows[0]) throw new Error(`TenantRequest ${id} not found`);
return mapRow(result.rows[0]);
}
// ---------------------------------------------------------------------------
// Row mapping (snake_case → camelCase)
// ---------------------------------------------------------------------------
function mapRow(row: any): TenantRequest {
return {
id: row.id,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
companyName: row.company_name,
contactName: row.contact_name,
contactEmail: row.contact_email,
agentName: row.agent_name,
soulMd: row.soul_md,
packages: row.packages ?? [],
billingAddress: typeof row.billing_address === "string"
? JSON.parse(row.billing_address)
: row.billing_address ?? {},
billingNotes: row.billing_notes,
status: row.status as TenantRequestStatus,
adminNotes: row.admin_notes,
tenantName: row.tenant_name,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}

300
src/lib/zitadel.ts Normal file
View File

@@ -0,0 +1,300 @@
/**
* ZITADEL API client for portal-driven registration (Option B).
*
* Uses v2 APIs:
* - OrganizationService: POST /v2/organizations
* - UserService: POST /v2/users/new + POST /v2/users/{id}/invite
* - ProjectService: Connect RPC CreateProjectGrant
* - AuthorizationService: Connect RPC CreateAuthorization
*
* Registration flow (invite-based):
* 1. Create Org
* 2. Create User (no password, email unverified)
* 3. Send invite → ZITADEL emails a link to set password + verify email
* 4. Create Project Grant
* 5. Create Authorization (role assignment)
*
* Auth: pieced-sa PAT (Personal Access Token) — passed as Bearer token.
* The SA must have IAM_OWNER role to create orgs cross-tenant.
*/
const ZITADEL_URL = process.env.ZITADEL_ISSUER!; // https://auth.pieced.ch
const ZITADEL_SA_PAT = process.env.ZITADEL_SA_PAT!;
const ZITADEL_PROJECT_ID = process.env.ZITADEL_PROJECT_ID!; // 367435120493199793
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function zitadelFetch<T>(
path: string,
method: string = "GET",
body?: unknown,
headers?: Record<string, string>
): Promise<T> {
const url = `${ZITADEL_URL}${path}`;
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${ZITADEL_SA_PAT}`,
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
const err = new Error(`ZITADEL ${method} ${path}: ${res.status} ${text}`);
(err as any).statusCode = res.status;
throw err;
}
return res.json() as Promise<T>;
}
/**
* Connect RPC call — ZITADEL v2 services use Connect protocol.
* Same as REST but requires Connect-Protocol-Version header.
*/
async function connectRpc<T>(
service: string,
method: string,
body: unknown
): Promise<T> {
return zitadelFetch<T>(
`/${service}/${method}`,
"POST",
body,
{ "Connect-Protocol-Version": "1" }
);
}
// ---------------------------------------------------------------------------
// v2 Organization API — REST
// ---------------------------------------------------------------------------
export interface CreateOrgResult {
organizationId: string;
creationDate: string;
}
export async function createOrganization(
name: string
): Promise<CreateOrgResult> {
return zitadelFetch<CreateOrgResult>("/v2/organizations", "POST", {
name,
});
}
// ---------------------------------------------------------------------------
// v2 User API — REST
// ---------------------------------------------------------------------------
export interface CreateUserResult {
id: string;
creationDate: string;
emailCode?: string;
}
/**
* Create a human user in a specific organization WITHOUT a password.
* The user cannot log in until they complete the invite flow
* (set password + verify email via the link in the invite email).
*
* POST /v2/users/new
*/
export async function createHumanUser(params: {
orgId: string;
email: string;
givenName: string;
familyName: string;
preferredLanguage?: string;
}): Promise<CreateUserResult> {
return zitadelFetch<CreateUserResult>("/v2/users/new", "POST", {
organizationId: params.orgId,
human: {
profile: {
givenName: params.givenName,
familyName: params.familyName,
displayName: `${params.givenName} ${params.familyName}`,
preferredLanguage: params.preferredLanguage || "en",
},
email: {
email: params.email,
// Not verified — invite flow will handle verification
},
},
// No password — user sets it via invite link
});
}
/**
* Send an invitation email to the user.
* The email contains a link where the user sets their password
* (or passkey/IdP) and verifies their email address in one step.
*
* Requires SMTP to be configured in ZITADEL.
* If SMTP is not configured, this call succeeds but no email is sent.
*
* POST /v2/users/{userId}/invite
*/
export async function createInviteCode(userId: string): Promise<void> {
await zitadelFetch(`/v2/users/${userId}/invite`, "POST", {
sendCode: {},
});
}
// ---------------------------------------------------------------------------
// v2 Project API — Connect RPC
// ---------------------------------------------------------------------------
export interface ProjectGrantResult {
projectGrantId: string;
creationDate: string;
}
/**
* Grant the "OpenClaw Platform" project to a customer organization.
* Connect RPC: zitadel.project.v2.ProjectService/CreateProjectGrant
*/
export async function createProjectGrant(
grantedOrgId: string,
roleKeys?: string[]
): Promise<ProjectGrantResult> {
return connectRpc<ProjectGrantResult>(
"zitadel.project.v2.ProjectService",
"CreateProjectGrant",
{
projectId: ZITADEL_PROJECT_ID,
grantedOrganizationId: grantedOrgId,
roleKeys: roleKeys || ["owner"],
}
);
}
// ---------------------------------------------------------------------------
// v2 Authorization API — Connect RPC
// ---------------------------------------------------------------------------
export interface AuthorizationResult {
id: string;
creationDate: string;
}
/**
* Create a role assignment (authorization) for a user.
* This makes the role appear in the JWT claims.
* Connect RPC: zitadel.authorization.v2.AuthorizationService/CreateAuthorization
*/
export async function createAuthorization(params: {
userId: string;
projectId?: string;
organizationId: string;
roleKeys?: string[];
}): Promise<AuthorizationResult> {
return connectRpc<AuthorizationResult>(
"zitadel.authorization.v2.AuthorizationService",
"CreateAuthorization",
{
userId: params.userId,
projectId: params.projectId || ZITADEL_PROJECT_ID,
organizationId: params.organizationId,
roleKeys: params.roleKeys || ["owner"],
}
);
}
// ---------------------------------------------------------------------------
// Delete Organization (for rollback on partial failure)
// ---------------------------------------------------------------------------
export async function deleteOrganization(orgId: string): Promise<void> {
await zitadelFetch(`/v2/organizations/${orgId}`, "DELETE");
}
// ---------------------------------------------------------------------------
// Full registration flow
// ---------------------------------------------------------------------------
export interface RegistrationResult {
orgId: string;
userId: string;
projectGrantId: string;
}
/**
* Complete registration flow:
* 1. Create ZITADEL Org
* 2. Create Human User (no password, email unverified)
* 3. Send invite code (ZITADEL emails link to set password + verify email)
* 4. Create Project Grant (link OpenClaw Platform project to new org)
* 5. Create Authorization (assign "owner" role to user)
*
* If any step after org creation fails, the org is deleted (rollback).
*/
export async function registerCustomer(params: {
companyName: string;
email: string;
givenName: string;
familyName: string;
preferredLanguage?: string;
}): Promise<RegistrationResult> {
// 1. Create org
const org = await createOrganization(params.companyName);
try {
// 2. Create user in org (no password)
const user = await createHumanUser({
orgId: org.organizationId,
email: params.email,
givenName: params.givenName,
familyName: params.familyName,
preferredLanguage: params.preferredLanguage,
});
// 3. Send invite — user receives email to set password + verify email
try {
await createInviteCode(user.id);
} catch (inviteErr) {
// Log but don't fail — SMTP may not be configured yet.
// Admin can resend the invite later from ZITADEL console.
console.warn(
`Invite email could not be sent for user ${user.id} (SMTP may not be configured):`,
inviteErr
);
}
// 4. Grant project to org
const grant = await createProjectGrant(org.organizationId, ["owner"]);
// 5. Assign "owner" role to user
await createAuthorization({
userId: user.id,
organizationId: org.organizationId,
roleKeys: ["owner"],
});
return {
orgId: org.organizationId,
userId: user.id,
projectGrantId: grant.projectGrantId,
};
} catch (err) {
// Rollback: delete the org so the customer can retry
console.error(
`Registration failed after org creation (${org.organizationId}), rolling back:`,
err
);
try {
await deleteOrganization(org.organizationId);
console.log(`Rolled back org ${org.organizationId}`);
} catch (rollbackErr) {
console.error(
`Failed to rollback org ${org.organizationId}:`,
rollbackErr
);
}
throw err;
}
}

View File

@@ -10,13 +10,72 @@
"language": "Sprache",
"cancel": "Abbrechen",
"save": "Speichern",
"error": "Ein Fehler ist aufgetreten"
"error": "Ein Fehler ist aufgetreten",
"register": "Registrieren"
},
"login": {
"title": "PieCed Portal",
"subtitle": "Melde dich an, um deinen KI-Assistenten zu verwalten",
"subtitle": "Melden Sie sich an, um Ihren KI-Assistenten zu verwalten",
"button": "Weiter mit ZITADEL",
"footer": "On-Premises gehostet in der Schweiz"
"footer": "On-Premises gehostet in der Schweiz",
"noAccount": "Noch kein Konto?",
"register": "Firma registrieren"
},
"register": {
"title": "Konto erstellen",
"subtitle": "Registrieren Sie Ihre Firma für einen in der Schweiz gehosteten KI-Assistenten",
"companyName": "Firmenname",
"companyNamePlaceholder": "Muster GmbH",
"givenName": "Vorname",
"familyName": "Nachname",
"email": "E-Mail-Adresse",
"submit": "Registrieren",
"hasAccount": "Bereits ein Konto?",
"footer": "Ihre Daten werden ausschliesslich On-Premises in der Schweiz gehostet.",
"successTitle": "Registrierung eingegangen",
"successDescription": "Sie erhalten eine Einladungs-E-Mail mit einem Link, um Ihr Passwort festzulegen und Ihre E-Mail-Adresse zu bestätigen. Danach können Sie sich anmelden und Ihren KI-Assistenten einrichten.",
"goToLogin": "Zur Anmeldung"
},
"onboarding": {
"loading": "Status wird geladen…",
"welcomeTitle": "KI-Assistenten einrichten",
"welcomeDescription": "In wenigen Schritten erhalten Sie Ihren eigenen KI-Assistenten — ausschliesslich in der Schweiz gehostet, vollständig unter Ihrer Kontrolle.",
"welcomeFeature_swissHosted": "On-Premises in der Schweiz gehostet — Ihre Daten verlassen nie das Land",
"welcomeFeature_privacy": "Keine Datenweitergabe an Dritte — vollständiger Datenschutz",
"welcomeFeature_customizable": "Vollständig anpassbare Persönlichkeit, Pakete und Integrationen",
"getStarted": "Loslegen",
"configureTitle": "Assistenten konfigurieren",
"configureDescription": "Geben Sie Ihrem Assistenten einen Namen und eine Persönlichkeit. Sie können dies jederzeit ändern.",
"agentName": "Agent-Name",
"soulMd": "Persönlichkeit (SOUL.md)",
"soulMdHint": "Definiert das Verhalten Ihres Assistenten. Markdown-Format. Kann später bearbeitet werden.",
"packages": "Pakete",
"packagesHint": "Optionale Integrationen. Können auch später aktiviert werden.",
"billingTitle": "Rechnungsinformationen",
"billingDescription": "Wir benötigen Ihre Rechnungsadresse für die Fakturierung. Ein Zahlungsanbieter wird zukünftig integriert.",
"billingCompany": "Firma",
"billingStreet": "Strasse",
"billingPostalCode": "PLZ",
"billingCity": "Ort",
"billingCountry": "Land",
"billingNotes": "Bemerkungen",
"billingNotesPlaceholder": "Bemerkungen zur Rechnung (Bestellnummer, MWST-Nr., bevorzugte Zahlungsart usw.)",
"confirmTitle": "Überprüfen & absenden",
"confirmDescription": "Bitte überprüfen Sie Ihre Einstellungen. Ihr Antrag wird von unserem Team geprüft, bevor die Bereitstellung beginnt.",
"confirmNote": "Nach dem Absenden prüft unser Team Ihren Antrag und die Rechnungsangaben. Sie erhalten Zugang nach Genehmigung — normalerweise innerhalb eines Werktages.",
"submitRequest": "Antrag absenden",
"back": "Zurück",
"next": "Weiter",
"pendingTitle": "Antrag eingereicht",
"pendingDescription": "Ihr Antrag wurde eingereicht und wird von unserem Team geprüft. Sie erhalten Zugang nach Genehmigung — normalerweise innerhalb eines Werktages.",
"rejectedTitle": "Antrag nicht genehmigt",
"rejectedDescription": "Leider wurde Ihr Antrag nicht genehmigt. Bitte kontaktieren Sie uns für weitere Informationen.",
"provisioningTitle": "Instanz wird eingerichtet",
"provisioningDescription": "Ihr KI-Assistent wird bereitgestellt. Dies dauert in der Regel wenige Minuten.",
"phase": "Phase",
"readyTitle": "Ihr Assistent ist bereit!",
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.",
"goToDashboard": "Zum Dashboard"
},
"dashboard": {
"title": "Dashboard",
@@ -40,13 +99,24 @@
"phase": "Phase",
"packages": "Pakete",
"created": "Erstellt",
"manage": "Verwalten"
"manage": "Verwalten",
"requests": "Onboarding-Anträge",
"pendingRequests": "Offene Anträge",
"approve": "Genehmigen",
"reject": "Ablehnen",
"company": "Firma",
"contact": "Kontakt",
"status": "Status",
"submitted": "Eingereicht",
"noRequests": "Keine offenen Anträge.",
"approveConfirm": "Diesen Antrag genehmigen und Bereitstellung starten?",
"rejectConfirm": "Diesen Antrag ablehnen?"
},
"tenantDetail": {
"agent": "Agent",
"packages": "Pakete",
"workspaceFiles": "Workspace-Dateien",
"notFound": "Mandant nicht gefunden.",
"notFound": "Tenant nicht gefunden.",
"usage": "Nutzung & Kosten"
},
"usage": {
@@ -64,15 +134,15 @@
"workspace": {
"save": "Speichern",
"placeholder": "Inhalt für {file} eingeben…",
"seedingNote": "Workspace-Dateien werden beim ersten Start eingerichtet. Aktualisierung löst ConfigMap-Update und Pod-Neustart aus."
"seedingNote": "Workspace-Dateien werden beim ersten Start geladen. Eine Aktualisierung auf einer bestehenden Instanz löst ein ConfigMap-Update und Pod-Neustart aus."
},
"packages": {
"enable": "Aktivieren",
"disable": "Deaktivieren",
"enableAndSave": "Aktivieren & Speichern",
"configure": "Konfigurieren",
"requiresApiKey": "API-Schlüssel erforderlich",
"missingFields": "Bitte füllen Sie alle Pflichtfelder aus.",
"requiresApiKey": "Erfordert API-Schlüssel",
"missingFields": "Bitte füllen Sie alle erforderlichen Felder aus.",
"status": {
"pending": "Ausstehend",
"active": "Aktiv",
@@ -80,30 +150,30 @@
},
"telegram": {
"description": "Verbinden Sie Ihren KI-Assistenten mit einem Telegram-Bot.",
"botTokenLabel": "Telegram Bot-Token",
"botTokenLabel": "Telegram Bot Token",
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"instructions": "1. Öffnen Sie @BotFather auf Telegram\n2. Senden Sie /newbot und folgen Sie den Anweisungen\n3. Kopieren Sie den Bot-Token",
"disclaimer": "Ich bestätige, dass ich Eigentümer dieses Telegram-Bots bin und PieCed IT autorisiere, ihn mit meiner Instanz zu verbinden."
"disclaimer": "Ich bestätige, dass ich diesen Telegram-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden."
},
"discord": {
"description": "Verbinden Sie Ihren KI-Assistenten über einen Bot mit Discord.",
"botTokenLabel": "Discord Bot-Token",
"description": "Verbinden Sie Ihren KI-Assistenten mit einem Discord-Server über einen Bot.",
"botTokenLabel": "Discord Bot Token",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Gehen Sie zu discord.com/developers/applications\n2. Erstellen Sie eine Anwendung und fügen Sie einen Bot hinzu\n3. Kopieren Sie den Bot-Token",
"disclaimer": "Ich bestätige, dass ich Eigentümer dieses Discord-Bots bin und PieCed IT autorisiere, ihn mit meiner Instanz zu verbinden."
"instructions": "1. Gehen Sie zu discord.com/developers/applications\n2. Erstellen Sie eine neue Anwendung und fügen Sie einen Bot hinzu\n3. Kopieren Sie den Bot-Token",
"disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden."
},
"email": {
"description": "Ermöglichen Sie Ihrem KI-Assistenten E-Mails zu senden und empfangen.",
"smtpHostLabel": "SMTP-Host",
"description": "Ermöglichen Sie Ihrem KI-Assistenten, E-Mails zu senden und zu empfangen.",
"smtpHostLabel": "SMTP Host",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "SMTP-Benutzername",
"smtpUserLabel": "SMTP Benutzername",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "SMTP-Passwort",
"smtpPasswordLabel": "SMTP Passwort",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "IMAP-Host",
"imapHostLabel": "IMAP Host",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Geben Sie SMTP- und IMAP-Zugangsdaten an. Der Assistent nutzt diese zum Senden und Empfangen.",
"disclaimer": "Ich bestätige, dass ich berechtigt bin, diese Zugangsdaten zu verwenden und PieCed IT auf dieses Postfach zugreifen darf."
"instructions": "Geben Sie SMTP- und IMAP-Zugangsdaten an. Der Assistent nutzt diese zum Senden und Empfangen von Nachrichten.",
"disclaimer": "Ich bestätige, dass ich berechtigt bin, diese E-Mail-Zugangsdaten zu verwenden und dass PieCed IT auf dieses Postfach zugreifen darf."
},
"webSearch": {
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web zu suchen."

View File

@@ -10,13 +10,72 @@
"language": "Language",
"cancel": "Cancel",
"save": "Save",
"error": "An error occurred"
"error": "An error occurred",
"register": "Register"
},
"login": {
"title": "PieCed Portal",
"subtitle": "Sign in to manage your AI assistant",
"button": "Continue with ZITADEL",
"footer": "Hosted on-premises in Switzerland"
"footer": "Hosted on-premises in Switzerland",
"noAccount": "No account yet?",
"register": "Register your company"
},
"register": {
"title": "Create your account",
"subtitle": "Register your company for a Swiss-hosted AI assistant",
"companyName": "Company Name",
"companyNamePlaceholder": "Acme GmbH",
"givenName": "First Name",
"familyName": "Last Name",
"email": "Email Address",
"submit": "Register",
"hasAccount": "Already have an account?",
"footer": "Your data is hosted exclusively on-premises in Switzerland.",
"successTitle": "Registration received",
"successDescription": "You will receive an invitation email with a link to set your password and verify your email address. Once completed, you can sign in to set up your AI assistant.",
"goToLogin": "Go to Sign In"
},
"onboarding": {
"loading": "Loading status…",
"welcomeTitle": "Set up your AI assistant",
"welcomeDescription": "In a few steps, you'll have your own AI assistant — hosted exclusively in Switzerland, fully under your control.",
"welcomeFeature_swissHosted": "Hosted on-premises in Switzerland — your data never leaves the country",
"welcomeFeature_privacy": "No data shared with third parties — complete privacy",
"welcomeFeature_customizable": "Fully customizable personality, packages, and integrations",
"getStarted": "Get started",
"configureTitle": "Configure your assistant",
"configureDescription": "Give your assistant a name and personality. You can always change this later.",
"agentName": "Agent Name",
"soulMd": "Personality (SOUL.md)",
"soulMdHint": "This defines how your assistant behaves. Markdown format. You can edit this later.",
"packages": "Packages",
"packagesHint": "Optional integrations. You can enable these later too.",
"billingTitle": "Billing information",
"billingDescription": "We need your billing address to set up invoicing. A payment provider will be integrated in the future.",
"billingCompany": "Company",
"billingStreet": "Street",
"billingPostalCode": "Postal Code",
"billingCity": "City",
"billingCountry": "Country",
"billingNotes": "Notes",
"billingNotesPlaceholder": "Any notes about billing (PO number, VAT ID, preferred payment method, etc.)",
"confirmTitle": "Review & submit",
"confirmDescription": "Please review your setup. Your request will be reviewed by our team before provisioning.",
"confirmNote": "After submission, our team will review your request and billing details. You'll receive access once approved — typically within one business day.",
"submitRequest": "Submit request",
"back": "Back",
"next": "Next",
"pendingTitle": "Request submitted",
"pendingDescription": "Your onboarding request has been submitted and is awaiting review by our team. You'll receive access once approved — typically within one business day.",
"rejectedTitle": "Request not approved",
"rejectedDescription": "Unfortunately, your onboarding request was not approved. Please contact us for more information.",
"provisioningTitle": "Setting up your instance",
"provisioningDescription": "Your AI assistant is being provisioned. This usually takes a few minutes.",
"phase": "Phase",
"readyTitle": "Your assistant is ready!",
"readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.",
"goToDashboard": "Go to Dashboard"
},
"dashboard": {
"title": "Dashboard",
@@ -40,7 +99,18 @@
"phase": "Phase",
"packages": "Packages",
"created": "Created",
"manage": "Manage"
"manage": "Manage",
"requests": "Onboarding Requests",
"pendingRequests": "Pending Requests",
"approve": "Approve",
"reject": "Reject",
"company": "Company",
"contact": "Contact",
"status": "Status",
"submitted": "Submitted",
"noRequests": "No pending requests.",
"approveConfirm": "Approve this request and start provisioning?",
"rejectConfirm": "Reject this request?"
},
"tenantDetail": {
"agent": "Agent",

View File

@@ -2,32 +2,91 @@
"common": {
"appName": "PieCed",
"tagline": "Plateforme IA",
"login": "Connexion",
"logout": "Déconnexion",
"login": "Se connecter",
"logout": "Se déconnecter",
"dashboard": "Tableau de bord",
"admin": "Admin",
"loading": "Chargement…",
"language": "Langue",
"cancel": "Annuler",
"save": "Enregistrer",
"error": "Une erreur est survenue"
"error": "Une erreur est survenue",
"register": "S'inscrire"
},
"login": {
"title": "PieCed Portal",
"title": "Portail PieCed",
"subtitle": "Connectez-vous pour gérer votre assistant IA",
"button": "Continuer avec ZITADEL",
"footer": "Hébergé sur site en Suisse"
"footer": "Hébergé on-premises en Suisse",
"noAccount": "Pas encore de compte ?",
"register": "Inscrivez votre entreprise"
},
"register": {
"title": "Créer votre compte",
"subtitle": "Inscrivez votre entreprise pour un assistant IA hébergé en Suisse",
"companyName": "Nom de l'entreprise",
"companyNamePlaceholder": "Acme SA",
"givenName": "Prénom",
"familyName": "Nom",
"email": "Adresse e-mail",
"submit": "S'inscrire",
"hasAccount": "Vous avez déjà un compte ?",
"footer": "Vos données sont hébergées exclusivement on-premises en Suisse.",
"successTitle": "Inscription reçue",
"successDescription": "Vous recevrez un e-mail d'invitation avec un lien pour définir votre mot de passe et vérifier votre adresse e-mail. Une fois terminé, vous pourrez vous connecter pour configurer votre assistant IA.",
"goToLogin": "Aller à la connexion"
},
"onboarding": {
"loading": "Chargement du statut…",
"welcomeTitle": "Configurez votre assistant IA",
"welcomeDescription": "En quelques étapes, vous aurez votre propre assistant IA — hébergé exclusivement en Suisse, entièrement sous votre contrôle.",
"welcomeFeature_swissHosted": "Hébergé on-premises en Suisse — vos données ne quittent jamais le pays",
"welcomeFeature_privacy": "Aucune donnée partagée avec des tiers — confidentialité totale",
"welcomeFeature_customizable": "Personnalité, paquets et intégrations entièrement personnalisables",
"getStarted": "Commencer",
"configureTitle": "Configurer votre assistant",
"configureDescription": "Donnez un nom et une personnalité à votre assistant. Vous pourrez toujours modifier cela plus tard.",
"agentName": "Nom de l'agent",
"soulMd": "Personnalité (SOUL.md)",
"soulMdHint": "Définit le comportement de votre assistant. Format Markdown. Modifiable ultérieurement.",
"packages": "Paquets",
"packagesHint": "Intégrations optionnelles. Vous pouvez aussi les activer plus tard.",
"billingTitle": "Informations de facturation",
"billingDescription": "Nous avons besoin de votre adresse de facturation. Un prestataire de paiement sera intégré à l'avenir.",
"billingCompany": "Entreprise",
"billingStreet": "Rue",
"billingPostalCode": "Code postal",
"billingCity": "Ville",
"billingCountry": "Pays",
"billingNotes": "Remarques",
"billingNotesPlaceholder": "Remarques concernant la facturation (numéro de commande, TVA, mode de paiement préféré, etc.)",
"confirmTitle": "Vérifier et envoyer",
"confirmDescription": "Veuillez vérifier votre configuration. Votre demande sera examinée par notre équipe avant la mise en service.",
"confirmNote": "Après l'envoi, notre équipe examinera votre demande et vos informations de facturation. Vous recevrez l'accès après approbation — généralement dans un délai d'un jour ouvrable.",
"submitRequest": "Envoyer la demande",
"back": "Retour",
"next": "Suivant",
"pendingTitle": "Demande envoyée",
"pendingDescription": "Votre demande d'intégration a été envoyée et est en attente d'examen par notre équipe. Vous recevrez l'accès après approbation — généralement dans un délai d'un jour ouvrable.",
"rejectedTitle": "Demande non approuvée",
"rejectedDescription": "Malheureusement, votre demande n'a pas été approuvée. Veuillez nous contacter pour plus d'informations.",
"provisioningTitle": "Configuration de votre instance",
"provisioningDescription": "Votre assistant IA est en cours de mise en service. Cela prend généralement quelques minutes.",
"phase": "Phase",
"readyTitle": "Votre assistant est prêt !",
"readyDescription": "Votre assistant IA a été mis en service et est actif. Vous pouvez maintenant le gérer depuis le tableau de bord.",
"goToDashboard": "Aller au tableau de bord"
},
"dashboard": {
"title": "Tableau de bord",
"welcome": "Bienvenue, {name}",
"instanceStatus": "État de l'instance",
"instanceStatus": "Statut de l'instance",
"usage": "Utilisation",
"packages": "Paquets",
"noInstance": "Aucune instance provisionnée.",
"comingSoon": "Vue détaillée à venir dans la Session 6.2",
"noInstanceDescription": "Configurez votre instance d'assistant IA pour démarrer avec PieCed IT.",
"manage": "Gérer l'instance & les paquets"
"noInstanceDescription": "Configurez votre instance d'assistant IA pour commencer avec PieCed IT.",
"manage": "Gérer l'instance et les paquets"
},
"admin": {
"title": "Admin plateforme",
@@ -40,14 +99,25 @@
"phase": "Phase",
"packages": "Paquets",
"created": "Créé",
"manage": "Gérer"
"manage": "Gérer",
"requests": "Demandes d'intégration",
"pendingRequests": "Demandes en attente",
"approve": "Approuver",
"reject": "Refuser",
"company": "Entreprise",
"contact": "Contact",
"status": "Statut",
"submitted": "Envoyé",
"noRequests": "Aucune demande en attente.",
"approveConfirm": "Approuver cette demande et lancer la mise en service ?",
"rejectConfirm": "Refuser cette demande ?"
},
"tenantDetail": {
"agent": "Agent",
"packages": "Paquets",
"workspaceFiles": "Fichiers Workspace",
"workspaceFiles": "Fichiers workspace",
"notFound": "Tenant introuvable.",
"usage": "Utilisation & Dépenses"
"usage": "Utilisation et dépenses"
},
"usage": {
"inputTokens": "Tokens d'entrée",
@@ -55,21 +125,21 @@
"totalSpend": "Dépenses totales",
"totalCost": "Coût total",
"budget": "Budget",
"noLimit": "Pas de limite",
"noLimit": "Aucune limite",
"last30Days": "30 derniers jours",
"noData": "Aucune donnée d'utilisation.",
"noData": "Aucune donnée d'utilisation disponible.",
"dailyBreakdown": "Détail journalier",
"requests": "requêtes"
},
"workspace": {
"save": "Enregistrer",
"placeholder": "Saisissez le contenu de {file}…",
"seedingNote": "Les fichiers workspace sont initialisés au premier démarrage. La mise à jour déclenche un redémarrage du pod."
"placeholder": "Saisir le contenu pour {file}…",
"seedingNote": "Les fichiers workspace sont initialisés au premier démarrage. Une mise à jour sur une instance existante déclenche une mise à jour du ConfigMap et un redémarrage du pod."
},
"packages": {
"enable": "Activer",
"disable": "Désactiver",
"enableAndSave": "Activer & Enregistrer",
"enableAndSave": "Activer et enregistrer",
"configure": "Configurer",
"requiresApiKey": "Clé API requise",
"missingFields": "Veuillez remplir tous les champs obligatoires.",
@@ -82,31 +152,31 @@
"description": "Connectez votre assistant IA à un bot Telegram.",
"botTokenLabel": "Token du bot Telegram",
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"instructions": "1. Ouvrez @BotFather sur Telegram\n2. Envoyez /newbot et suivez les instructions\n3. Copiez le token fourni",
"instructions": "1. Ouvrez @BotFather sur Telegram\n2. Envoyez /newbot et suivez les instructions\n3. Copiez le token du bot fourni",
"disclaimer": "Je confirme être propriétaire de ce bot Telegram et autorise PieCed IT à le connecter à mon assistant IA."
},
"discord": {
"description": "Connectez votre assistant IA à Discord via un bot.",
"description": "Connectez votre assistant IA à un serveur Discord via un bot.",
"botTokenLabel": "Token du bot Discord",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Allez sur discord.com/developers/applications\n2. Créez une application et ajoutez un bot\n3. Copiez le token",
"instructions": "1. Allez sur discord.com/developers/applications\n2. Créez une nouvelle application et ajoutez un bot\n3. Copiez le token du bot",
"disclaimer": "Je confirme être propriétaire de ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA."
},
"email": {
"description": "Permettez à votre assistant IA d'envoyer et recevoir des e-mails.",
"description": "Permettez à votre assistant IA d'envoyer et de recevoir des e-mails.",
"smtpHostLabel": "Hôte SMTP",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Utilisateur SMTP",
"smtpUserLabel": "Nom d'utilisateur SMTP",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "Mot de passe SMTP",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "Hôte IMAP",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Fournissez les identifiants SMTP et IMAP pour l'envoi et la réception de messages.",
"disclaimer": "Je confirme être autorisé(e) à utiliser ces identifiants et que PieCed IT peut accéder à cette boîte."
"instructions": "Fournissez les identifiants SMTP et IMAP. L'assistant les utilise pour envoyer et surveiller les messages.",
"disclaimer": "Je confirme être autorisé à utiliser ces identifiants e-mail et que PieCed IT peut accéder à cette boîte mail."
},
"webSearch": {
"description": "Donnez à votre assistant IA la possibilité de rechercher sur le web."
"description": "Donnez à votre assistant IA la capacité de rechercher sur le web."
},
"documentProcessing": {
"description": "Activez l'analyse, le résumé et l'extraction de documents."

View File

@@ -10,13 +10,72 @@
"language": "Lingua",
"cancel": "Annulla",
"save": "Salva",
"error": "Si è verificato un errore"
"error": "Si è verificato un errore",
"register": "Registrati"
},
"login": {
"title": "PieCed Portal",
"title": "Portale PieCed",
"subtitle": "Accedi per gestire il tuo assistente IA",
"button": "Continua con ZITADEL",
"footer": "Ospitato on-premises in Svizzera"
"footer": "Ospitato on-premises in Svizzera",
"noAccount": "Non hai ancora un account?",
"register": "Registra la tua azienda"
},
"register": {
"title": "Crea il tuo account",
"subtitle": "Registra la tua azienda per un assistente IA ospitato in Svizzera",
"companyName": "Nome dell'azienda",
"companyNamePlaceholder": "Acme SA",
"givenName": "Nome",
"familyName": "Cognome",
"email": "Indirizzo e-mail",
"submit": "Registrati",
"hasAccount": "Hai già un account?",
"footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.",
"successTitle": "Registrazione ricevuta",
"successDescription": "Riceverai un'e-mail di invito con un link per impostare la password e verificare il tuo indirizzo e-mail. Una volta completato, potrai accedere per configurare il tuo assistente IA.",
"goToLogin": "Vai all'accesso"
},
"onboarding": {
"loading": "Caricamento dello stato…",
"welcomeTitle": "Configura il tuo assistente IA",
"welcomeDescription": "In pochi passaggi avrai il tuo assistente IA personale — ospitato esclusivamente in Svizzera, completamente sotto il tuo controllo.",
"welcomeFeature_swissHosted": "Ospitato on-premises in Svizzera — i tuoi dati non lasciano mai il paese",
"welcomeFeature_privacy": "Nessun dato condiviso con terzi — privacy completa",
"welcomeFeature_customizable": "Personalità, pacchetti e integrazioni completamente personalizzabili",
"getStarted": "Inizia",
"configureTitle": "Configura il tuo assistente",
"configureDescription": "Dai un nome e una personalità al tuo assistente. Puoi sempre modificarlo in seguito.",
"agentName": "Nome dell'agente",
"soulMd": "Personalità (SOUL.md)",
"soulMdHint": "Definisce il comportamento del tuo assistente. Formato Markdown. Modificabile in seguito.",
"packages": "Pacchetti",
"packagesHint": "Integrazioni opzionali. Puoi attivarle anche in seguito.",
"billingTitle": "Informazioni di fatturazione",
"billingDescription": "Abbiamo bisogno del tuo indirizzo di fatturazione. Un fornitore di pagamento verrà integrato in futuro.",
"billingCompany": "Azienda",
"billingStreet": "Via",
"billingPostalCode": "CAP",
"billingCity": "Città",
"billingCountry": "Paese",
"billingNotes": "Note",
"billingNotesPlaceholder": "Note sulla fatturazione (numero d'ordine, partita IVA, metodo di pagamento preferito, ecc.)",
"confirmTitle": "Verifica e invia",
"confirmDescription": "Verifica la tua configurazione. La tua richiesta verrà esaminata dal nostro team prima dell'attivazione.",
"confirmNote": "Dopo l'invio, il nostro team esaminerà la tua richiesta e i dati di fatturazione. Riceverai l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
"submitRequest": "Invia richiesta",
"back": "Indietro",
"next": "Avanti",
"pendingTitle": "Richiesta inviata",
"pendingDescription": "La tua richiesta di attivazione è stata inviata ed è in attesa di revisione da parte del nostro team. Riceverai l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
"rejectedTitle": "Richiesta non approvata",
"rejectedDescription": "Purtroppo la tua richiesta non è stata approvata. Contattaci per maggiori informazioni.",
"provisioningTitle": "Configurazione dell'istanza",
"provisioningDescription": "Il tuo assistente IA è in fase di attivazione. Di solito sono necessari pochi minuti.",
"phase": "Fase",
"readyTitle": "Il tuo assistente è pronto!",
"readyDescription": "Il tuo assistente IA è stato attivato ed è operativo. Puoi ora gestirlo dalla dashboard.",
"goToDashboard": "Vai alla dashboard"
},
"dashboard": {
"title": "Dashboard",
@@ -24,7 +83,7 @@
"instanceStatus": "Stato dell'istanza",
"usage": "Utilizzo",
"packages": "Pacchetti",
"noInstance": "Nessuna istanza ancora provisioned.",
"noInstance": "Nessuna istanza ancora attivata.",
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
"manage": "Gestisci istanza e pacchetti"
@@ -33,21 +92,32 @@
"title": "Admin piattaforma",
"subtitle": "Tutti i tenant della piattaforma",
"allTenants": "Tenant",
"noTenants": "Nessun tenant ancora provisionato.",
"noTenants": "Nessun tenant ancora attivato.",
"noAccess": "Permessi insufficienti per questa vista.",
"name": "Nome",
"displayName": "Nome visualizzato",
"phase": "Fase",
"packages": "Pacchetti",
"created": "Creato",
"manage": "Gestisci"
"manage": "Gestisci",
"requests": "Richieste di attivazione",
"pendingRequests": "Richieste in sospeso",
"approve": "Approva",
"reject": "Rifiuta",
"company": "Azienda",
"contact": "Contatto",
"status": "Stato",
"submitted": "Inviato",
"noRequests": "Nessuna richiesta in sospeso.",
"approveConfirm": "Approvare questa richiesta e avviare l'attivazione?",
"rejectConfirm": "Rifiutare questa richiesta?"
},
"tenantDetail": {
"agent": "Agente",
"packages": "Pacchetti",
"workspaceFiles": "File Workspace",
"workspaceFiles": "File workspace",
"notFound": "Tenant non trovato.",
"usage": "Utilizzo & Spese"
"usage": "Utilizzo e spese"
},
"usage": {
"inputTokens": "Token di input",
@@ -57,24 +127,24 @@
"budget": "Budget",
"noLimit": "Nessun limite",
"last30Days": "Ultimi 30 giorni",
"noData": "Nessun dato di utilizzo.",
"noData": "Nessun dato di utilizzo disponibile.",
"dailyBreakdown": "Dettaglio giornaliero",
"requests": "richieste"
},
"workspace": {
"save": "Salva",
"placeholder": "Inserisci il contenuto per {file}…",
"seedingNote": "I file workspace vengono inizializzati al primo avvio. L'aggiornamento attiva un riavvio del pod."
"seedingNote": "I file workspace vengono inizializzati al primo avvio. Un aggiornamento su un'istanza esistente attiva un aggiornamento del ConfigMap e un riavvio del pod."
},
"packages": {
"enable": "Attiva",
"disable": "Disattiva",
"enableAndSave": "Attiva & Salva",
"enableAndSave": "Attiva e salva",
"configure": "Configura",
"requiresApiKey": "Chiave API richiesta",
"missingFields": "Compilare tutti i campi obbligatori.",
"missingFields": "Compila tutti i campi obbligatori.",
"status": {
"pending": "In attesa",
"pending": "In sospeso",
"active": "Attivo",
"error": "Errore"
},
@@ -82,34 +152,34 @@
"description": "Collega il tuo assistente IA a un bot Telegram.",
"botTokenLabel": "Token del bot Telegram",
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
"instructions": "1. Apri @BotFather su Telegram\n2. Invia /newbot e segui le istruzioni\n3. Copia il token del bot",
"disclaimer": "Confermo di essere il proprietario di questo bot Telegram e autorizzo PieCed IT a collegarlo al mio assistente IA."
"instructions": "1. Apri @BotFather su Telegram\n2. Invia /newbot e segui le istruzioni\n3. Copia il token del bot fornito",
"disclaimer": "Confermo di essere proprietario di questo bot Telegram e autorizzo PieCed IT a collegarlo al mio assistente IA."
},
"discord": {
"description": "Collega il tuo assistente IA a Discord tramite un bot.",
"description": "Collega il tuo assistente IA a un server Discord tramite un bot.",
"botTokenLabel": "Token del bot Discord",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Vai su discord.com/developers/applications\n2. Crea un'applicazione e aggiungi un bot\n3. Copia il token del bot",
"disclaimer": "Confermo di essere il proprietario di questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA."
"instructions": "1. Vai su discord.com/developers/applications\n2. Crea una nuova applicazione e aggiungi un bot\n3. Copia il token del bot",
"disclaimer": "Confermo di essere proprietario di questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA."
},
"email": {
"description": "Consenti al tuo assistente IA di inviare e ricevere e-mail.",
"description": "Permetti al tuo assistente IA di inviare e ricevere e-mail.",
"smtpHostLabel": "Host SMTP",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Utente SMTP",
"smtpUserLabel": "Nome utente SMTP",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "Password SMTP",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "Host IMAP",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Fornisci le credenziali SMTP e IMAP per l'invio e la ricezione dei messaggi.",
"disclaimer": "Confermo di essere autorizzato/a a usare queste credenziali e che PieCed IT può accedere a questa casella."
"instructions": "Fornisci le credenziali SMTP e IMAP. L'assistente le utilizza per inviare e monitorare i messaggi.",
"disclaimer": "Confermo di essere autorizzato a utilizzare queste credenziali e-mail e che PieCed IT può accedere a questa casella di posta."
},
"webSearch": {
"description": "Dai al tuo assistente IA la possibilità di cercare sul web."
"description": "Dai al tuo assistente IA la capacità di cercare sul web."
},
"documentProcessing": {
"description": "Attiva analisi, riassunto ed estrazione dei documenti."
"description": "Attiva analisi, riepilogo ed estrazione di documenti."
}
}
}

View File

@@ -6,22 +6,23 @@ import { routing } from "@/i18n/routing";
const intlMiddleware = createIntlMiddleware(routing);
const publicPaths = ["/login", "/api/auth"];
const publicPaths = ["/login", "/register", "/api/auth", "/api/register"];
function isPublicPath(pathname: string): boolean {
// Strip locale prefix for comparison
const stripped = pathname.replace(/^\/(de|fr|it|en)/, "") || "/";
return (
publicPaths.some((p) => stripped === p || stripped.startsWith(`${p}/`)) ||
pathname.startsWith("/api/auth")
pathname.startsWith("/api/auth") ||
pathname.startsWith("/api/register")
);
}
export default async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// NextAuth API routes pass through directly
if (pathname.startsWith("/api/auth")) {
// NextAuth API routes and register API pass through directly
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/register")) {
return NextResponse.next();
}
@@ -39,5 +40,5 @@ export default async function middleware(request: NextRequest) {
}
export const config = {
matcher: ["/((?!_next|favicon.ico|api).*)" ],
matcher: ["/((?!_next|favicon.ico|api).*)"],
};

View File

@@ -65,3 +65,57 @@ export interface UsageSummary {
totalSpendChf: number;
period: string;
}
// ---------------------------------------------------------------------------
// Registration & Onboarding
// ---------------------------------------------------------------------------
export interface RegistrationInput {
companyName: string;
givenName: string;
familyName: string;
email: string;
preferredLanguage?: string;
}
export interface BillingAddress {
company?: string;
street?: string;
city?: string;
postalCode?: string;
country?: string;
}
export type TenantRequestStatus =
| "pending" // Submitted, awaiting admin approval
| "approved" // Admin approved, provisioning will start
| "provisioning" // PiecedTenant CR created, operator reconciling
| "active" // Tenant running
| "rejected"; // Admin rejected
export interface TenantRequest {
id: string;
zitadelOrgId: string;
zitadelUserId: string;
companyName: string;
contactName: string;
contactEmail: string;
agentName: string;
soulMd?: string;
packages: string[];
billingAddress: BillingAddress;
billingNotes?: string;
status: TenantRequestStatus;
adminNotes?: string;
tenantName?: string;
createdAt: string;
updatedAt: string;
}
export interface OnboardingInput {
agentName: string;
soulMd?: string;
packages?: string[];
billingAddress: BillingAddress;
billingNotes?: string;
}