Session 6.3
This commit is contained in:
@@ -7,6 +7,13 @@ ZITADEL_ISSUER=https://auth.pieced.ch
|
|||||||
ZITADEL_CLIENT_ID=
|
ZITADEL_CLIENT_ID=
|
||||||
ZITADEL_CLIENT_SECRET=
|
ZITADEL_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# ZITADEL Management API (service account PAT for Option B registration)
|
||||||
|
ZITADEL_SA_PAT=
|
||||||
|
ZITADEL_PROJECT_ID=367435120493199793
|
||||||
|
|
||||||
# LiteLLM (in-cluster)
|
# LiteLLM (in-cluster)
|
||||||
LITELLM_INTERNAL_URL=http://litellm.inference.svc:4000
|
LITELLM_INTERNAL_URL=http://litellm.inference.svc:4000
|
||||||
LITELLM_MASTER_KEY=
|
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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
serverExternalPackages: ["pg"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
159
package-lock.json
generated
159
package-lock.json
generated
@@ -9,9 +9,11 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"next-intl": "^4.9.0",
|
"next-intl": "^4.9.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
@@ -2013,6 +2015,17 @@
|
|||||||
"form-data": "^4.0.4"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -6073,6 +6086,95 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6137,6 +6239,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/preact": {
|
||||||
"version": "10.24.3",
|
"version": "10.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
@@ -6690,6 +6831,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"next-intl": "^4.9.0",
|
"next-intl": "^4.9.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { getSessionUser } from "@/lib/session";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listTenants } from "@/lib/k8s";
|
import { listTenants } from "@/lib/k8s";
|
||||||
|
import { getTenantRequestByOrgId } from "@/lib/db";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||||
|
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
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
|
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// No tenant → check for existing request, show onboarding flow
|
||||||
if (!myTenant) {
|
if (!myTenant) {
|
||||||
|
const existingRequest = await getTenantRequestByOrgId(user.orgId);
|
||||||
|
const initialState = existingRequest?.status ?? "no_request";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<div className="mb-8 animate-in">
|
||||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
@@ -156,16 +154,11 @@ export default async function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center animate-in animate-in-delay-1">
|
<div className="animate-in animate-in-delay-1">
|
||||||
<div className="h-14 w-14 rounded-xl bg-accent/15 flex items-center justify-center mb-4">
|
<OnboardingFlow
|
||||||
<div className="h-8 w-8 rounded-lg bg-accent/40" />
|
orgName={user.orgName}
|
||||||
</div>
|
initialState={initialState as any}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -227,14 +220,6 @@ export default async function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<span>→</span> {t("manage")}
|
<span>→</span> {t("manage")}
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const t = useTranslations("login");
|
const t = useTranslations("login");
|
||||||
@@ -49,6 +50,16 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
{t("button")}
|
{t("button")}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-text-muted text-[11px] mt-6 tracking-wide uppercase">
|
<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",
|
"language": "Sprache",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"error": "Ein Fehler ist aufgetreten"
|
"error": "Ein Fehler ist aufgetreten",
|
||||||
|
"register": "Registrieren"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "PieCed Portal",
|
"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",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -40,13 +99,24 @@
|
|||||||
"phase": "Phase",
|
"phase": "Phase",
|
||||||
"packages": "Pakete",
|
"packages": "Pakete",
|
||||||
"created": "Erstellt",
|
"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": {
|
"tenantDetail": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
"packages": "Pakete",
|
"packages": "Pakete",
|
||||||
"workspaceFiles": "Workspace-Dateien",
|
"workspaceFiles": "Workspace-Dateien",
|
||||||
"notFound": "Mandant nicht gefunden.",
|
"notFound": "Tenant nicht gefunden.",
|
||||||
"usage": "Nutzung & Kosten"
|
"usage": "Nutzung & Kosten"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
@@ -64,15 +134,15 @@
|
|||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"placeholder": "Inhalt für {file} eingeben…",
|
"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": {
|
"packages": {
|
||||||
"enable": "Aktivieren",
|
"enable": "Aktivieren",
|
||||||
"disable": "Deaktivieren",
|
"disable": "Deaktivieren",
|
||||||
"enableAndSave": "Aktivieren & Speichern",
|
"enableAndSave": "Aktivieren & Speichern",
|
||||||
"configure": "Konfigurieren",
|
"configure": "Konfigurieren",
|
||||||
"requiresApiKey": "API-Schlüssel erforderlich",
|
"requiresApiKey": "Erfordert API-Schlüssel",
|
||||||
"missingFields": "Bitte füllen Sie alle Pflichtfelder aus.",
|
"missingFields": "Bitte füllen Sie alle erforderlichen Felder aus.",
|
||||||
"status": {
|
"status": {
|
||||||
"pending": "Ausstehend",
|
"pending": "Ausstehend",
|
||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
@@ -80,30 +150,30 @@
|
|||||||
},
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"description": "Verbinden Sie Ihren KI-Assistenten mit einem Telegram-Bot.",
|
"description": "Verbinden Sie Ihren KI-Assistenten mit einem Telegram-Bot.",
|
||||||
"botTokenLabel": "Telegram Bot-Token",
|
"botTokenLabel": "Telegram Bot Token",
|
||||||
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
"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",
|
"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": {
|
"discord": {
|
||||||
"description": "Verbinden Sie Ihren KI-Assistenten über einen Bot mit Discord.",
|
"description": "Verbinden Sie Ihren KI-Assistenten mit einem Discord-Server über einen Bot.",
|
||||||
"botTokenLabel": "Discord Bot-Token",
|
"botTokenLabel": "Discord Bot Token",
|
||||||
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
"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",
|
"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 Eigentümer dieses Discord-Bots bin und PieCed IT autorisiere, ihn mit meiner Instanz zu verbinden."
|
"disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden."
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
"description": "Ermöglichen Sie Ihrem KI-Assistenten E-Mails zu senden und empfangen.",
|
"description": "Ermöglichen Sie Ihrem KI-Assistenten, E-Mails zu senden und zu empfangen.",
|
||||||
"smtpHostLabel": "SMTP-Host",
|
"smtpHostLabel": "SMTP Host",
|
||||||
"smtpHostPlaceholder": "smtp.example.com",
|
"smtpHostPlaceholder": "smtp.example.com",
|
||||||
"smtpUserLabel": "SMTP-Benutzername",
|
"smtpUserLabel": "SMTP Benutzername",
|
||||||
"smtpUserPlaceholder": "user@example.com",
|
"smtpUserPlaceholder": "user@example.com",
|
||||||
"smtpPasswordLabel": "SMTP-Passwort",
|
"smtpPasswordLabel": "SMTP Passwort",
|
||||||
"smtpPasswordPlaceholder": "••••••••",
|
"smtpPasswordPlaceholder": "••••••••",
|
||||||
"imapHostLabel": "IMAP-Host",
|
"imapHostLabel": "IMAP Host",
|
||||||
"imapHostPlaceholder": "imap.example.com",
|
"imapHostPlaceholder": "imap.example.com",
|
||||||
"instructions": "Geben Sie SMTP- und IMAP-Zugangsdaten an. Der Assistent nutzt diese zum Senden und Empfangen.",
|
"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 Zugangsdaten zu verwenden und PieCed IT auf dieses Postfach zugreifen darf."
|
"disclaimer": "Ich bestätige, dass ich berechtigt bin, diese E-Mail-Zugangsdaten zu verwenden und dass PieCed IT auf dieses Postfach zugreifen darf."
|
||||||
},
|
},
|
||||||
"webSearch": {
|
"webSearch": {
|
||||||
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web zu suchen."
|
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web zu suchen."
|
||||||
|
|||||||
@@ -10,13 +10,72 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"error": "An error occurred"
|
"error": "An error occurred",
|
||||||
|
"register": "Register"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "PieCed Portal",
|
"title": "PieCed Portal",
|
||||||
"subtitle": "Sign in to manage your AI assistant",
|
"subtitle": "Sign in to manage your AI assistant",
|
||||||
"button": "Continue with ZITADEL",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -40,7 +99,18 @@
|
|||||||
"phase": "Phase",
|
"phase": "Phase",
|
||||||
"packages": "Packages",
|
"packages": "Packages",
|
||||||
"created": "Created",
|
"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": {
|
"tenantDetail": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -2,32 +2,91 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"appName": "PieCed",
|
"appName": "PieCed",
|
||||||
"tagline": "Plateforme IA",
|
"tagline": "Plateforme IA",
|
||||||
"login": "Connexion",
|
"login": "Se connecter",
|
||||||
"logout": "Déconnexion",
|
"logout": "Se déconnecter",
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"error": "Une erreur est survenue"
|
"error": "Une erreur est survenue",
|
||||||
|
"register": "S'inscrire"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "PieCed Portal",
|
"title": "Portail PieCed",
|
||||||
"subtitle": "Connectez-vous pour gérer votre assistant IA",
|
"subtitle": "Connectez-vous pour gérer votre assistant IA",
|
||||||
"button": "Continuer avec ZITADEL",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
"welcome": "Bienvenue, {name}",
|
"welcome": "Bienvenue, {name}",
|
||||||
"instanceStatus": "État de l'instance",
|
"instanceStatus": "Statut de l'instance",
|
||||||
"usage": "Utilisation",
|
"usage": "Utilisation",
|
||||||
"packages": "Paquets",
|
"packages": "Paquets",
|
||||||
"noInstance": "Aucune instance provisionnée.",
|
"noInstance": "Aucune instance provisionnée.",
|
||||||
"comingSoon": "Vue détaillée à venir dans la Session 6.2",
|
"comingSoon": "Vue détaillée à venir dans la Session 6.2",
|
||||||
"noInstanceDescription": "Configurez votre instance d'assistant IA pour démarrer avec PieCed IT.",
|
"noInstanceDescription": "Configurez votre instance d'assistant IA pour commencer avec PieCed IT.",
|
||||||
"manage": "Gérer l'instance & les paquets"
|
"manage": "Gérer l'instance et les paquets"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Admin plateforme",
|
"title": "Admin plateforme",
|
||||||
@@ -40,14 +99,25 @@
|
|||||||
"phase": "Phase",
|
"phase": "Phase",
|
||||||
"packages": "Paquets",
|
"packages": "Paquets",
|
||||||
"created": "Créé",
|
"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": {
|
"tenantDetail": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
"packages": "Paquets",
|
"packages": "Paquets",
|
||||||
"workspaceFiles": "Fichiers Workspace",
|
"workspaceFiles": "Fichiers workspace",
|
||||||
"notFound": "Tenant introuvable.",
|
"notFound": "Tenant introuvable.",
|
||||||
"usage": "Utilisation & Dépenses"
|
"usage": "Utilisation et dépenses"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"inputTokens": "Tokens d'entrée",
|
"inputTokens": "Tokens d'entrée",
|
||||||
@@ -55,21 +125,21 @@
|
|||||||
"totalSpend": "Dépenses totales",
|
"totalSpend": "Dépenses totales",
|
||||||
"totalCost": "Coût total",
|
"totalCost": "Coût total",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"noLimit": "Pas de limite",
|
"noLimit": "Aucune limite",
|
||||||
"last30Days": "30 derniers jours",
|
"last30Days": "30 derniers jours",
|
||||||
"noData": "Aucune donnée d'utilisation.",
|
"noData": "Aucune donnée d'utilisation disponible.",
|
||||||
"dailyBreakdown": "Détail journalier",
|
"dailyBreakdown": "Détail journalier",
|
||||||
"requests": "requêtes"
|
"requests": "requêtes"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"placeholder": "Saisissez le contenu de {file}…",
|
"placeholder": "Saisir le contenu pour {file}…",
|
||||||
"seedingNote": "Les fichiers workspace sont initialisés au premier démarrage. La mise à jour déclenche un redémarrage du pod."
|
"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": {
|
"packages": {
|
||||||
"enable": "Activer",
|
"enable": "Activer",
|
||||||
"disable": "Désactiver",
|
"disable": "Désactiver",
|
||||||
"enableAndSave": "Activer & Enregistrer",
|
"enableAndSave": "Activer et enregistrer",
|
||||||
"configure": "Configurer",
|
"configure": "Configurer",
|
||||||
"requiresApiKey": "Clé API requise",
|
"requiresApiKey": "Clé API requise",
|
||||||
"missingFields": "Veuillez remplir tous les champs obligatoires.",
|
"missingFields": "Veuillez remplir tous les champs obligatoires.",
|
||||||
@@ -82,31 +152,31 @@
|
|||||||
"description": "Connectez votre assistant IA à un bot Telegram.",
|
"description": "Connectez votre assistant IA à un bot Telegram.",
|
||||||
"botTokenLabel": "Token du bot Telegram",
|
"botTokenLabel": "Token du bot Telegram",
|
||||||
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
"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."
|
"disclaimer": "Je confirme être propriétaire de ce bot Telegram et autorise PieCed IT à le connecter à mon assistant IA."
|
||||||
},
|
},
|
||||||
"discord": {
|
"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",
|
"botTokenLabel": "Token du bot Discord",
|
||||||
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
"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."
|
"disclaimer": "Je confirme être propriétaire de ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA."
|
||||||
},
|
},
|
||||||
"email": {
|
"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",
|
"smtpHostLabel": "Hôte SMTP",
|
||||||
"smtpHostPlaceholder": "smtp.example.com",
|
"smtpHostPlaceholder": "smtp.example.com",
|
||||||
"smtpUserLabel": "Utilisateur SMTP",
|
"smtpUserLabel": "Nom d'utilisateur SMTP",
|
||||||
"smtpUserPlaceholder": "user@example.com",
|
"smtpUserPlaceholder": "user@example.com",
|
||||||
"smtpPasswordLabel": "Mot de passe SMTP",
|
"smtpPasswordLabel": "Mot de passe SMTP",
|
||||||
"smtpPasswordPlaceholder": "••••••••",
|
"smtpPasswordPlaceholder": "••••••••",
|
||||||
"imapHostLabel": "Hôte IMAP",
|
"imapHostLabel": "Hôte IMAP",
|
||||||
"imapHostPlaceholder": "imap.example.com",
|
"imapHostPlaceholder": "imap.example.com",
|
||||||
"instructions": "Fournissez les identifiants SMTP et IMAP pour l'envoi et la réception de messages.",
|
"instructions": "Fournissez les identifiants SMTP et IMAP. L'assistant les utilise pour envoyer et surveiller les messages.",
|
||||||
"disclaimer": "Je confirme être autorisé(e) à utiliser ces identifiants et que PieCed IT peut accéder à cette boîte."
|
"disclaimer": "Je confirme être autorisé à utiliser ces identifiants e-mail et que PieCed IT peut accéder à cette boîte mail."
|
||||||
},
|
},
|
||||||
"webSearch": {
|
"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": {
|
"documentProcessing": {
|
||||||
"description": "Activez l'analyse, le résumé et l'extraction de documents."
|
"description": "Activez l'analyse, le résumé et l'extraction de documents."
|
||||||
|
|||||||
@@ -10,13 +10,72 @@
|
|||||||
"language": "Lingua",
|
"language": "Lingua",
|
||||||
"cancel": "Annulla",
|
"cancel": "Annulla",
|
||||||
"save": "Salva",
|
"save": "Salva",
|
||||||
"error": "Si è verificato un errore"
|
"error": "Si è verificato un errore",
|
||||||
|
"register": "Registrati"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "PieCed Portal",
|
"title": "Portale PieCed",
|
||||||
"subtitle": "Accedi per gestire il tuo assistente IA",
|
"subtitle": "Accedi per gestire il tuo assistente IA",
|
||||||
"button": "Continua con ZITADEL",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -24,7 +83,7 @@
|
|||||||
"instanceStatus": "Stato dell'istanza",
|
"instanceStatus": "Stato dell'istanza",
|
||||||
"usage": "Utilizzo",
|
"usage": "Utilizzo",
|
||||||
"packages": "Pacchetti",
|
"packages": "Pacchetti",
|
||||||
"noInstance": "Nessuna istanza ancora provisioned.",
|
"noInstance": "Nessuna istanza ancora attivata.",
|
||||||
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
|
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
|
||||||
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
|
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
|
||||||
"manage": "Gestisci istanza e pacchetti"
|
"manage": "Gestisci istanza e pacchetti"
|
||||||
@@ -33,21 +92,32 @@
|
|||||||
"title": "Admin piattaforma",
|
"title": "Admin piattaforma",
|
||||||
"subtitle": "Tutti i tenant della piattaforma",
|
"subtitle": "Tutti i tenant della piattaforma",
|
||||||
"allTenants": "Tenant",
|
"allTenants": "Tenant",
|
||||||
"noTenants": "Nessun tenant ancora provisionato.",
|
"noTenants": "Nessun tenant ancora attivato.",
|
||||||
"noAccess": "Permessi insufficienti per questa vista.",
|
"noAccess": "Permessi insufficienti per questa vista.",
|
||||||
"name": "Nome",
|
"name": "Nome",
|
||||||
"displayName": "Nome visualizzato",
|
"displayName": "Nome visualizzato",
|
||||||
"phase": "Fase",
|
"phase": "Fase",
|
||||||
"packages": "Pacchetti",
|
"packages": "Pacchetti",
|
||||||
"created": "Creato",
|
"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": {
|
"tenantDetail": {
|
||||||
"agent": "Agente",
|
"agent": "Agente",
|
||||||
"packages": "Pacchetti",
|
"packages": "Pacchetti",
|
||||||
"workspaceFiles": "File Workspace",
|
"workspaceFiles": "File workspace",
|
||||||
"notFound": "Tenant non trovato.",
|
"notFound": "Tenant non trovato.",
|
||||||
"usage": "Utilizzo & Spese"
|
"usage": "Utilizzo e spese"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
"inputTokens": "Token di input",
|
"inputTokens": "Token di input",
|
||||||
@@ -57,24 +127,24 @@
|
|||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"noLimit": "Nessun limite",
|
"noLimit": "Nessun limite",
|
||||||
"last30Days": "Ultimi 30 giorni",
|
"last30Days": "Ultimi 30 giorni",
|
||||||
"noData": "Nessun dato di utilizzo.",
|
"noData": "Nessun dato di utilizzo disponibile.",
|
||||||
"dailyBreakdown": "Dettaglio giornaliero",
|
"dailyBreakdown": "Dettaglio giornaliero",
|
||||||
"requests": "richieste"
|
"requests": "richieste"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Salva",
|
"save": "Salva",
|
||||||
"placeholder": "Inserisci il contenuto per {file}…",
|
"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": {
|
"packages": {
|
||||||
"enable": "Attiva",
|
"enable": "Attiva",
|
||||||
"disable": "Disattiva",
|
"disable": "Disattiva",
|
||||||
"enableAndSave": "Attiva & Salva",
|
"enableAndSave": "Attiva e salva",
|
||||||
"configure": "Configura",
|
"configure": "Configura",
|
||||||
"requiresApiKey": "Chiave API richiesta",
|
"requiresApiKey": "Chiave API richiesta",
|
||||||
"missingFields": "Compilare tutti i campi obbligatori.",
|
"missingFields": "Compila tutti i campi obbligatori.",
|
||||||
"status": {
|
"status": {
|
||||||
"pending": "In attesa",
|
"pending": "In sospeso",
|
||||||
"active": "Attivo",
|
"active": "Attivo",
|
||||||
"error": "Errore"
|
"error": "Errore"
|
||||||
},
|
},
|
||||||
@@ -82,34 +152,34 @@
|
|||||||
"description": "Collega il tuo assistente IA a un bot Telegram.",
|
"description": "Collega il tuo assistente IA a un bot Telegram.",
|
||||||
"botTokenLabel": "Token del bot Telegram",
|
"botTokenLabel": "Token del bot Telegram",
|
||||||
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||||
"instructions": "1. Apri @BotFather su Telegram\n2. Invia /newbot e segui le istruzioni\n3. Copia il token del bot",
|
"instructions": "1. Apri @BotFather su Telegram\n2. Invia /newbot e segui le istruzioni\n3. Copia il token del bot fornito",
|
||||||
"disclaimer": "Confermo di essere il proprietario di questo bot Telegram e autorizzo PieCed IT a collegarlo al mio assistente IA."
|
"disclaimer": "Confermo di essere proprietario di questo bot Telegram e autorizzo PieCed IT a collegarlo al mio assistente IA."
|
||||||
},
|
},
|
||||||
"discord": {
|
"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",
|
"botTokenLabel": "Token del bot Discord",
|
||||||
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||||
"instructions": "1. Vai su discord.com/developers/applications\n2. Crea un'applicazione e aggiungi un bot\n3. Copia il token del bot",
|
"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 il proprietario di questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA."
|
"disclaimer": "Confermo di essere proprietario di questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA."
|
||||||
},
|
},
|
||||||
"email": {
|
"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",
|
"smtpHostLabel": "Host SMTP",
|
||||||
"smtpHostPlaceholder": "smtp.example.com",
|
"smtpHostPlaceholder": "smtp.example.com",
|
||||||
"smtpUserLabel": "Utente SMTP",
|
"smtpUserLabel": "Nome utente SMTP",
|
||||||
"smtpUserPlaceholder": "user@example.com",
|
"smtpUserPlaceholder": "user@example.com",
|
||||||
"smtpPasswordLabel": "Password SMTP",
|
"smtpPasswordLabel": "Password SMTP",
|
||||||
"smtpPasswordPlaceholder": "••••••••",
|
"smtpPasswordPlaceholder": "••••••••",
|
||||||
"imapHostLabel": "Host IMAP",
|
"imapHostLabel": "Host IMAP",
|
||||||
"imapHostPlaceholder": "imap.example.com",
|
"imapHostPlaceholder": "imap.example.com",
|
||||||
"instructions": "Fornisci le credenziali SMTP e IMAP per l'invio e la ricezione dei messaggi.",
|
"instructions": "Fornisci le credenziali SMTP e IMAP. L'assistente le utilizza per inviare e monitorare i messaggi.",
|
||||||
"disclaimer": "Confermo di essere autorizzato/a a usare queste credenziali e che PieCed IT può accedere a questa casella."
|
"disclaimer": "Confermo di essere autorizzato a utilizzare queste credenziali e-mail e che PieCed IT può accedere a questa casella di posta."
|
||||||
},
|
},
|
||||||
"webSearch": {
|
"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": {
|
"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 intlMiddleware = createIntlMiddleware(routing);
|
||||||
|
|
||||||
const publicPaths = ["/login", "/api/auth"];
|
const publicPaths = ["/login", "/register", "/api/auth", "/api/register"];
|
||||||
|
|
||||||
function isPublicPath(pathname: string): boolean {
|
function isPublicPath(pathname: string): boolean {
|
||||||
// Strip locale prefix for comparison
|
// Strip locale prefix for comparison
|
||||||
const stripped = pathname.replace(/^\/(de|fr|it|en)/, "") || "/";
|
const stripped = pathname.replace(/^\/(de|fr|it|en)/, "") || "/";
|
||||||
return (
|
return (
|
||||||
publicPaths.some((p) => stripped === p || stripped.startsWith(`${p}/`)) ||
|
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) {
|
export default async function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// NextAuth API routes pass through directly
|
// NextAuth API routes and register API pass through directly
|
||||||
if (pathname.startsWith("/api/auth")) {
|
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/register")) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,5 +40,5 @@ export default async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next|favicon.ico|api).*)" ],
|
matcher: ["/((?!_next|favicon.ico|api).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,3 +65,57 @@ export interface UsageSummary {
|
|||||||
totalSpendChf: number;
|
totalSpendChf: number;
|
||||||
period: string;
|
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