Add initial Portal version
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
*.md
|
||||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# NextAuth
|
||||||
|
NEXTAUTH_URL=https://app.pieced.ch
|
||||||
|
NEXTAUTH_SECRET= # openssl rand -base64 32
|
||||||
|
|
||||||
|
# ZITADEL OIDC
|
||||||
|
ZITADEL_ISSUER=https://auth.pieced.ch
|
||||||
|
ZITADEL_CLIENT_ID=
|
||||||
|
ZITADEL_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# LiteLLM (in-cluster)
|
||||||
|
LITELLM_INTERNAL_URL=http://litellm.inference.svc:4000
|
||||||
|
LITELLM_MASTER_KEY=
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# PieCed Portal
|
||||||
|
|
||||||
|
Customer self-service portal for the PieCed IT multi-tenant OpenClaw platform.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
|-------|--------|
|
||||||
|
| Framework | Next.js 15 LTS (App Router, standalone output, Turbopack) |
|
||||||
|
| Auth | NextAuth v5 + ZITADEL OIDC (CODE flow) |
|
||||||
|
| Tenant mgmt | Direct K8s API → `PiecedTenant` CRs (Option A) |
|
||||||
|
| Usage data | LiteLLM `/team/info` + `/global/spend/logs` |
|
||||||
|
| i18n | next-intl 4.x (en/de) |
|
||||||
|
| Styling | Tailwind CSS 4 |
|
||||||
|
| Deployment | Container in `pieced-system`, exposed at `app.pieced.ch` |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. ZITADEL Application
|
||||||
|
|
||||||
|
In ZITADEL console (`auth.pieced.ch`), project "OpenClaw Platform":
|
||||||
|
|
||||||
|
1. Create Application → **PieCed Portal** → Web → Authentication Method: **CODE**
|
||||||
|
2. Redirect URI: `https://app.pieced.ch/api/auth/callback/zitadel`
|
||||||
|
3. Post-logout URI: `https://app.pieced.ch/login`
|
||||||
|
4. Note Client ID and Client Secret
|
||||||
|
|
||||||
|
### 2. OpenBao Secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bao kv put pieced/portal/oidc \
|
||||||
|
client_id="<from step 1>" \
|
||||||
|
client_secret="<from step 1>" \
|
||||||
|
nextauth_secret="$(openssl rand -base64 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Build & Push
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.0 .
|
||||||
|
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Update image tag in `pieced-gitops/apps/portal/deployment.yaml`, push, ArgoCD syncs.
|
||||||
|
|
||||||
|
### 4. DNS
|
||||||
|
|
||||||
|
Ensure `app.pieced.ch` A record → MetalLB ingress IP (or ExternalDNS handles it).
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
# Fill in values — K8s client uses ~/.kube/config locally
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── auth/[...nextauth]/route.ts # NextAuth handler
|
||||||
|
│ │ ├── tenants/route.ts # Tenant CRUD (K8s API)
|
||||||
|
│ │ └── usage/route.ts # Usage stub
|
||||||
|
│ ├── [locale]/
|
||||||
|
│ │ ├── layout.tsx # Locale layout + NavShell
|
||||||
|
│ │ ├── page.tsx # Redirect → /dashboard
|
||||||
|
│ │ ├── login/page.tsx # ZITADEL sign-in
|
||||||
|
│ │ ├── dashboard/page.tsx # Customer dashboard
|
||||||
|
│ │ └── admin/page.tsx # Platform admin tenant list
|
||||||
|
│ ├── layout.tsx # Root layout
|
||||||
|
│ └── globals.css # Tailwind 4 theme
|
||||||
|
├── components/
|
||||||
|
│ ├── layout/nav-shell.tsx # Header + navigation
|
||||||
|
│ └── ui/ # Reusable UI components
|
||||||
|
├── i18n/
|
||||||
|
│ ├── routing.ts # next-intl 4.x routing config
|
||||||
|
│ ├── navigation.ts # Localized Link, redirect, etc.
|
||||||
|
│ └── request.ts # Server-side i18n config
|
||||||
|
├── lib/
|
||||||
|
│ ├── auth.ts # NextAuth v5 + ZITADEL config
|
||||||
|
│ ├── k8s.ts # K8s client for PiecedTenant CRs
|
||||||
|
│ ├── litellm.ts # LiteLLM API client
|
||||||
|
│ └── session.ts # Session helpers
|
||||||
|
├── messages/
|
||||||
|
│ ├── en.json
|
||||||
|
│ └── de.json
|
||||||
|
└── types/index.ts # Shared TypeScript types
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Roadmap
|
||||||
|
|
||||||
|
- **6.1** ← This session: scaffold, auth, basic pages
|
||||||
|
- **6.2**: Instance management, package config, usage display
|
||||||
|
- **6.3**: Onboarding flow (create ZITADEL org → PiecedTenant CR)
|
||||||
|
- **6.4**: Workspace editor (SOUL.md, AGENTS.md, TOOLS.md)
|
||||||
|
- **6.5**: Admin panel (tenant lifecycle, billing overview)
|
||||||
10
next.config.mjs
Normal file
10
next.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withNextIntl(nextConfig);
|
||||||
7460
package-lock.json
generated
Normal file
7460
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "pieced-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
|
"next": "^15.5.15",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"next-intl": "^4.9.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.0",
|
||||||
|
"@types/node": "^22.13.0",
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"eslint": "^9.22.0",
|
||||||
|
"eslint-config-next": "^15.5.15",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
5
src/app/[locale]/admin/page.tsx
Normal file
5
src/app/[locale]/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminTenantsClient from "@/components/admin/AdminTenantsClient";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return <AdminTenantsClient />;
|
||||||
|
}
|
||||||
5
src/app/[locale]/dashboard/page.tsx
Normal file
5
src/app/[locale]/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardClient from "@/components/dashboard/DashboardClient";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return <DashboardClient />;
|
||||||
|
}
|
||||||
43
src/app/[locale]/layout.tsx
Normal file
43
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { getMessages } from "next-intl/server";
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { NavShell } from "@/components/layout/nav-shell";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
if (!routing.locales.includes(locale as any)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale} className="dark">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>PieCed Portal</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="PieCed IT — Multi-tenant AI assistant platform"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<NavShell>{children}</NavShell>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/[locale]/login/page.tsx
Normal file
60
src/app/[locale]/login/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const t = useTranslations("login");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
||||||
|
{/* Background grid pattern */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.04]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(var(--color-accent) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--color-accent) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: "60px 60px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full max-w-sm px-5 animate-in">
|
||||||
|
{/* Logo mark */}
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="relative h-12 w-12">
|
||||||
|
<div className="absolute inset-0 rounded-lg bg-accent/15" />
|
||||||
|
<div className="absolute inset-[5px] rounded-md bg-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40">
|
||||||
|
<h1 className="font-display text-xl font-semibold text-center mb-1">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-text-secondary text-sm text-center mb-8">
|
||||||
|
{t("subtitle")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => signIn("zitadel", { callbackUrl: "/dashboard" })}
|
||||||
|
className="
|
||||||
|
w-full py-3 px-4 rounded-lg font-medium text-sm
|
||||||
|
bg-accent text-surface-0 cursor-pointer
|
||||||
|
hover:bg-accent-dim active:scale-[0.98]
|
||||||
|
transition-all duration-150
|
||||||
|
shadow-lg shadow-accent/20
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{t("button")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-text-muted text-[11px] mt-6 tracking-wide uppercase">
|
||||||
|
{t("footer")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/[locale]/page.tsx
Normal file
5
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
5
src/app/[locale]/tenants/[name]/page.tsx
Normal file
5
src/app/[locale]/tenants/[name]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import TenantDetailClient from "@/components/tenants/TenantDetailClient";
|
||||||
|
|
||||||
|
export default function TenantDetailPage() {
|
||||||
|
return <TenantDetailClient />;
|
||||||
|
}
|
||||||
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
12
src/app/api/packages/route.ts
Normal file
12
src/app/api/packages/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(PACKAGE_CATALOG);
|
||||||
|
}
|
||||||
95
src/app/api/tenants/[name]/route.ts
Normal file
95
src/app/api/tenants/[name]/route.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||||
|
|
||||||
|
function isPlatformRole(roles: string[]): boolean {
|
||||||
|
return roles.some((r) =>
|
||||||
|
["platform_admin", "platform_operator"].includes(r)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ name: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = await params;
|
||||||
|
const { orgId, roles } = session.user as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenant = await getTenant(name);
|
||||||
|
if (!tenant) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope check: non-platform users can only see their own org's tenants
|
||||||
|
if (
|
||||||
|
!isPlatformRole(roles || []) &&
|
||||||
|
tenant.metadata?.labels?.["zitadel-org-id"] !== orgId
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(tenant);
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "K8s API error", detail: e.message },
|
||||||
|
{ status: e.statusCode || 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ name: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = await params;
|
||||||
|
const { orgId, roles } = session.user as any;
|
||||||
|
const body = await req.json();
|
||||||
|
const userRoles = roles || [];
|
||||||
|
|
||||||
|
// Only owner or platform roles can patch
|
||||||
|
if (!isPlatformRole(userRoles) && !userRoles.includes("owner")) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ownership check
|
||||||
|
const existing = await getTenant(name);
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isPlatformRole(userRoles) &&
|
||||||
|
existing.metadata?.labels?.["zitadel-org-id"] !== orgId
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build partial spec — only allow specific fields
|
||||||
|
const specPatch: Record<string, any> = {};
|
||||||
|
if (body.packages !== undefined) specPatch.packages = body.packages;
|
||||||
|
if (body.workspaceFiles !== undefined)
|
||||||
|
specPatch.workspaceFiles = body.workspaceFiles;
|
||||||
|
if (body.displayName !== undefined)
|
||||||
|
specPatch.displayName = body.displayName;
|
||||||
|
if (body.agentName !== undefined) specPatch.agentName = body.agentName;
|
||||||
|
|
||||||
|
const updated = await patchTenantSpec(name, specPatch);
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Patch failed", detail: e.message },
|
||||||
|
{ status: e.statusCode || 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/app/api/tenants/[name]/secrets/route.ts
Normal file
100
src/app/api/tenants/[name]/secrets/route.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getTenant } from "@/lib/k8s";
|
||||||
|
import { writePackageSecrets } from "@/lib/openbao";
|
||||||
|
import { getPackageDef } from "@/lib/packages";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ name: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = await params;
|
||||||
|
const { orgId, roles } = session.user as any;
|
||||||
|
const userRoles = roles || [];
|
||||||
|
|
||||||
|
const isPlatform = userRoles.some((r: string) =>
|
||||||
|
["platform_admin", "platform_operator"].includes(r)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isPlatform && !userRoles.includes("owner")) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { packageId, secrets } = body as {
|
||||||
|
packageId: string;
|
||||||
|
secrets: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!packageId || !secrets || typeof secrets !== "object") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing packageId or secrets" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate package exists and requires secrets
|
||||||
|
const pkgDef = getPackageDef(packageId);
|
||||||
|
if (!pkgDef) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unknown package" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!pkgDef.requiresSecrets) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Package does not require secrets" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all required secret keys are present
|
||||||
|
const requiredKeys = (pkgDef.secrets || []).map((s) => s.key);
|
||||||
|
const missingKeys = requiredKeys.filter((k) => !secrets[k]?.trim());
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Missing required secrets: ${missingKeys.join(", ")}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tenant ownership
|
||||||
|
try {
|
||||||
|
const tenant = await getTenant(name);
|
||||||
|
if (!tenant) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Tenant not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isPlatform &&
|
||||||
|
tenant.metadata?.labels?.["zitadel-org-id"] !== orgId
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Tenant lookup failed" },
|
||||||
|
{ status: e.statusCode || 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to OpenBao
|
||||||
|
try {
|
||||||
|
await writePackageSecrets(name, packageId, secrets);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("OpenBao write error:", err.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to store secrets" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
56
src/app/api/tenants/route.ts
Normal file
56
src/app/api/tenants/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { listTenants, getTenant, createTenant } from "@/lib/k8s";
|
||||||
|
import type { PiecedTenantSpec } from "@/types";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user)
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const tenants = await listTenants();
|
||||||
|
|
||||||
|
if (user.isPlatform) {
|
||||||
|
return NextResponse.json(tenants);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customers see only their own tenant
|
||||||
|
const own = tenants.filter(
|
||||||
|
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||||
|
);
|
||||||
|
return NextResponse.json(own);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user)
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!user.isPlatform && !user.roles.includes("owner")) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
name: string;
|
||||||
|
spec: PiecedTenantSpec;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(body.name) || body.name.length > 63) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid tenant name: lowercase alphanumeric and hyphens, 2-63 chars" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getTenant(body.name);
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Tenant already exists" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = await createTenant(body.name, body.spec, {
|
||||||
|
"pieced.ch/zitadel-org-id": user.orgId,
|
||||||
|
});
|
||||||
|
return NextResponse.json(tenant, { status: 201 });
|
||||||
|
}
|
||||||
107
src/app/api/usage/route.ts
Normal file
107
src/app/api/usage/route.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getTeamInfo, getTeamSpendLogs } from "@/lib/litellm";
|
||||||
|
|
||||||
|
// Pricing constants (CHF)
|
||||||
|
const INPUT_RATE = 3; // CHF per MTok
|
||||||
|
const OUTPUT_RATE = 15; // CHF per MTok
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = session.user as any;
|
||||||
|
if (!orgId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No org context" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The LiteLLM team_id maps to the tenant name, which is derived from orgId
|
||||||
|
// Convention: team_id = "pieced-{orgId}" or looked up from the tenant CR
|
||||||
|
const searchParams = req.nextUrl.searchParams;
|
||||||
|
const teamId = searchParams.get("teamId");
|
||||||
|
|
||||||
|
if (!teamId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "teamId query param required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Current period info
|
||||||
|
const teamInfo = await getTeamInfo(teamId);
|
||||||
|
|
||||||
|
// Historical spend logs (last 30 days)
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() - 30);
|
||||||
|
|
||||||
|
const spendLogs = await getTeamSpendLogs(
|
||||||
|
teamId,
|
||||||
|
startDate.toISOString().split("T")[0],
|
||||||
|
endDate.toISOString().split("T")[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate CHF costs from token counts
|
||||||
|
const dailyUsage = (spendLogs || []).map((day: any) => ({
|
||||||
|
date: day.date || day.day,
|
||||||
|
inputTokens: day.prompt_tokens || 0,
|
||||||
|
outputTokens: day.completion_tokens || 0,
|
||||||
|
inputCostCHF:
|
||||||
|
((day.prompt_tokens || 0) / 1_000_000) * INPUT_RATE,
|
||||||
|
outputCostCHF:
|
||||||
|
((day.completion_tokens || 0) / 1_000_000) * OUTPUT_RATE,
|
||||||
|
totalCostCHF:
|
||||||
|
((day.prompt_tokens || 0) / 1_000_000) * INPUT_RATE +
|
||||||
|
((day.completion_tokens || 0) / 1_000_000) * OUTPUT_RATE,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Totals for current period
|
||||||
|
const totalInputTokens = dailyUsage.reduce(
|
||||||
|
(s: number, d: any) => s + d.inputTokens,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalOutputTokens = dailyUsage.reduce(
|
||||||
|
(s: number, d: any) => s + d.outputTokens,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalCostCHF = dailyUsage.reduce(
|
||||||
|
(s: number, d: any) => s + d.totalCostCHF,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
teamId,
|
||||||
|
currentPeriod: {
|
||||||
|
inputTokens: totalInputTokens,
|
||||||
|
outputTokens: totalOutputTokens,
|
||||||
|
inputCostCHF: (totalInputTokens / 1_000_000) * INPUT_RATE,
|
||||||
|
outputCostCHF: (totalOutputTokens / 1_000_000) * OUTPUT_RATE,
|
||||||
|
totalCostCHF,
|
||||||
|
},
|
||||||
|
budget: {
|
||||||
|
maxBudget: teamInfo?.max_budget ?? null,
|
||||||
|
spend: teamInfo?.spend ?? 0,
|
||||||
|
remaining: teamInfo?.max_budget
|
||||||
|
? teamInfo.max_budget - (teamInfo.spend ?? 0)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
rateLimits: {
|
||||||
|
rpm: teamInfo?.rpm_limit ?? null,
|
||||||
|
tpm: teamInfo?.tpm_limit ?? null,
|
||||||
|
},
|
||||||
|
dailyUsage,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Usage fetch error:", err.message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch usage data" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/app/globals.css
Normal file
104
src/app/globals.css
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap");
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Design direction: Swiss Industrial
|
||||||
|
* Dark neutral base, sharp teal accent, geometric precision.
|
||||||
|
* Outfit for headings (geometric, characterful), system for body.
|
||||||
|
* No purple gradients, no Inter, no AI slop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-surface-0: #0a0c10;
|
||||||
|
--color-surface-1: #12151c;
|
||||||
|
--color-surface-2: #1a1e28;
|
||||||
|
--color-surface-3: #242a36;
|
||||||
|
|
||||||
|
--color-text-primary: #e8ecf4;
|
||||||
|
--color-text-secondary: #8892a4;
|
||||||
|
--color-text-muted: #565e6e;
|
||||||
|
|
||||||
|
--color-accent: #00d4aa;
|
||||||
|
--color-accent-dim: #00b892;
|
||||||
|
--color-accent-glow: #00d4aa26;
|
||||||
|
|
||||||
|
--color-border: #1e2330;
|
||||||
|
--color-border-active: #2c3344;
|
||||||
|
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-warning: #fbbf24;
|
||||||
|
--color-error: #f87171;
|
||||||
|
|
||||||
|
--font-display: "Outfit", system-ui, sans-serif;
|
||||||
|
--font-body: "Outfit", system-ui, sans-serif;
|
||||||
|
--font-mono: "IBM Plex Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: var(--color-surface-0);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility: subtle noise texture overlay */
|
||||||
|
.bg-noise::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.03;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent underline for headings */
|
||||||
|
.accent-rule {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.accent-rule::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4px;
|
||||||
|
left: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover lift */
|
||||||
|
.card-interactive {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.card-interactive:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
border-color: var(--color-border-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dot pulse */
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
.status-pulse {
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered fade-in for page content */
|
||||||
|
@keyframes fade-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-in {
|
||||||
|
animation: fade-up 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
.animate-in-delay-1 { animation-delay: 0.05s; }
|
||||||
|
.animate-in-delay-2 { animation-delay: 0.1s; }
|
||||||
|
.animate-in-delay-3 { animation-delay: 0.15s; }
|
||||||
9
src/app/layout.tsx
Normal file
9
src/app/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
193
src/components/admin/AdminTenantsClient.tsx
Normal file
193
src/components/admin/AdminTenantsClient.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PhaseBadge } from "@/components/dashboard/InstanceStatus";
|
||||||
|
|
||||||
|
interface TenantRow {
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
phase: string;
|
||||||
|
packages: string[];
|
||||||
|
agentName?: string;
|
||||||
|
created: string;
|
||||||
|
orgId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = "name" | "phase" | "packages" | "created";
|
||||||
|
type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
export default function AdminTenantsClient() {
|
||||||
|
const t = useTranslations("admin");
|
||||||
|
const router = useRouter();
|
||||||
|
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/tenants")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
const items = data.items || data || [];
|
||||||
|
setTenants(
|
||||||
|
items.map((t: any) => ({
|
||||||
|
name: t.metadata.name,
|
||||||
|
displayName: t.spec?.displayName,
|
||||||
|
phase: t.status?.phase || "Pending",
|
||||||
|
packages: t.spec?.packages || [],
|
||||||
|
agentName: t.spec?.agentName,
|
||||||
|
created: t.metadata.creationTimestamp,
|
||||||
|
orgId: t.metadata?.labels?.["zitadel-org-id"],
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleSort(key: SortKey) {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir("asc");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return [...tenants].sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
switch (sortKey) {
|
||||||
|
case "name":
|
||||||
|
cmp = (a.displayName || a.name).localeCompare(
|
||||||
|
b.displayName || b.name
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "phase":
|
||||||
|
cmp = a.phase.localeCompare(b.phase);
|
||||||
|
break;
|
||||||
|
case "packages":
|
||||||
|
cmp = a.packages.length - b.packages.length;
|
||||||
|
break;
|
||||||
|
case "created":
|
||||||
|
cmp =
|
||||||
|
new Date(a.created).getTime() - new Date(b.created).getTime();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sortDir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
}, [tenants, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const SortHeader = ({
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
field: SortKey;
|
||||||
|
}) => (
|
||||||
|
<th
|
||||||
|
onClick={() => toggleSort(field)}
|
||||||
|
className="cursor-pointer select-none px-3 py-2 text-left text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{sortKey === field && (
|
||||||
|
<span className="ml-1 text-teal-400">
|
||||||
|
{sortDir === "asc" ? "↑" : "↓"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 w-40 bg-zinc-800 rounded mb-4" />
|
||||||
|
<div className="h-64 bg-zinc-900/50 border border-zinc-800 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-100">
|
||||||
|
{t("tenants")}
|
||||||
|
</h1>
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{tenants.length} {t("total")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-zinc-800">
|
||||||
|
<tr>
|
||||||
|
<SortHeader label={t("name")} field="name" />
|
||||||
|
<SortHeader label={t("phase")} field="phase" />
|
||||||
|
<SortHeader label={t("packages")} field="packages" />
|
||||||
|
<SortHeader label={t("created")} field="created" />
|
||||||
|
<th className="px-3 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800/50">
|
||||||
|
{sorted.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.name}
|
||||||
|
className="hover:bg-zinc-800/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="text-zinc-200">
|
||||||
|
{row.displayName || row.name}
|
||||||
|
</div>
|
||||||
|
{row.agentName && (
|
||||||
|
<div className="text-[10px] text-zinc-600">
|
||||||
|
{row.agentName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<PhaseBadge phase={row.phase} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{row.packages.length === 0 ? (
|
||||||
|
<span className="text-xs text-zinc-600">—</span>
|
||||||
|
) : (
|
||||||
|
row.packages.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p}
|
||||||
|
className="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-400"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-xs text-zinc-500">
|
||||||
|
{new Date(row.created).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/tenants/${row.name}`)}
|
||||||
|
className="text-xs text-teal-400 hover:text-teal-300"
|
||||||
|
>
|
||||||
|
{t("manage")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{sorted.length === 0 && (
|
||||||
|
<div className="py-12 text-center text-sm text-zinc-600">
|
||||||
|
{t("noTenants")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/components/dashboard/DashboardClient.tsx
Normal file
124
src/components/dashboard/DashboardClient.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import InstanceStatus from "@/components/dashboard/InstanceStatus";
|
||||||
|
import UsageDisplay from "@/components/dashboard/UsageDisplay";
|
||||||
|
|
||||||
|
interface TenantSummary {
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
creationTimestamp: string;
|
||||||
|
};
|
||||||
|
spec: {
|
||||||
|
agentName?: string;
|
||||||
|
packages?: string[];
|
||||||
|
litellmTeamId?: string;
|
||||||
|
};
|
||||||
|
status?: {
|
||||||
|
phase: string;
|
||||||
|
conditions?: { type: string; status: string; message?: string }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const t = useTranslations("dashboard");
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const [tenant, setTenant] = useState<TenantSummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/tenants")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
// For non-platform users: pick first tenant from the list
|
||||||
|
const items = data.items || data || [];
|
||||||
|
if (items.length > 0) {
|
||||||
|
setTenant(items[0]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
<div className="h-32 rounded-lg bg-zinc-900/50 border border-zinc-800" />
|
||||||
|
<div className="h-64 rounded-lg bg-zinc-900/50 border border-zinc-800" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tenant yet — show CTA
|
||||||
|
if (!tenant) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div className="rounded-full bg-teal-950/50 border border-teal-800/30 p-4 mb-4">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-teal-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 4.5v15m7.5-7.5h-15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-medium text-zinc-200 mb-1">
|
||||||
|
{t("noInstance")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-500 mb-6 max-w-sm">
|
||||||
|
{t("noInstanceDescription")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/onboarding")}
|
||||||
|
className="rounded-lg bg-teal-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-teal-500 transition-colors"
|
||||||
|
>
|
||||||
|
{t("getStarted")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantName = tenant.metadata.name;
|
||||||
|
const teamId = tenant.spec.litellmTeamId || tenantName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-100">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<InstanceStatus
|
||||||
|
phase={tenant.status?.phase || "Pending"}
|
||||||
|
conditions={tenant.status?.conditions}
|
||||||
|
agentName={tenant.spec.agentName}
|
||||||
|
packages={tenant.spec.packages}
|
||||||
|
createdAt={tenant.metadata.creationTimestamp}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-zinc-300 mb-3">
|
||||||
|
{t("usageTitle")}
|
||||||
|
</h2>
|
||||||
|
<UsageDisplay teamId={teamId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick link to tenant settings */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/tenants/${tenantName}`)}
|
||||||
|
className="text-xs text-teal-400 hover:text-teal-300 transition-colors"
|
||||||
|
>
|
||||||
|
{t("manageInstance")} →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/dashboard/InstanceStatus.tsx
Normal file
111
src/components/dashboard/InstanceStatus.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
type Phase = "Running" | "Provisioning" | "Pending" | "Error" | string;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
phase: Phase;
|
||||||
|
conditions?: { type: string; status: string; message?: string }[];
|
||||||
|
agentName?: string;
|
||||||
|
packages?: string[];
|
||||||
|
createdAt?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASE_STYLES: Record<string, { bg: string; text: string; dot: string }> = {
|
||||||
|
Running: {
|
||||||
|
bg: "bg-emerald-950/50 border-emerald-800/50",
|
||||||
|
text: "text-emerald-400",
|
||||||
|
dot: "bg-emerald-400 animate-pulse",
|
||||||
|
},
|
||||||
|
Provisioning: {
|
||||||
|
bg: "bg-amber-950/50 border-amber-800/50",
|
||||||
|
text: "text-amber-400",
|
||||||
|
dot: "bg-amber-400 animate-pulse",
|
||||||
|
},
|
||||||
|
Pending: {
|
||||||
|
bg: "bg-zinc-800/50 border-zinc-700/50",
|
||||||
|
text: "text-zinc-400",
|
||||||
|
dot: "bg-zinc-400",
|
||||||
|
},
|
||||||
|
Error: {
|
||||||
|
bg: "bg-red-950/50 border-red-800/50",
|
||||||
|
text: "text-red-400",
|
||||||
|
dot: "bg-red-400",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PhaseBadge({ phase }: { phase: Phase }) {
|
||||||
|
const style = PHASE_STYLES[phase] || PHASE_STYLES.Pending;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${style.bg} ${style.text}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
|
||||||
|
{phase}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InstanceStatus({
|
||||||
|
phase,
|
||||||
|
conditions,
|
||||||
|
agentName,
|
||||||
|
packages,
|
||||||
|
createdAt,
|
||||||
|
compact = false,
|
||||||
|
}: Props) {
|
||||||
|
const t = useTranslations("dashboard");
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return <PhaseBadge phase={phase} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCondition = conditions?.find(
|
||||||
|
(c) => c.status === "False" && c.message
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-zinc-300">
|
||||||
|
{t("instanceStatus")}
|
||||||
|
</h3>
|
||||||
|
<PhaseBadge phase={phase} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agentName && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-zinc-500">{t("agentName")}:</span>{" "}
|
||||||
|
<span className="text-zinc-200">{agentName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{packages && packages.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<span
|
||||||
|
key={pkg}
|
||||||
|
className="rounded bg-teal-950/50 border border-teal-800/30 px-2 py-0.5 text-xs text-teal-300"
|
||||||
|
>
|
||||||
|
{pkg}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createdAt && (
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{t("created")}: {new Date(createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorCondition && (
|
||||||
|
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
|
||||||
|
{errorCondition.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
src/components/dashboard/UsageDisplay.tsx
Normal file
216
src/components/dashboard/UsageDisplay.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface DailyUsage {
|
||||||
|
date: string;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
totalCostCHF: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsageData {
|
||||||
|
currentPeriod: {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
inputCostCHF: number;
|
||||||
|
outputCostCHF: number;
|
||||||
|
totalCostCHF: number;
|
||||||
|
};
|
||||||
|
budget: {
|
||||||
|
maxBudget: number | null;
|
||||||
|
spend: number;
|
||||||
|
remaining: number | null;
|
||||||
|
};
|
||||||
|
rateLimits: {
|
||||||
|
rpm: number | null;
|
||||||
|
tpm: number | null;
|
||||||
|
};
|
||||||
|
dailyUsage: DailyUsage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||||
|
return n.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCHF(n: number): string {
|
||||||
|
return `CHF ${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageChart({ data }: { data: DailyUsage[] }) {
|
||||||
|
if (!data.length) return null;
|
||||||
|
|
||||||
|
const maxTokens = Math.max(
|
||||||
|
...data.map((d) => d.inputTokens + d.outputTokens),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const barWidth = Math.max(4, Math.floor(600 / data.length) - 2);
|
||||||
|
const chartHeight = 120;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${Math.max(data.length * (barWidth + 2), 600)} ${chartHeight + 24}`}
|
||||||
|
className="w-full h-36"
|
||||||
|
preserveAspectRatio="xMinYMid meet"
|
||||||
|
>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const total = d.inputTokens + d.outputTokens;
|
||||||
|
const totalH = (total / maxTokens) * chartHeight;
|
||||||
|
const inputH = (d.inputTokens / maxTokens) * chartHeight;
|
||||||
|
const x = i * (barWidth + 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={d.date}>
|
||||||
|
<title>
|
||||||
|
{d.date}: {formatTokens(d.inputTokens)} in / {formatTokens(d.outputTokens)} out
|
||||||
|
</title>
|
||||||
|
{/* Output tokens (bottom) */}
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={chartHeight - totalH}
|
||||||
|
width={barWidth}
|
||||||
|
height={totalH - inputH}
|
||||||
|
rx={1}
|
||||||
|
className="fill-teal-700/60"
|
||||||
|
/>
|
||||||
|
{/* Input tokens (top) */}
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={chartHeight - inputH}
|
||||||
|
width={barWidth}
|
||||||
|
height={inputH}
|
||||||
|
rx={1}
|
||||||
|
className="fill-teal-400/80"
|
||||||
|
/>
|
||||||
|
{/* Date label (every 7th) */}
|
||||||
|
{i % 7 === 0 && (
|
||||||
|
<text
|
||||||
|
x={x + barWidth / 2}
|
||||||
|
y={chartHeight + 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-zinc-500 text-[8px]"
|
||||||
|
>
|
||||||
|
{d.date.slice(5)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-zinc-500 mt-1">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-sm bg-teal-400/80" /> Input
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-sm bg-teal-700/60" /> Output
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsageDisplay({ teamId }: { teamId: string | null }) {
|
||||||
|
const t = useTranslations("dashboard");
|
||||||
|
const [data, setData] = useState<UsageData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!teamId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/usage?teamId=${encodeURIComponent(teamId)}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(setData)
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [teamId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 animate-pulse">
|
||||||
|
<div className="h-4 w-32 bg-zinc-800 rounded mb-4" />
|
||||||
|
<div className="h-36 bg-zinc-800/50 rounded" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
{error ? t("usageError") : t("noUsageData")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentPeriod, budget, rateLimits, dailyUsage } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Spend summary cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<StatCard label={t("inputTokens")} value={formatTokens(currentPeriod.inputTokens)} sub={formatCHF(currentPeriod.inputCostCHF)} />
|
||||||
|
<StatCard label={t("outputTokens")} value={formatTokens(currentPeriod.outputTokens)} sub={formatCHF(currentPeriod.outputCostCHF)} />
|
||||||
|
<StatCard label={t("totalCost")} value={formatCHF(currentPeriod.totalCostCHF)} accent />
|
||||||
|
{budget.remaining !== null ? (
|
||||||
|
<StatCard label={t("budgetRemaining")} value={formatCHF(budget.remaining)} />
|
||||||
|
) : (
|
||||||
|
<StatCard label={t("budget")} value={t("noBudgetSet")} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rate limits */}
|
||||||
|
{(rateLimits.rpm || rateLimits.tpm) && (
|
||||||
|
<div className="flex gap-4 text-xs text-zinc-500">
|
||||||
|
{rateLimits.rpm && <span>RPM limit: {rateLimits.rpm}</span>}
|
||||||
|
{rateLimits.tpm && <span>TPM limit: {formatTokens(rateLimits.tpm)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
||||||
|
<h3 className="text-sm font-medium text-zinc-300 mb-3">
|
||||||
|
{t("last30Days")}
|
||||||
|
</h3>
|
||||||
|
<UsageChart data={dailyUsage} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
accent?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
|
||||||
|
<div className="text-xs text-zinc-500 mb-1">{label}</div>
|
||||||
|
<div
|
||||||
|
className={`text-lg font-semibold tabular-nums ${
|
||||||
|
accent ? "text-teal-400" : "text-zinc-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{sub && <div className="text-xs text-zinc-500 mt-0.5">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/layout/nav-shell.tsx
Normal file
104
src/components/layout/nav-shell.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
import { usePathname } from "@/i18n/navigation";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||||
|
|
||||||
|
function NavBar() {
|
||||||
|
const t = useTranslations("common");
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const user = (session as any)?.platformUser;
|
||||||
|
|
||||||
|
const isLogin = pathname === "/login";
|
||||||
|
if (isLogin) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||||
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||||
|
{/* Logo / brand */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-2.5 group">
|
||||||
|
{/* Geometric mark */}
|
||||||
|
<div className="relative h-7 w-7">
|
||||||
|
<div className="absolute inset-0 rounded-md bg-accent/20 group-hover:bg-accent/30 transition-colors" />
|
||||||
|
<div className="absolute inset-[3px] rounded-sm bg-accent" />
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-base font-semibold tracking-tight text-text-primary">
|
||||||
|
{t("appName")}
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline text-[11px] font-medium tracking-widest uppercase text-text-muted">
|
||||||
|
{t("tagline")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Nav links */}
|
||||||
|
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||||
|
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||||
|
{t("dashboard")}
|
||||||
|
</NavLink>
|
||||||
|
{user?.isPlatform && (
|
||||||
|
<NavLink href="/admin" active={pathname === "/admin"}>
|
||||||
|
{t("admin")}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{user && (
|
||||||
|
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
||||||
|
{user.orgName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({
|
||||||
|
href,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
active: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`
|
||||||
|
px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||||
|
${
|
||||||
|
active
|
||||||
|
? "bg-surface-3 text-text-primary"
|
||||||
|
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<NavBar />
|
||||||
|
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
src/components/packages/PackageCard.tsx
Normal file
224
src/components/packages/PackageCard.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { PackageDef } from "@/lib/packages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pkg: PackageDef;
|
||||||
|
enabled: boolean;
|
||||||
|
status?: "pending" | "active" | "error";
|
||||||
|
tenantName: string;
|
||||||
|
onToggle: (
|
||||||
|
packageId: string,
|
||||||
|
enable: boolean,
|
||||||
|
secrets?: Record<string, string>
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageCard({
|
||||||
|
pkg,
|
||||||
|
enabled,
|
||||||
|
status,
|
||||||
|
tenantName,
|
||||||
|
onToggle,
|
||||||
|
}: Props) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [secrets, setSecrets] = useState<Record<string, string>>({});
|
||||||
|
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const statusStyles = {
|
||||||
|
pending: "text-amber-400",
|
||||||
|
active: "text-emerald-400",
|
||||||
|
error: "text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleEnable() {
|
||||||
|
if (pkg.requiresSecrets) {
|
||||||
|
setShowModal(true);
|
||||||
|
setSecrets({});
|
||||||
|
setDisclaimerAccepted(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onToggle(pkg.id, true);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onToggle(pkg.id, false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitSecrets() {
|
||||||
|
if (!disclaimerAccepted && pkg.disclaimerKey) return;
|
||||||
|
|
||||||
|
const requiredKeys = (pkg.secrets || []).map((s) => s.key);
|
||||||
|
const missing = requiredKeys.filter((k) => !secrets[k]?.trim());
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setError(t("packages.missingFields"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Write secrets first
|
||||||
|
const secretRes = await fetch(
|
||||||
|
`/api/tenants/${tenantName}/secrets`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ packageId: pkg.id, secrets }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!secretRes.ok) {
|
||||||
|
const err = await secretRes.json();
|
||||||
|
throw new Error(err.error || "Failed to store secrets");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then enable the package
|
||||||
|
await onToggle(pkg.id, true);
|
||||||
|
setShowModal(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4 flex flex-col gap-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-zinc-200">
|
||||||
|
{pkg.name}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500 uppercase tracking-wide">
|
||||||
|
{pkg.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
{t(pkg.descriptionKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enabled && status && (
|
||||||
|
<span className={`text-xs ${statusStyles[status] || ""}`}>
|
||||||
|
{t(`packages.status.${status}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-auto pt-2 border-t border-zinc-800/50">
|
||||||
|
{pkg.requiresSecrets && (
|
||||||
|
<span className="text-[10px] text-zinc-600">
|
||||||
|
{t("packages.requiresApiKey")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={enabled ? handleDisable : handleEnable}
|
||||||
|
disabled={saving}
|
||||||
|
className={`ml-auto rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
enabled
|
||||||
|
? "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||||||
|
: "bg-teal-600 text-white hover:bg-teal-500"
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? "..."
|
||||||
|
: enabled
|
||||||
|
? t("packages.disable")
|
||||||
|
: t("packages.enable")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secret input modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-zinc-700 bg-zinc-900 p-6 space-y-4">
|
||||||
|
<h3 className="text-base font-medium text-zinc-100">
|
||||||
|
{t("packages.configure")} {pkg.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{pkg.customerInstructionsKey && (
|
||||||
|
<div className="rounded bg-zinc-800/50 border border-zinc-700/50 p-3 text-xs text-zinc-400 leading-relaxed">
|
||||||
|
{t(pkg.customerInstructionsKey)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(pkg.secrets || []).map((secret) => (
|
||||||
|
<label key={secret.key} className="block">
|
||||||
|
<span className="text-xs text-zinc-400 mb-1 block">
|
||||||
|
{t(secret.labelKey)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder={t(secret.placeholderKey)}
|
||||||
|
value={secrets[secret.key] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSecrets((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[secret.key]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600 focus:border-teal-600 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pkg.disclaimerKey && (
|
||||||
|
<label className="flex items-start gap-2 text-xs text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={disclaimerAccepted}
|
||||||
|
onChange={(e) => setDisclaimerAccepted(e.target.checked)}
|
||||||
|
className="mt-0.5 rounded border-zinc-600 bg-zinc-800 accent-teal-500"
|
||||||
|
/>
|
||||||
|
<span>{t(pkg.disclaimerKey)}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="rounded px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitSecrets}
|
||||||
|
disabled={
|
||||||
|
saving || (!!pkg.disclaimerKey && !disclaimerAccepted)
|
||||||
|
}
|
||||||
|
className="rounded bg-teal-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-teal-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "..." : t("packages.enableAndSave")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/packages/WorkspaceEditor.tsx
Normal file
87
src/components/packages/WorkspaceEditor.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface WorkspaceFile {
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tenantName: string;
|
||||||
|
files: WorkspaceFile[];
|
||||||
|
onSave: (files: WorkspaceFile[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const;
|
||||||
|
|
||||||
|
export default function WorkspaceEditor({ tenantName, files, onSave }: Props) {
|
||||||
|
const t = useTranslations("workspace");
|
||||||
|
const [activeTab, setActiveTab] = useState<string>("SOUL.md");
|
||||||
|
const [localFiles, setLocalFiles] = useState<WorkspaceFile[]>(files);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
const activeFile = localFiles.find((f) => f.name === activeTab);
|
||||||
|
|
||||||
|
function handleChange(content: string) {
|
||||||
|
setLocalFiles((prev) =>
|
||||||
|
prev.map((f) => (f.name === activeTab ? { ...f, content } : f))
|
||||||
|
);
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(localFiles);
|
||||||
|
setDirty(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900/50">
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{FILE_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`rounded px-2.5 py-1 text-xs font-mono transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? "bg-zinc-800 text-teal-400"
|
||||||
|
: "text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
className="rounded bg-teal-600 px-3 py-1 text-xs font-medium text-white hover:bg-teal-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? "..." : t("save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={activeFile?.content || ""}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
className="w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-zinc-300 placeholder:text-zinc-700 focus:outline-none"
|
||||||
|
placeholder={t("placeholder", { file: activeTab })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-zinc-800 px-4 py-2">
|
||||||
|
<p className="text-[10px] text-zinc-600 leading-relaxed">
|
||||||
|
{t("seedingNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/tenants/TenantDetailClient.tsx
Normal file
196
src/components/tenants/TenantDetailClient.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
|
||||||
|
import { PhaseBadge } from "@/components/dashboard/InstanceStatus";
|
||||||
|
import PackageCard from "@/components/packages/PackageCard";
|
||||||
|
import WorkspaceEditor from "@/components/packages/WorkspaceEditor";
|
||||||
|
|
||||||
|
interface TenantCR {
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
creationTimestamp: string;
|
||||||
|
resourceVersion: string;
|
||||||
|
};
|
||||||
|
spec: {
|
||||||
|
agentName?: string;
|
||||||
|
displayName?: string;
|
||||||
|
packages?: string[];
|
||||||
|
workspaceFiles?: { name: string; content: string }[];
|
||||||
|
litellmTeamId?: string;
|
||||||
|
};
|
||||||
|
status?: {
|
||||||
|
phase: string;
|
||||||
|
conditions?: {
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
message?: string;
|
||||||
|
reason?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TenantDetailClient() {
|
||||||
|
const t = useTranslations("tenantDetail");
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const [tenant, setTenant] = useState<TenantCR | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTenant = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/tenants/${name}`);
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
setTenant(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTenant();
|
||||||
|
}, [fetchTenant]);
|
||||||
|
|
||||||
|
async function handlePackageToggle(
|
||||||
|
packageId: string,
|
||||||
|
enable: boolean
|
||||||
|
) {
|
||||||
|
if (!tenant) return;
|
||||||
|
|
||||||
|
const currentPackages = tenant.spec.packages || [];
|
||||||
|
const newPackages = enable
|
||||||
|
? [...currentPackages, packageId]
|
||||||
|
: currentPackages.filter((p) => p !== packageId);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/tenants/${name}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ packages: newPackages }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to update packages");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refetch tenant state
|
||||||
|
await fetchTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWorkspaceSave(
|
||||||
|
files: { name: string; content: string }[]
|
||||||
|
) {
|
||||||
|
const res = await fetch(`/api/tenants/${name}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ workspaceFiles: files }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to update workspace files");
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchTenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageStatus(
|
||||||
|
pkgId: string
|
||||||
|
): "pending" | "active" | "error" | undefined {
|
||||||
|
if (!tenant?.status?.conditions) return undefined;
|
||||||
|
const cond = tenant.status.conditions.find(
|
||||||
|
(c) => c.type === `Package/${pkgId}`
|
||||||
|
);
|
||||||
|
if (!cond) return "pending";
|
||||||
|
if (cond.status === "True") return "active";
|
||||||
|
if (cond.status === "False") return "error";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
<div className="h-8 w-48 bg-zinc-800 rounded" />
|
||||||
|
<div className="h-40 bg-zinc-900/50 border border-zinc-800 rounded-lg" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="h-28 bg-zinc-900/50 border border-zinc-800 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !tenant) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-red-900/30 bg-red-950/20 p-4 text-sm text-red-400">
|
||||||
|
{error || t("notFound")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledPackages = tenant.spec.packages || [];
|
||||||
|
const workspaceFiles = tenant.spec.workspaceFiles || [
|
||||||
|
{ name: "SOUL.md", content: "" },
|
||||||
|
{ name: "AGENTS.md", content: "" },
|
||||||
|
{ name: "TOOLS.md", content: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-100">
|
||||||
|
{tenant.spec.displayName || name}
|
||||||
|
</h1>
|
||||||
|
{tenant.spec.agentName && (
|
||||||
|
<p className="text-sm text-zinc-500 mt-0.5">
|
||||||
|
{t("agent")}: {tenant.spec.agentName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PhaseBadge phase={tenant.status?.phase || "Pending"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Packages */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-zinc-300 mb-3">
|
||||||
|
{t("packages")}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{PACKAGE_CATALOG.map((pkg) => (
|
||||||
|
<PackageCard
|
||||||
|
key={pkg.id}
|
||||||
|
pkg={pkg}
|
||||||
|
enabled={enabledPackages.includes(pkg.id)}
|
||||||
|
status={
|
||||||
|
enabledPackages.includes(pkg.id)
|
||||||
|
? getPackageStatus(pkg.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
tenantName={name}
|
||||||
|
onToggle={handlePackageToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Workspace files */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-medium text-zinc-300 mb-3">
|
||||||
|
{t("workspaceFiles")}
|
||||||
|
</h2>
|
||||||
|
<WorkspaceEditor
|
||||||
|
tenantName={name}
|
||||||
|
files={workspaceFiles}
|
||||||
|
onSave={handleWorkspaceSave}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/ui/card.tsx
Normal file
37
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
interactive = false,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
interactive?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
rounded-xl border border-border bg-surface-1 p-6
|
||||||
|
${interactive ? "card-interactive cursor-pointer" : ""}
|
||||||
|
${className}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
className={`text-xs font-semibold uppercase tracking-wider text-text-muted mb-3 ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/ui/language-switcher.tsx
Normal file
91
src/components/ui/language-switcher.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { useRouter, usePathname } from "@/i18n/navigation";
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
const LOCALE_LABELS: Record<string, string> = {
|
||||||
|
de: "DE",
|
||||||
|
fr: "FR",
|
||||||
|
it: "IT",
|
||||||
|
en: "EN",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCALE_NAMES: Record<string, string> = {
|
||||||
|
de: "Deutsch",
|
||||||
|
fr: "Français",
|
||||||
|
it: "Italiano",
|
||||||
|
en: "English",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function switchLocale(next: string) {
|
||||||
|
setOpen(false);
|
||||||
|
router.replace(pathname, { locale: next as any });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="
|
||||||
|
flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium
|
||||||
|
text-text-secondary hover:text-text-primary hover:bg-surface-2
|
||||||
|
transition-colors cursor-pointer
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span className="font-mono">{LOCALE_LABELS[locale]}</span>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<path d="M3 4.5L6 7.5L9 4.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 w-32 rounded-lg border border-border bg-surface-1 shadow-xl shadow-black/30 overflow-hidden z-50">
|
||||||
|
{Object.entries(LOCALE_NAMES).map(([code, name]) => (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
onClick={() => switchLocale(code)}
|
||||||
|
className={`
|
||||||
|
w-full px-3 py-2 text-left text-xs transition-colors cursor-pointer
|
||||||
|
flex items-center justify-between
|
||||||
|
${
|
||||||
|
code === locale
|
||||||
|
? "bg-surface-3 text-accent font-medium"
|
||||||
|
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span>{name}</span>
|
||||||
|
<span className="font-mono text-text-muted">
|
||||||
|
{LOCALE_LABELS[code]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/ui/status-badge.tsx
Normal file
29
src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const phaseStyles: Record<string, string> = {
|
||||||
|
Running:
|
||||||
|
"bg-success/10 text-success border-success/20",
|
||||||
|
Provisioning:
|
||||||
|
"bg-warning/10 text-warning border-warning/20",
|
||||||
|
Pending:
|
||||||
|
"bg-text-muted/10 text-text-secondary border-border",
|
||||||
|
Error:
|
||||||
|
"bg-error/10 text-error border-error/20",
|
||||||
|
Deleting:
|
||||||
|
"bg-text-muted/10 text-text-muted border-border",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ phase }: { phase: string }) {
|
||||||
|
const style = phaseStyles[phase] ?? phaseStyles.Pending;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${style}`}
|
||||||
|
>
|
||||||
|
{phase === "Running" && (
|
||||||
|
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-success" />
|
||||||
|
)}
|
||||||
|
{phase === "Provisioning" && (
|
||||||
|
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
|
||||||
|
)}
|
||||||
|
{phase}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/i18n/navigation.ts
Normal file
5
src/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createNavigation } from "next-intl/navigation";
|
||||||
|
import { routing } from "./routing";
|
||||||
|
|
||||||
|
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||||
|
createNavigation(routing);
|
||||||
14
src/i18n/request.ts
Normal file
14
src/i18n/request.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
import { routing } from "./routing";
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
let locale = await requestLocale;
|
||||||
|
if (!locale || !routing.locales.includes(locale as any)) {
|
||||||
|
locale = routing.defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default,
|
||||||
|
};
|
||||||
|
});
|
||||||
7
src/i18n/routing.ts
Normal file
7
src/i18n/routing.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineRouting } from "next-intl/routing";
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ["de", "fr", "it", "en"],
|
||||||
|
defaultLocale: "de",
|
||||||
|
localePrefix: "as-needed",
|
||||||
|
});
|
||||||
75
src/lib/auth.ts
Normal file
75
src/lib/auth.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
import type { PlatformRole, SessionUser, ZitadelClaims } from "@/types";
|
||||||
|
|
||||||
|
const PLATFORM_ROLES: PlatformRole[] = ["platform_admin", "platform_operator"];
|
||||||
|
|
||||||
|
function extractRoles(
|
||||||
|
rolesObj?: Record<string, Record<string, string>>
|
||||||
|
): PlatformRole[] {
|
||||||
|
if (!rolesObj) return [];
|
||||||
|
return Object.keys(rolesObj) as PlatformRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authConfig: NextAuthConfig = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: "zitadel",
|
||||||
|
name: "ZITADEL",
|
||||||
|
type: "oidc",
|
||||||
|
issuer: process.env.ZITADEL_ISSUER!,
|
||||||
|
clientId: process.env.ZITADEL_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.ZITADEL_CLIENT_SECRET!,
|
||||||
|
authorization: {
|
||||||
|
params: {
|
||||||
|
scope:
|
||||||
|
"openid profile email urn:zitadel:iam:org:project:roles urn:zitadel:iam:user:resourceowner",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.sub,
|
||||||
|
name: profile.name,
|
||||||
|
email: profile.email,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, account, profile }) {
|
||||||
|
if (account && profile) {
|
||||||
|
const claims = profile as unknown as ZitadelClaims;
|
||||||
|
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||||
|
token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"];
|
||||||
|
token.roles = extractRoles(
|
||||||
|
claims["urn:zitadel:iam:org:project:roles"]
|
||||||
|
);
|
||||||
|
token.accessToken = account.access_token;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
const roles = (token.roles as PlatformRole[]) ?? [];
|
||||||
|
const sessionUser: SessionUser = {
|
||||||
|
id: token.sub!,
|
||||||
|
name: session.user?.name ?? "",
|
||||||
|
email: session.user?.email ?? "",
|
||||||
|
orgId: token.orgId as string,
|
||||||
|
orgName: token.orgName as string,
|
||||||
|
roles,
|
||||||
|
isPlatform: roles.some((r) => PLATFORM_ROLES.includes(r)),
|
||||||
|
};
|
||||||
|
(session as any).platformUser = sessionUser;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
maxAge: 8 * 60 * 60,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
|
||||||
132
src/lib/k8s.ts
Normal file
132
src/lib/k8s.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import * as k8s from "@kubernetes/client-node";
|
||||||
|
import type { PiecedTenant, PiecedTenantSpec } from "@/types";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
const kc = new k8s.KubeConfig();
|
||||||
|
if (process.env.KUBERNETES_SERVICE_HOST) {
|
||||||
|
kc.loadFromCluster();
|
||||||
|
} else {
|
||||||
|
kc.loadFromDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_VERSION = "pieced.ch/v1alpha1";
|
||||||
|
const PLURAL = "piecedtenants";
|
||||||
|
|
||||||
|
// Raw K8s API client — avoids @kubernetes/client-node API surface instability
|
||||||
|
// across versions. The REST API itself is stable.
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
const cluster = kc.getCurrentCluster();
|
||||||
|
if (!cluster) throw new Error("No active K8s cluster in kubeconfig");
|
||||||
|
return cluster.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
// In-cluster: read SA token
|
||||||
|
if (process.env.KUBERNETES_SERVICE_HOST) {
|
||||||
|
const token = readFileSync(
|
||||||
|
"/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
// Local dev: extract token from kubeconfig current user
|
||||||
|
const user = kc.getCurrentUser();
|
||||||
|
if (user?.token) {
|
||||||
|
return { Authorization: `Bearer ${user.token}` };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function k8sRequest<T>(
|
||||||
|
path: string,
|
||||||
|
method: string = "GET",
|
||||||
|
body?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
const err = new Error(`K8s ${method} ${path}: ${res.status} ${text}`);
|
||||||
|
(err as any).statusCode = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTenants(): Promise<PiecedTenant[]> {
|
||||||
|
const result = await k8sRequest<{ items: PiecedTenant[] }>("");
|
||||||
|
return result.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTenant(name: string): Promise<PiecedTenant | null> {
|
||||||
|
try {
|
||||||
|
return await k8sRequest<PiecedTenant>(`/${name}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.statusCode === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTenant(
|
||||||
|
name: string,
|
||||||
|
spec: PiecedTenantSpec,
|
||||||
|
labels?: Record<string, string>
|
||||||
|
): Promise<PiecedTenant> {
|
||||||
|
return k8sRequest<PiecedTenant>("", "POST", {
|
||||||
|
apiVersion: API_VERSION,
|
||||||
|
kind: "PiecedTenant",
|
||||||
|
metadata: { name, labels },
|
||||||
|
spec,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTenantSpec(
|
||||||
|
name: string,
|
||||||
|
spec: Partial<PiecedTenantSpec>
|
||||||
|
): Promise<PiecedTenant> {
|
||||||
|
const existing = await getTenant(name);
|
||||||
|
if (!existing) throw new Error(`Tenant ${name} not found`);
|
||||||
|
|
||||||
|
return k8sRequest<PiecedTenant>(`/${name}`, "PUT", {
|
||||||
|
...existing,
|
||||||
|
spec: { ...existing.spec, ...spec },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTenant(name: string): Promise<void> {
|
||||||
|
await k8sRequest(`/${name}`, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchTenantSpec(
|
||||||
|
name: string,
|
||||||
|
spec: Partial<PiecedTenantSpec>
|
||||||
|
): Promise<PiecedTenant> {
|
||||||
|
const url = `${getBaseUrl()}/apis/${API_VERSION}/${PLURAL}/${name}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/merge-patch+json",
|
||||||
|
...getAuthHeaders(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ spec }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
const err = new Error(`K8s PATCH /${name}: ${res.status} ${text}`);
|
||||||
|
(err as any).statusCode = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return res.json() as Promise<PiecedTenant>;
|
||||||
|
}
|
||||||
33
src/lib/litellm.ts
Normal file
33
src/lib/litellm.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const LITELLM_URL =
|
||||||
|
process.env.LITELLM_INTERNAL_URL ?? "http://litellm.inference.svc:4000";
|
||||||
|
const LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY!;
|
||||||
|
|
||||||
|
async function litellmFetch(path: string, init?: RequestInit) {
|
||||||
|
const res = await fetch(`${LITELLM_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${LITELLM_MASTER_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`LiteLLM ${path}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamInfo(teamId: string) {
|
||||||
|
return litellmFetch(`/team/info?team_id=${encodeURIComponent(teamId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamSpendLogs(
|
||||||
|
teamId: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({ team_id: teamId });
|
||||||
|
if (startDate) params.set("start_date", startDate);
|
||||||
|
if (endDate) params.set("end_date", endDate);
|
||||||
|
return litellmFetch(`/global/spend/logs?${params}`);
|
||||||
|
}
|
||||||
92
src/lib/openbao.ts
Normal file
92
src/lib/openbao.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
const OPENBAO_ADDR =
|
||||||
|
process.env.OPENBAO_ADDR || "http://openbao.openbao.svc:8200";
|
||||||
|
const SA_TOKEN_PATH =
|
||||||
|
"/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||||
|
const K8S_AUTH_ROLE = process.env.OPENBAO_K8S_ROLE || "pieced-portal";
|
||||||
|
const K8S_AUTH_MOUNT = process.env.OPENBAO_K8S_MOUNT || "kubernetes";
|
||||||
|
|
||||||
|
let cachedToken: { token: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
async function authenticate(): Promise<string> {
|
||||||
|
if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) {
|
||||||
|
return cachedToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = readFileSync(SA_TOKEN_PATH, "utf-8").trim();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${OPENBAO_ADDR}/v1/auth/${K8S_AUTH_MOUNT}/login`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ role: K8S_AUTH_ROLE, jwt }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`OpenBao K8s auth failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const token = data.auth.client_token as string;
|
||||||
|
const leaseDuration = (data.auth.lease_duration as number) || 3600;
|
||||||
|
|
||||||
|
cachedToken = {
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + leaseDuration * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write secrets for a tenant package to OpenBao KV v2.
|
||||||
|
* Path: secret/data/tenants/{tenantId}/{packageId}
|
||||||
|
*/
|
||||||
|
export async function writePackageSecrets(
|
||||||
|
tenantId: string,
|
||||||
|
packageId: string,
|
||||||
|
secrets: Record<string, string>
|
||||||
|
): Promise<void> {
|
||||||
|
const token = await authenticate();
|
||||||
|
const path = `secret/data/tenants/${tenantId}/${packageId}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Vault-Token": token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ data: secrets }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`OpenBao write failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete secrets for a tenant package from OpenBao KV v2.
|
||||||
|
* Uses metadata delete to remove all versions.
|
||||||
|
*/
|
||||||
|
export async function deletePackageSecrets(
|
||||||
|
tenantId: string,
|
||||||
|
packageId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const token = await authenticate();
|
||||||
|
const path = `secret/metadata/tenants/${tenantId}/${packageId}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${OPENBAO_ADDR}/v1/${path}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "X-Vault-Token": token },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`OpenBao delete failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/lib/packages.ts
Normal file
104
src/lib/packages.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
export interface PackageDef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
descriptionKey: string; // i18n key
|
||||||
|
icon: string; // emoji or lucide icon name
|
||||||
|
requiresSecrets: boolean;
|
||||||
|
secrets?: {
|
||||||
|
key: string;
|
||||||
|
labelKey: string;
|
||||||
|
placeholderKey: string;
|
||||||
|
}[];
|
||||||
|
customerInstructionsKey?: string; // i18n key for how-to
|
||||||
|
disclaimerKey?: string; // i18n key
|
||||||
|
category: "channel" | "skill";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||||
|
{
|
||||||
|
id: "telegram",
|
||||||
|
name: "Telegram",
|
||||||
|
descriptionKey: "packages.telegram.description",
|
||||||
|
icon: "MessageCircle",
|
||||||
|
requiresSecrets: true,
|
||||||
|
secrets: [
|
||||||
|
{
|
||||||
|
key: "bot-token",
|
||||||
|
labelKey: "packages.telegram.botTokenLabel",
|
||||||
|
placeholderKey: "packages.telegram.botTokenPlaceholder",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customerInstructionsKey: "packages.telegram.instructions",
|
||||||
|
disclaimerKey: "packages.telegram.disclaimer",
|
||||||
|
category: "channel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "discord",
|
||||||
|
name: "Discord",
|
||||||
|
descriptionKey: "packages.discord.description",
|
||||||
|
icon: "Hash",
|
||||||
|
requiresSecrets: true,
|
||||||
|
secrets: [
|
||||||
|
{
|
||||||
|
key: "bot-token",
|
||||||
|
labelKey: "packages.discord.botTokenLabel",
|
||||||
|
placeholderKey: "packages.discord.botTokenPlaceholder",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customerInstructionsKey: "packages.discord.instructions",
|
||||||
|
disclaimerKey: "packages.discord.disclaimer",
|
||||||
|
category: "channel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email",
|
||||||
|
name: "Email",
|
||||||
|
descriptionKey: "packages.email.description",
|
||||||
|
icon: "Mail",
|
||||||
|
requiresSecrets: true,
|
||||||
|
secrets: [
|
||||||
|
{
|
||||||
|
key: "smtp-host",
|
||||||
|
labelKey: "packages.email.smtpHostLabel",
|
||||||
|
placeholderKey: "packages.email.smtpHostPlaceholder",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "smtp-user",
|
||||||
|
labelKey: "packages.email.smtpUserLabel",
|
||||||
|
placeholderKey: "packages.email.smtpUserPlaceholder",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "smtp-password",
|
||||||
|
labelKey: "packages.email.smtpPasswordLabel",
|
||||||
|
placeholderKey: "packages.email.smtpPasswordPlaceholder",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "imap-host",
|
||||||
|
labelKey: "packages.email.imapHostLabel",
|
||||||
|
placeholderKey: "packages.email.imapHostPlaceholder",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customerInstructionsKey: "packages.email.instructions",
|
||||||
|
disclaimerKey: "packages.email.disclaimer",
|
||||||
|
category: "channel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "web-search",
|
||||||
|
name: "Web Search",
|
||||||
|
descriptionKey: "packages.webSearch.description",
|
||||||
|
icon: "Search",
|
||||||
|
requiresSecrets: false,
|
||||||
|
category: "skill",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "document-processing",
|
||||||
|
name: "Document Processing",
|
||||||
|
descriptionKey: "packages.documentProcessing.description",
|
||||||
|
icon: "FileText",
|
||||||
|
requiresSecrets: false,
|
||||||
|
category: "skill",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getPackageDef(id: string): PackageDef | undefined {
|
||||||
|
return PACKAGE_CATALOG.find((p) => p.id === id);
|
||||||
|
}
|
||||||
19
src/lib/session.ts
Normal file
19
src/lib/session.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import type { SessionUser } from "@/types";
|
||||||
|
|
||||||
|
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||||
|
const session = await auth();
|
||||||
|
return (session as any)?.platformUser ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireSession(): Promise<SessionUser> {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) throw new Error("Unauthorized");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requirePlatformRole(): Promise<SessionUser> {
|
||||||
|
const user = await requireSession();
|
||||||
|
if (!user.isPlatform) throw new Error("Forbidden: platform role required");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
117
src/messages/de.json
Normal file
117
src/messages/de.json
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "PieCed",
|
||||||
|
"tagline": "KI-Plattform",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"admin": "Admin",
|
||||||
|
"loading": "Laden…",
|
||||||
|
"language": "Sprache",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"error": "Ein Fehler ist aufgetreten"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "PieCed Portal",
|
||||||
|
"subtitle": "Melden Sie sich an, um Ihren KI-Assistenten zu verwalten",
|
||||||
|
"button": "Weiter mit ZITADEL",
|
||||||
|
"footer": "On-Premises gehostet in der Schweiz"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"welcome": "Willkommen, {name}",
|
||||||
|
"instanceStatus": "Instanz-Status",
|
||||||
|
"usage": "Nutzung",
|
||||||
|
"packages": "Pakete",
|
||||||
|
"noInstance": "Noch keine Instanz",
|
||||||
|
"noInstanceDescription": "Richten Sie Ihre KI-Assistenten-Instanz ein, um mit PieCed IT zu starten.",
|
||||||
|
"comingSoon": "Detailansicht folgt in Session 6.2",
|
||||||
|
"getStarted": "Loslegen",
|
||||||
|
"agentName": "Agent",
|
||||||
|
"created": "Erstellt",
|
||||||
|
"usageTitle": "Nutzung & Kosten",
|
||||||
|
"inputTokens": "Input-Tokens",
|
||||||
|
"outputTokens": "Output-Tokens",
|
||||||
|
"totalCost": "Gesamtkosten",
|
||||||
|
"budgetRemaining": "Budget verbleibend",
|
||||||
|
"budget": "Budget",
|
||||||
|
"noBudgetSet": "Kein Limit",
|
||||||
|
"last30Days": "Letzte 30 Tage",
|
||||||
|
"usageError": "Nutzungsdaten konnten nicht geladen werden.",
|
||||||
|
"noUsageData": "Keine Nutzungsdaten verfügbar.",
|
||||||
|
"manageInstance": "Instanz & Pakete verwalten"
|
||||||
|
},
|
||||||
|
"tenantDetail": {
|
||||||
|
"agent": "Agent",
|
||||||
|
"packages": "Pakete",
|
||||||
|
"workspaceFiles": "Workspace-Dateien",
|
||||||
|
"notFound": "Mandant nicht gefunden."
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"save": "Speichern",
|
||||||
|
"placeholder": "Inhalt für {file} eingeben…",
|
||||||
|
"seedingNote": "Hinweis: Workspace-Dateien werden beim ersten Start eingerichtet. Eine Aktualisierung bei bestehenden Instanzen löst ein ConfigMap-Update und Pod-Neustart aus."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"enable": "Aktivieren",
|
||||||
|
"disable": "Deaktivieren",
|
||||||
|
"enableAndSave": "Aktivieren & Speichern",
|
||||||
|
"configure": "Konfigurieren",
|
||||||
|
"requiresApiKey": "API-Schlüssel erforderlich",
|
||||||
|
"missingFields": "Bitte füllen Sie alle Pflichtfelder aus.",
|
||||||
|
"status": {
|
||||||
|
"pending": "Ausstehend",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"error": "Fehler"
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"description": "Verbinden Sie Ihren KI-Assistenten mit einem Telegram-Bot.",
|
||||||
|
"botTokenLabel": "Telegram Bot-Token",
|
||||||
|
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||||
|
"instructions": "1. Öffnen Sie @BotFather auf Telegram\n2. Senden Sie /newbot und folgen Sie den Anweisungen\n3. Kopieren Sie den bereitgestellten Bot-Token",
|
||||||
|
"disclaimer": "Ich bestätige, dass ich Eigentümer dieses Telegram-Bots bin und PieCed IT autorisiere, ihn mit meiner KI-Assistenten-Instanz zu verbinden."
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"description": "Verbinden Sie Ihren KI-Assistenten über einen Bot mit einem Discord-Server.",
|
||||||
|
"botTokenLabel": "Discord Bot-Token",
|
||||||
|
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||||
|
"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 von der Bot-Einstellungsseite",
|
||||||
|
"disclaimer": "Ich bestätige, dass ich Eigentümer dieses Discord-Bots bin und PieCed IT autorisiere, ihn mit meiner KI-Assistenten-Instanz zu verbinden."
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"description": "Ermöglichen Sie Ihrem KI-Assistenten, E-Mails zu senden und zu empfangen.",
|
||||||
|
"smtpHostLabel": "SMTP-Host",
|
||||||
|
"smtpHostPlaceholder": "smtp.example.com",
|
||||||
|
"smtpUserLabel": "SMTP-Benutzername",
|
||||||
|
"smtpUserPlaceholder": "user@example.com",
|
||||||
|
"smtpPasswordLabel": "SMTP-Passwort",
|
||||||
|
"smtpPasswordPlaceholder": "••••••••",
|
||||||
|
"imapHostLabel": "IMAP-Host",
|
||||||
|
"imapHostPlaceholder": "imap.example.com",
|
||||||
|
"instructions": "Geben Sie die SMTP- und IMAP-Zugangsdaten Ihres E-Mail-Servers an. Der Assistent nutzt diese zum Senden und Empfangen von Nachrichten.",
|
||||||
|
"disclaimer": "Ich bestätige, dass ich berechtigt bin, diese E-Mail-Zugangsdaten zu verwenden und PieCed IT in meinem Auftrag auf dieses Postfach zugreifen darf."
|
||||||
|
},
|
||||||
|
"webSearch": {
|
||||||
|
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web nach aktuellen Informationen zu suchen."
|
||||||
|
},
|
||||||
|
"documentProcessing": {
|
||||||
|
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Plattform-Admin",
|
||||||
|
"subtitle": "Alle Plattform-Mandanten",
|
||||||
|
"allTenants": "Mandanten",
|
||||||
|
"tenants": "Alle Mandanten",
|
||||||
|
"total": "gesamt",
|
||||||
|
"noTenants": "Keine Mandanten provisioniert.",
|
||||||
|
"noAccess": "Unzureichende Berechtigungen für diese Ansicht.",
|
||||||
|
"name": "Name",
|
||||||
|
"displayName": "Anzeigename",
|
||||||
|
"phase": "Status",
|
||||||
|
"packages": "Pakete",
|
||||||
|
"created": "Erstellt",
|
||||||
|
"manage": "Verwalten"
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/messages/en.json
Normal file
117
src/messages/en.json
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "PieCed",
|
||||||
|
"tagline": "AI Platform",
|
||||||
|
"login": "Login",
|
||||||
|
"logout": "Logout",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"admin": "Admin",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"language": "Language",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"error": "An error occurred"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "PieCed Portal",
|
||||||
|
"subtitle": "Sign in to manage your AI assistant",
|
||||||
|
"button": "Continue with ZITADEL",
|
||||||
|
"footer": "Hosted on-premises in Switzerland"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"welcome": "Welcome, {name}",
|
||||||
|
"instanceStatus": "Instance Status",
|
||||||
|
"usage": "Usage",
|
||||||
|
"packages": "Packages",
|
||||||
|
"noInstance": "No Instance Yet",
|
||||||
|
"noInstanceDescription": "Set up your AI assistant instance to get started with PieCed IT.",
|
||||||
|
"comingSoon": "Detailed view coming in Session 6.2",
|
||||||
|
"getStarted": "Get Started",
|
||||||
|
"agentName": "Agent",
|
||||||
|
"created": "Created",
|
||||||
|
"usageTitle": "Usage & Spend",
|
||||||
|
"inputTokens": "Input Tokens",
|
||||||
|
"outputTokens": "Output Tokens",
|
||||||
|
"totalCost": "Total Cost",
|
||||||
|
"budgetRemaining": "Budget Remaining",
|
||||||
|
"budget": "Budget",
|
||||||
|
"noBudgetSet": "No limit",
|
||||||
|
"last30Days": "Last 30 Days",
|
||||||
|
"usageError": "Failed to load usage data.",
|
||||||
|
"noUsageData": "No usage data available.",
|
||||||
|
"manageInstance": "Manage instance & packages"
|
||||||
|
},
|
||||||
|
"tenantDetail": {
|
||||||
|
"agent": "Agent",
|
||||||
|
"packages": "Packages",
|
||||||
|
"workspaceFiles": "Workspace Files",
|
||||||
|
"notFound": "Tenant not found."
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"save": "Save",
|
||||||
|
"placeholder": "Enter content for {file}…",
|
||||||
|
"seedingNote": "Note: Workspace files are seeded on first boot. Updating files on an existing instance will trigger a ConfigMap update and pod restart."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable",
|
||||||
|
"enableAndSave": "Enable & Save",
|
||||||
|
"configure": "Configure",
|
||||||
|
"requiresApiKey": "Requires API key",
|
||||||
|
"missingFields": "Please fill in all required fields.",
|
||||||
|
"status": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"active": "Active",
|
||||||
|
"error": "Error"
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"description": "Connect your AI assistant to a Telegram bot for messaging.",
|
||||||
|
"botTokenLabel": "Telegram Bot Token",
|
||||||
|
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||||
|
"instructions": "1. Open @BotFather on Telegram\n2. Send /newbot and follow the prompts\n3. Copy the bot token provided",
|
||||||
|
"disclaimer": "I confirm that I own this Telegram bot and authorize PieCed IT to connect it to my AI assistant instance."
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"description": "Connect your AI assistant to a Discord server via a bot.",
|
||||||
|
"botTokenLabel": "Discord Bot Token",
|
||||||
|
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||||
|
"instructions": "1. Go to discord.com/developers/applications\n2. Create a new application and add a bot\n3. Copy the bot token from the Bot settings page",
|
||||||
|
"disclaimer": "I confirm that I own this Discord bot and authorize PieCed IT to connect it to my AI assistant instance."
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"description": "Enable your AI assistant to send and receive email.",
|
||||||
|
"smtpHostLabel": "SMTP Host",
|
||||||
|
"smtpHostPlaceholder": "smtp.example.com",
|
||||||
|
"smtpUserLabel": "SMTP Username",
|
||||||
|
"smtpUserPlaceholder": "user@example.com",
|
||||||
|
"smtpPasswordLabel": "SMTP Password",
|
||||||
|
"smtpPasswordPlaceholder": "••••••••",
|
||||||
|
"imapHostLabel": "IMAP Host",
|
||||||
|
"imapHostPlaceholder": "imap.example.com",
|
||||||
|
"instructions": "Provide your email server's SMTP and IMAP credentials. The assistant will use these to send replies and monitor incoming messages.",
|
||||||
|
"disclaimer": "I confirm that I am authorized to use these email credentials and that PieCed IT may access this mailbox on my behalf."
|
||||||
|
},
|
||||||
|
"webSearch": {
|
||||||
|
"description": "Give your AI assistant the ability to search the web for current information."
|
||||||
|
},
|
||||||
|
"documentProcessing": {
|
||||||
|
"description": "Enable document parsing, summarization, and extraction capabilities."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Platform Admin",
|
||||||
|
"subtitle": "All platform tenants",
|
||||||
|
"allTenants": "Tenants",
|
||||||
|
"tenants": "All Tenants",
|
||||||
|
"total": "total",
|
||||||
|
"noTenants": "No tenants provisioned.",
|
||||||
|
"noAccess": "Insufficient permissions for this view.",
|
||||||
|
"name": "Name",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"phase": "Status",
|
||||||
|
"packages": "Packages",
|
||||||
|
"created": "Created",
|
||||||
|
"manage": "Manage"
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/messages/fr.json
Normal file
117
src/messages/fr.json
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "PieCed",
|
||||||
|
"tagline": "Plateforme IA",
|
||||||
|
"login": "Connexion",
|
||||||
|
"logout": "Déconnexion",
|
||||||
|
"dashboard": "Tableau de bord",
|
||||||
|
"admin": "Admin",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"language": "Langue",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"error": "Une erreur est survenue"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "PieCed Portal",
|
||||||
|
"subtitle": "Connectez-vous pour gérer votre assistant IA",
|
||||||
|
"button": "Continuer avec ZITADEL",
|
||||||
|
"footer": "Hébergé sur site en Suisse"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Tableau de bord",
|
||||||
|
"welcome": "Bienvenue, {name}",
|
||||||
|
"instanceStatus": "État de l'instance",
|
||||||
|
"usage": "Utilisation",
|
||||||
|
"packages": "Paquets",
|
||||||
|
"noInstance": "Aucune instance provisionnée",
|
||||||
|
"noInstanceDescription": "Configurez votre instance d'assistant IA pour démarrer avec PieCed IT.",
|
||||||
|
"comingSoon": "Vue détaillée à venir dans la Session 6.2",
|
||||||
|
"getStarted": "Commencer",
|
||||||
|
"agentName": "Agent",
|
||||||
|
"created": "Créé",
|
||||||
|
"usageTitle": "Utilisation & Dépenses",
|
||||||
|
"inputTokens": "Tokens d'entrée",
|
||||||
|
"outputTokens": "Tokens de sortie",
|
||||||
|
"totalCost": "Coût total",
|
||||||
|
"budgetRemaining": "Budget restant",
|
||||||
|
"budget": "Budget",
|
||||||
|
"noBudgetSet": "Pas de limite",
|
||||||
|
"last30Days": "30 derniers jours",
|
||||||
|
"usageError": "Impossible de charger les données d'utilisation.",
|
||||||
|
"noUsageData": "Aucune donnée d'utilisation disponible.",
|
||||||
|
"manageInstance": "Gérer l'instance & les paquets"
|
||||||
|
},
|
||||||
|
"tenantDetail": {
|
||||||
|
"agent": "Agent",
|
||||||
|
"packages": "Paquets",
|
||||||
|
"workspaceFiles": "Fichiers Workspace",
|
||||||
|
"notFound": "Tenant introuvable."
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"placeholder": "Saisissez le contenu de {file}…",
|
||||||
|
"seedingNote": "Remarque : les fichiers workspace sont initialisés au premier démarrage. Leur mise à jour sur une instance existante déclenche une mise à jour de la ConfigMap et un redémarrage du pod."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"enable": "Activer",
|
||||||
|
"disable": "Désactiver",
|
||||||
|
"enableAndSave": "Activer & Enregistrer",
|
||||||
|
"configure": "Configurer",
|
||||||
|
"requiresApiKey": "Clé API requise",
|
||||||
|
"missingFields": "Veuillez remplir tous les champs obligatoires.",
|
||||||
|
"status": {
|
||||||
|
"pending": "En attente",
|
||||||
|
"active": "Actif",
|
||||||
|
"error": "Erreur"
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"description": "Connectez votre assistant IA à un bot Telegram pour la messagerie.",
|
||||||
|
"botTokenLabel": "Token du bot Telegram",
|
||||||
|
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||||
|
"instructions": "1. Ouvrez @BotFather sur Telegram\n2. Envoyez /newbot et suivez les instructions\n3. Copiez le token du bot fourni",
|
||||||
|
"disclaimer": "Je confirme être propriétaire de ce bot Telegram et j'autorise PieCed IT à le connecter à mon instance d'assistant IA."
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"description": "Connectez votre assistant IA à un serveur Discord via un bot.",
|
||||||
|
"botTokenLabel": "Token du bot Discord",
|
||||||
|
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||||
|
"instructions": "1. Allez sur discord.com/developers/applications\n2. Créez une nouvelle application et ajoutez un bot\n3. Copiez le token du bot depuis la page de configuration",
|
||||||
|
"disclaimer": "Je confirme être propriétaire de ce bot Discord et j'autorise PieCed IT à le connecter à mon instance d'assistant IA."
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"description": "Permettez à votre assistant IA d'envoyer et de recevoir des e-mails.",
|
||||||
|
"smtpHostLabel": "Hôte SMTP",
|
||||||
|
"smtpHostPlaceholder": "smtp.example.com",
|
||||||
|
"smtpUserLabel": "Utilisateur SMTP",
|
||||||
|
"smtpUserPlaceholder": "user@example.com",
|
||||||
|
"smtpPasswordLabel": "Mot de passe SMTP",
|
||||||
|
"smtpPasswordPlaceholder": "••••••••",
|
||||||
|
"imapHostLabel": "Hôte IMAP",
|
||||||
|
"imapHostPlaceholder": "imap.example.com",
|
||||||
|
"instructions": "Fournissez les identifiants SMTP et IMAP de votre serveur de messagerie. L'assistant les utilisera pour envoyer des réponses et surveiller les messages entrants.",
|
||||||
|
"disclaimer": "Je confirme être autorisé(e) à utiliser ces identifiants de messagerie et que PieCed IT peut accéder à cette boîte aux lettres en mon nom."
|
||||||
|
},
|
||||||
|
"webSearch": {
|
||||||
|
"description": "Donnez à votre assistant IA la possibilité de rechercher des informations actuelles sur le web."
|
||||||
|
},
|
||||||
|
"documentProcessing": {
|
||||||
|
"description": "Activez les capacités d'analyse, de résumé et d'extraction de documents."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Admin plateforme",
|
||||||
|
"subtitle": "Tous les tenants de la plateforme",
|
||||||
|
"allTenants": "Tenants",
|
||||||
|
"tenants": "Tous les tenants",
|
||||||
|
"total": "total",
|
||||||
|
"noTenants": "Aucun tenant provisionné.",
|
||||||
|
"noAccess": "Permissions insuffisantes pour cette vue.",
|
||||||
|
"name": "Nom",
|
||||||
|
"displayName": "Nom d'affichage",
|
||||||
|
"phase": "Statut",
|
||||||
|
"packages": "Paquets",
|
||||||
|
"created": "Créé",
|
||||||
|
"manage": "Gérer"
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/messages/it.json
Normal file
117
src/messages/it.json
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "PieCed",
|
||||||
|
"tagline": "Piattaforma IA",
|
||||||
|
"login": "Accedi",
|
||||||
|
"logout": "Esci",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"admin": "Admin",
|
||||||
|
"loading": "Caricamento…",
|
||||||
|
"language": "Lingua",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"save": "Salva",
|
||||||
|
"error": "Si è verificato un errore"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "PieCed Portal",
|
||||||
|
"subtitle": "Accedi per gestire il tuo assistente IA",
|
||||||
|
"button": "Continua con ZITADEL",
|
||||||
|
"footer": "Ospitato on-premises in Svizzera"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"welcome": "Benvenuto, {name}",
|
||||||
|
"instanceStatus": "Stato dell'istanza",
|
||||||
|
"usage": "Utilizzo",
|
||||||
|
"packages": "Pacchetti",
|
||||||
|
"noInstance": "Nessuna istanza ancora",
|
||||||
|
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
|
||||||
|
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
|
||||||
|
"getStarted": "Inizia",
|
||||||
|
"agentName": "Agente",
|
||||||
|
"created": "Creato",
|
||||||
|
"usageTitle": "Utilizzo & Spese",
|
||||||
|
"inputTokens": "Token di input",
|
||||||
|
"outputTokens": "Token di output",
|
||||||
|
"totalCost": "Costo totale",
|
||||||
|
"budgetRemaining": "Budget rimanente",
|
||||||
|
"budget": "Budget",
|
||||||
|
"noBudgetSet": "Nessun limite",
|
||||||
|
"last30Days": "Ultimi 30 giorni",
|
||||||
|
"usageError": "Impossibile caricare i dati di utilizzo.",
|
||||||
|
"noUsageData": "Nessun dato di utilizzo disponibile.",
|
||||||
|
"manageInstance": "Gestisci istanza e pacchetti"
|
||||||
|
},
|
||||||
|
"tenantDetail": {
|
||||||
|
"agent": "Agente",
|
||||||
|
"packages": "Pacchetti",
|
||||||
|
"workspaceFiles": "File Workspace",
|
||||||
|
"notFound": "Tenant non trovato."
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"save": "Salva",
|
||||||
|
"placeholder": "Inserisci il contenuto per {file}…",
|
||||||
|
"seedingNote": "Nota: i file workspace vengono inizializzati al primo avvio. L'aggiornamento su un'istanza esistente attiva un aggiornamento della ConfigMap e un riavvio del pod."
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"enable": "Attiva",
|
||||||
|
"disable": "Disattiva",
|
||||||
|
"enableAndSave": "Attiva & Salva",
|
||||||
|
"configure": "Configura",
|
||||||
|
"requiresApiKey": "Chiave API richiesta",
|
||||||
|
"missingFields": "Compilare tutti i campi obbligatori.",
|
||||||
|
"status": {
|
||||||
|
"pending": "In attesa",
|
||||||
|
"active": "Attivo",
|
||||||
|
"error": "Errore"
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"description": "Collega il tuo assistente IA a un bot Telegram per la messaggistica.",
|
||||||
|
"botTokenLabel": "Token del bot Telegram",
|
||||||
|
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||||
|
"instructions": "1. Apri @BotFather su Telegram\n2. Invia /newbot e segui le istruzioni\n3. Copia il token del bot fornito",
|
||||||
|
"disclaimer": "Confermo di essere il proprietario di questo bot Telegram e autorizzo PieCed IT a collegarlo alla mia istanza di assistente IA."
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"description": "Collega il tuo assistente IA a un server Discord tramite un bot.",
|
||||||
|
"botTokenLabel": "Token del bot Discord",
|
||||||
|
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||||
|
"instructions": "1. Vai su discord.com/developers/applications\n2. Crea una nuova applicazione e aggiungi un bot\n3. Copia il token del bot dalla pagina delle impostazioni",
|
||||||
|
"disclaimer": "Confermo di essere il proprietario di questo bot Discord e autorizzo PieCed IT a collegarlo alla mia istanza di assistente IA."
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"description": "Consenti al tuo assistente IA di inviare e ricevere e-mail.",
|
||||||
|
"smtpHostLabel": "Host SMTP",
|
||||||
|
"smtpHostPlaceholder": "smtp.example.com",
|
||||||
|
"smtpUserLabel": "Utente SMTP",
|
||||||
|
"smtpUserPlaceholder": "user@example.com",
|
||||||
|
"smtpPasswordLabel": "Password SMTP",
|
||||||
|
"smtpPasswordPlaceholder": "••••••••",
|
||||||
|
"imapHostLabel": "Host IMAP",
|
||||||
|
"imapHostPlaceholder": "imap.example.com",
|
||||||
|
"instructions": "Fornisci le credenziali SMTP e IMAP del tuo server di posta. L'assistente le utilizzerà per inviare risposte e monitorare i messaggi in arrivo.",
|
||||||
|
"disclaimer": "Confermo di essere autorizzato/a a utilizzare queste credenziali e-mail e che PieCed IT può accedere a questa casella di posta per mio conto."
|
||||||
|
},
|
||||||
|
"webSearch": {
|
||||||
|
"description": "Dai al tuo assistente IA la possibilità di cercare informazioni attuali sul web."
|
||||||
|
},
|
||||||
|
"documentProcessing": {
|
||||||
|
"description": "Attiva le funzionalità di analisi, riassunto ed estrazione dei documenti."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Admin piattaforma",
|
||||||
|
"subtitle": "Tutti i tenant della piattaforma",
|
||||||
|
"allTenants": "Tenant",
|
||||||
|
"tenants": "Tutti i tenant",
|
||||||
|
"total": "totale",
|
||||||
|
"noTenants": "Nessun tenant provisionato.",
|
||||||
|
"noAccess": "Permessi insufficienti per questa vista.",
|
||||||
|
"name": "Nome",
|
||||||
|
"displayName": "Nome visualizzato",
|
||||||
|
"phase": "Stato",
|
||||||
|
"packages": "Pacchetti",
|
||||||
|
"created": "Creato",
|
||||||
|
"manage": "Gestisci"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/middleware.ts
Normal file
43
src/middleware.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import createIntlMiddleware from "next-intl/middleware";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
|
||||||
|
const intlMiddleware = createIntlMiddleware(routing);
|
||||||
|
|
||||||
|
const publicPaths = ["/login", "/api/auth"];
|
||||||
|
|
||||||
|
function isPublicPath(pathname: string): boolean {
|
||||||
|
// Strip locale prefix for comparison
|
||||||
|
const stripped = pathname.replace(/^\/(de|fr|it|en)/, "") || "/";
|
||||||
|
return (
|
||||||
|
publicPaths.some((p) => stripped === p || stripped.startsWith(`${p}/`)) ||
|
||||||
|
pathname.startsWith("/api/auth")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// NextAuth API routes pass through directly
|
||||||
|
if (pathname.startsWith("/api/auth")) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth guard for protected paths
|
||||||
|
if (!isPublicPath(pathname)) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intlMiddleware(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next|favicon.ico|api/auth).*)"],
|
||||||
|
};
|
||||||
66
src/types/index.ts
Normal file
66
src/types/index.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
// ZITADEL JWT custom claims
|
||||||
|
export interface ZitadelClaims {
|
||||||
|
"urn:zitadel:iam:user:resourceowner:id": string;
|
||||||
|
"urn:zitadel:iam:user:resourceowner:name": string;
|
||||||
|
"urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlatformRole =
|
||||||
|
| "platform_admin"
|
||||||
|
| "platform_operator"
|
||||||
|
| "owner"
|
||||||
|
| "user"
|
||||||
|
| "viewer";
|
||||||
|
|
||||||
|
export interface SessionUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
orgId: string;
|
||||||
|
orgName: string;
|
||||||
|
roles: PlatformRole[];
|
||||||
|
isPlatform: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PiecedTenant CR (pieced.ch/v1alpha1)
|
||||||
|
export interface PiecedTenantSpec {
|
||||||
|
displayName: string;
|
||||||
|
agentName: string;
|
||||||
|
plan?: string;
|
||||||
|
packages?: string[];
|
||||||
|
workspaceFiles?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiecedTenantStatus {
|
||||||
|
phase: "Pending" | "Provisioning" | "Running" | "Error" | "Deleting";
|
||||||
|
message?: string;
|
||||||
|
observedGeneration?: number;
|
||||||
|
conditions?: Array<{
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
lastTransitionTime?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PiecedTenant {
|
||||||
|
apiVersion: "pieced.ch/v1alpha1";
|
||||||
|
kind: "PiecedTenant";
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
namespace?: string;
|
||||||
|
creationTimestamp?: string;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
|
};
|
||||||
|
spec: PiecedTenantSpec;
|
||||||
|
status?: PiecedTenantStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageSummary {
|
||||||
|
totalInputTokens: number;
|
||||||
|
totalOutputTokens: number;
|
||||||
|
totalSpendChf: number;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user