Session 6.3
This commit is contained in:
@@ -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
5
buildanddeploy.sh
Normal 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
|
||||
|
||||
@@ -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
159
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
195
src/app/[locale]/register/page.tsx
Normal file
195
src/app/[locale]/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/app/api/admin/requests/[id]/approve/route.ts
Normal file
81
src/app/api/admin/requests/[id]/approve/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
43
src/app/api/admin/requests/[id]/reject/route.ts
Normal file
43
src/app/api/admin/requests/[id]/reject/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
21
src/app/api/admin/requests/route.ts
Normal file
21
src/app/api/admin/requests/route.ts
Normal 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);
|
||||
}
|
||||
145
src/app/api/onboarding/route.ts
Normal file
145
src/app/api/onboarding/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
70
src/app/api/register/route.ts
Normal file
70
src/app/api/register/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/components/onboarding/onboarding-flow.tsx
Normal file
31
src/components/onboarding/onboarding-flow.tsx
Normal 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 />;
|
||||
}
|
||||
240
src/components/onboarding/provisioning-status.tsx
Normal file
240
src/components/onboarding/provisioning-status.tsx
Normal 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;
|
||||
}
|
||||
488
src/components/onboarding/wizard.tsx
Normal file
488
src/components/onboarding/wizard.tsx
Normal 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
175
src/lib/db.ts
Normal 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
300
src/lib/zitadel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).*)"],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user