Add initial Portal version

This commit is contained in:
2026-04-09 22:16:22 +02:00
commit d526c1ff4a
51 changed files with 10752 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
.git
.env*
*.md

12
.env.example Normal file
View 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
View File

@@ -0,0 +1,6 @@
node_modules/
.next/
.env
.env.local
*.tsbuildinfo
next-env.d.ts

24
Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

0
public/.gitkeep Normal file
View File

View File

@@ -0,0 +1,5 @@
import AdminTenantsClient from "@/components/admin/AdminTenantsClient";
export default function AdminPage() {
return <AdminTenantsClient />;
}

View File

@@ -0,0 +1,5 @@
import DashboardClient from "@/components/dashboard/DashboardClient";
export default function DashboardPage() {
return <DashboardClient />;
}

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

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

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,5 @@
import TenantDetailClient from "@/components/tenants/TenantDetailClient";
export default function TenantDetailPage() {
return <TenantDetailClient />;
}

View File

@@ -0,0 +1,2 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

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

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

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

View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

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

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

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

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

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

View 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>
)}
</>
);
}

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

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

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

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}