Compare commits

..

5 Commits

Author SHA1 Message Date
7d58c78cb9 Fix modal popup
All checks were successful
Build and Push / build (push) Successful in 1m22s
2026-05-01 16:56:33 +02:00
f308c84325 Group F - Fix spending per tenant
All checks were successful
Build and Push / build (push) Successful in 1m22s
2026-05-01 13:34:56 +02:00
2cf5b56441 OCI Warning status
All checks were successful
Build and Push / build (push) Successful in 2m12s
2026-05-01 10:25:50 +02:00
f84516a65b Group D fixes
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-04-29 22:16:48 +02:00
219b4c8365 Group D fixes
Some checks failed
Build and Push / build (push) Failing after 37s
2026-04-29 22:13:08 +02:00
22 changed files with 1365 additions and 193 deletions

View File

@@ -0,0 +1,87 @@
import { getSessionUser, canMutate } from "@/lib/session";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getTenantRequestById } from "@/lib/db";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { BackLink } from "@/components/ui/back-link";
/**
* /dashboard/edit/[id] — re-opens the onboarding wizard with the
* fields of a still-pending request pre-filled (Bug 6). On submit,
* the wizard PATCHes /api/onboarding/[id] instead of POSTing to
* /api/onboarding.
*
* Hard guards
* -----------
* - Logged-in customer owner (or platform user) only — same as the
* /dashboard/new page.
* - Request must exist, belong to the caller's org, and be in 'pending'
* status. Editing approved/provisioning rows would race against the
* operator; we redirect such cases back to the dashboard rather than
* render an invalid wizard.
*
* Pre-fill
* --------
* The wizard takes a single `editingRequest` prop — when present, it
* (a) pre-populates state from those values and (b) targets the PATCH
* endpoint on submit. When absent, it behaves exactly as today (POST
* to /api/onboarding).
*
* Note on encrypted secrets
* -------------------------
* Per-package secrets are NEVER decrypted server-side and exposed to
* the client (would be a clear security regression). When editing,
* the wizard opens with empty secret fields and the user re-enters
* any they want to change. If they don't touch the package-secrets
* UI, the existing encrypted blob in the DB is preserved by the
* PATCH endpoint (it only re-encrypts when the wizard sends a
* non-empty secrets payload).
*/
export default async function EditRequestPage({
params,
}: {
params: Promise<{ id: string; locale: string }>;
}) {
const { id } = await params;
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.isPlatform) redirect("/dashboard");
if (!canMutate(user)) redirect("/dashboard");
const tr = await getTenantRequestById(id);
if (!tr) redirect("/dashboard");
if (tr.zitadelOrgId !== user.orgId) redirect("/dashboard");
if (tr.status !== "pending") redirect("/dashboard");
const t = await getTranslations("dashboard");
const tOnboarding = await getTranslations("onboarding");
return (
<div className="container max-w-3xl mx-auto px-4 py-8">
<div className="mb-8 animate-in">
<BackLink href="/dashboard" label={t("title")} />
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{tOnboarding("editRequestTitle")}
</h1>
<p className="text-sm text-text-secondary">
{tOnboarding("editRequestDescription")}
</p>
</div>
<OnboardingFlow
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
editingRequest={{
id: tr.id,
instanceName: tr.instanceName ?? "",
agentName: tr.agentName,
soulMd: tr.soulMd ?? "",
agentsMd: tr.agentsMd ?? "",
packages: tr.packages,
billingAddress: tr.billingAddress,
billingNotes: tr.billingNotes ?? "",
}}
/>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
import { personalAccountAtCapacity } from "@/lib/personal-org"; import { personalAccountAtCapacity } from "@/lib/personal-org";
import { Card, CardHeader } from "@/components/ui/card"; import { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"; import { ProvisioningStatus } from "@/components/onboarding/provisioning-status";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
@@ -315,7 +316,11 @@ export default async function DashboardPage() {
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
{inflightRequests.map((r) => ( {inflightRequests.map((r) => (
<ProvisioningStatus key={r.id} requestId={r.id} /> <ProvisioningStatus
key={r.id}
requestId={r.id}
canAct={canMutate(user)}
/>
))} ))}
</div> </div>
</div> </div>
@@ -344,7 +349,10 @@ export default async function DashboardPage() {
{tenant.metadata.name} {tenant.metadata.name}
</div> </div>
</div> </div>
<StatusBadge phase={tenant.status?.phase ?? "Pending"} /> <div className="flex items-center gap-2 shrink-0">
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
<WarningBadge warnings={tenant.status?.warnings ?? []} />
</div>
</div> </div>
{tenant.spec.agentName && ( {tenant.spec.agentName && (

View File

@@ -4,6 +4,7 @@ import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s"; import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility"; import { canUserSeeTenant } from "@/lib/visibility";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display"; import { UsageDisplay } from "@/components/dashboard/usage-display";
import { PackageList } from "@/components/packages/package-list"; import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor"; import { WorkspaceEditor } from "@/components/packages/workspace-editor";
@@ -66,18 +67,12 @@ export default async function TenantDetailPage({
); );
const channelUsers = tenant.spec.channelUsers || {}; const channelUsers = tenant.spec.channelUsers || {};
// Admins inspecting another tenant's usage: pass teamId AND keyAlias so // Bug 19 fix: every viewer (customer or admin) passes the tenant
// the backend filters spend logs by this specific tenant's virtual key. // name to UsageDisplay. The /api/usage route resolves team+alias
// Without keyAlias the response would include sibling tenants in the // from the tenant CR's status and applies the visibility check, so
// same org, since teams are now shared (Slice 2). // no per-role branching is needed here. Previous version only
// Customers viewing their own: pass nothing — backend resolves both // passed identifiers for platform admins; customers got "the first
// from the session-bound tenant. // visible tenant" by API fallback, mingling siblings.
const usageTeamId = user.isPlatform
? tenant.status?.litellmTeamId || undefined
: undefined;
const usageKeyAlias = user.isPlatform
? tenant.status?.litellmKeyAlias || undefined
: undefined;
return ( return (
<div> <div>
@@ -88,6 +83,7 @@ export default async function TenantDetailPage({
{tenant.spec.displayName || name} {tenant.spec.displayName || name}
</h1> </h1>
<StatusBadge phase={tenant.status?.phase ?? "Pending"} /> <StatusBadge phase={tenant.status?.phase ?? "Pending"} />
<WarningBadge warnings={tenant.status?.warnings ?? []} />
</div> </div>
{tenant.spec.agentName && ( {tenant.spec.agentName && (
<p className="text-sm text-text-secondary mt-3"> <p className="text-sm text-text-secondary mt-3">
@@ -148,7 +144,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")} {t("usage")}
</h2> </h2>
<UsageDisplay teamId={usageTeamId} keyAlias={usageKeyAlias} /> <UsageDisplay tenant={name} />
</section> </section>
{/* Packages */} {/* Packages */}

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { dismissTenantRequest, getTenantRequestById } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/onboarding/[id]/dismiss
*
* Customer-side acknowledgement of a rejected or cancelled request
* (Bug 13). Sets `dismissed_at = now()` so the row stops appearing
* in the dashboard's `listActiveTenantRequestsByOrgId` query. The
* row itself is preserved for audit.
*
* Authorization mirrors the GET / DELETE / PATCH endpoints on this
* resource: customer owners (or platform staff) of the row's org.
*
* Idempotent: dismissing an already-dismissed request returns 200
* with no change. We refuse to dismiss non-terminal rows (pending,
* approved, provisioning, active) — those are still actionable, and
* "hiding" them would stash live state from the customer.
*/
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const tr = await getTenantRequestById(id);
if (!tr) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (tr.status !== "rejected" && tr.status !== "cancelled") {
return NextResponse.json(
{
error:
"Only rejected or cancelled requests can be dismissed. Active requests stay visible.",
code: "not_dismissable",
currentStatus: tr.status,
},
{ status: 409 }
);
}
try {
await dismissTenantRequest(id);
return NextResponse.json({ message: "Dismissed.", id });
} catch (e: any) {
console.error("Failed to dismiss request:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to dismiss request") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,207 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import {
getTenantRequestById,
updateTenantRequestStatus,
updateTenantRequestEditableFields,
} from "@/lib/db";
import { encryptSecrets } from "@/lib/crypto";
import { onboardingSchema } from "@/lib/validation";
import { safeError } from "@/lib/errors";
/**
* Customer-side controls for a single tenant_request row.
*
* - DELETE /api/onboarding/[id] → cancel a still-pending request
* - PATCH /api/onboarding/[id] → edit fields of a still-pending
* request (Bug 6)
*
* Both endpoints share the same authorization check: the caller must
* be a customer owner (or platform staff) of the request's org. We
* also enforce status === 'pending' on the row — once an admin has
* acted on it, the customer can no longer mutate it from the portal.
*
* Reading these is via the existing GET /api/onboarding?id=... handler.
*/
async function loadAuthorized(
id: string
): Promise<
| { error: NextResponse }
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
> {
const user = await getSessionUser();
if (!user) {
return {
error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
};
}
if (!canMutate(user)) {
return {
error: NextResponse.json({ error: "Forbidden" }, { status: 403 }),
};
}
const tr = await getTenantRequestById(id);
if (!tr) {
return {
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
};
}
// Customers may only read their own org's requests; platform users
// may read any. Same scope as `GET /api/onboarding?id=...`.
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
return {
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
};
}
return { req: tr };
}
/**
* DELETE /api/onboarding/[id]
*
* Customer cancels a still-pending request. Status flips to 'cancelled';
* the row is preserved for audit. The customer can dismiss the
* cancelled card afterwards (Bug 13 reuse — same dismissal mechanism).
*
* Once admin has approved/provisioned/rejected, this endpoint refuses
* (409). Cancelling a tenant that's already running goes through the
* subscription-suspend flow on the tenant detail page, not here.
*/
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const loaded = await loadAuthorized(id);
if ("error" in loaded) return loaded.error;
const tr = loaded.req!;
if (tr.status !== "pending") {
return NextResponse.json(
{
error:
"Only pending requests can be cancelled. Approved or provisioning instances must be managed from the tenant page.",
code: "not_pending",
currentStatus: tr.status,
},
{ status: 409 }
);
}
try {
await updateTenantRequestStatus(id, "cancelled");
return NextResponse.json({ message: "Request cancelled.", id });
} catch (e: any) {
console.error("Failed to cancel request:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to cancel request") },
{ status: 500 }
);
}
}
/**
* PATCH /api/onboarding/[id]
*
* Customer edits a still-pending request. Validation is the same as on
* POST /api/onboarding (shared schema). Only customer-input fields are
* editable; status/tenant_name/admin_notes/etc. are server-managed.
*
* Note on company-level fields
* ----------------------------
* For a follow-up instance (org has prior approved rows), the POST
* handler intentionally ignores the wizard's billingAddress and uses
* the on-file value instead. We mirror that here: company-level fields
* (companyName, contactName, contactEmail, billingAddress) on a
* follow-up edit are NOT updated through this endpoint. The customer
* should use a future settings page (Bug 11) for those. For now,
* editing only mutates per-instance fields — agent name, instance
* name, packages, soulMd, agentsMd, billingNotes, packageSecrets.
*
* For the FIRST instance (no prior approved rows), billingAddress IS
* editable here, since the customer is still defining their company's
* billing data.
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const loaded = await loadAuthorized(id);
if ("error" in loaded) return loaded.error;
const tr = loaded.req!;
if (tr.status !== "pending") {
return NextResponse.json(
{
error: "Only pending requests can be edited.",
code: "not_pending",
currentStatus: tr.status,
},
{ status: 409 }
);
}
const body = await req.json().catch(() => null);
const parsed = onboardingSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const input = parsed.data;
// Re-encrypt package secrets if present in the patch body. When the
// user re-opens the wizard to edit, the secrets array is populated
// afresh from the wizard (we never decrypt and return existing
// secrets — that'd be a security regression). If the user didn't
// touch any secret-bearing package, the wizard sends no
// packageSecrets and we leave the existing encrypted blob alone.
let encryptedSecrets: Buffer | null | undefined;
if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) {
try {
encryptedSecrets = await encryptSecrets(input.packageSecrets);
} catch (e: any) {
console.error("Failed to encrypt package secrets:", e);
return NextResponse.json(
{ error: "Failed to secure credentials. Please try again." },
{ status: 500 }
);
}
}
// Only first-instance edits get billingAddress; follow-ups inherit
// company billing from the on-file approved row.
const isFirstInstance = !tr.tenantName; // approximation; covers the
// "no prior approved row for this org" case the POST handler treats
// identically. A more rigorous check would call
// getMostRecentApprovedRequestForOrg, but in practice an org with
// an approved row for some other tenant has a tenantName on those
// rows, not on the pending one being edited — so the simple check
// here is fine for the only state the endpoint accepts (pending).
try {
const updated = await updateTenantRequestEditableFields(id, {
instanceName: input.instanceName,
agentName: input.agentName,
soulMd: input.soulMd,
agentsMd: input.agentsMd,
packages: input.packages ?? [],
billingAddress: isFirstInstance ? input.billingAddress : undefined,
billingNotes: input.billingNotes,
encryptedSecrets,
});
if (!updated) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ message: "Request updated.", id });
} catch (e: any) {
console.error("Failed to edit request:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to edit request") },
{ status: 500 }
);
}
}

View File

@@ -24,15 +24,33 @@ import { z } from "zod";
* Helper: shape a TenantRequest row for client consumption. * Helper: shape a TenantRequest row for client consumption.
* Hides server-only fields (encryptedSecrets, internal db ids). * Hides server-only fields (encryptedSecrets, internal db ids).
*/ */
/**
* Helper: shape a TenantRequest row for client consumption.
* Hides server-only fields (encryptedSecrets, internal db ids).
*
* Slice 7 / Bug 6: surfaces enough fields for the customer-side edit
* flow to pre-fill the wizard. soulMd, agentsMd, billingAddress,
* billingNotes were previously kept off the public shape because the
* pre-Slice-3 dashboard didn't render them. Edit needs them.
*
* Bug 13: surfaces dismissedAt so the dashboard can distinguish
* "freshly rejected, show prominently" from "rejected and acknowledged,
* keep hidden" without an extra API call.
*/
function publicRequestShape(r: TenantRequest) { function publicRequestShape(r: TenantRequest) {
return { return {
id: r.id, id: r.id,
instanceName: r.instanceName, instanceName: r.instanceName,
agentName: r.agentName, agentName: r.agentName,
soulMd: r.soulMd,
agentsMd: r.agentsMd,
packages: r.packages, packages: r.packages,
billingAddress: r.billingAddress,
billingNotes: r.billingNotes,
status: r.status, status: r.status,
adminNotes: r.adminNotes, adminNotes: r.adminNotes,
tenantName: r.tenantName, tenantName: r.tenantName,
dismissedAt: r.dismissedAt ?? null,
createdAt: r.createdAt, createdAt: r.createdAt,
updatedAt: r.updatedAt, updatedAt: r.updatedAt,
}; };

View File

@@ -8,64 +8,109 @@ import { safeError } from "@/lib/errors";
/** /**
* GET /api/usage * GET /api/usage
* *
* Customers: tenant resolved server-side from the user's orgId. The * Per-tenant spend/token usage for a given month.
* response is filtered by the tenant's `litellmKeyAlias` so
* sibling tenants in the same org don't bleed into the total.
* Platform admins: may pass ?teamId=... to inspect any team. They may
* also pass ?keyAlias=... to scope to a single tenant.
* *
* Slice 2 note * Resolution rules (in priority order)
* ------------ * ------------------------------------
* LiteLLM teams are now shared across all tenants of an org. The team's * 1. `?tenant=<name>` query param — the canonical path. The route
* `/team/info` budget is the *company* budget; the per-tenant numbers * looks up the PiecedTenant CR by name, runs it through the
* come from filtering spend logs by `key_alias`. If a tenant has no * viewer's visibility filter, and reads `status.litellmTeamId` +
* `litellmKeyAlias` in status (transitional state right after upgrade, * `status.litellmKeyAlias`. This is what the tenant-detail page
* before the operator has reconciled), we fall back to team-level * calls with for both customers and admins.
* filtering — the numbers will be slightly inflated for that one * 2. `?teamId=<id>` (+ optional `?keyAlias=<alias>`) — admin escape
* reconcile cycle. * hatch for debugging across orgs (e.g. opening the platform
* panel without a specific tenant in mind). Platform-only;
* ignored for customer sessions.
* 3. No params — 400. We deliberately do NOT fall back to "the
* first visible tenant". Bug 19: that fallback meant siblings
* in the same org showed identical numbers because the API
* always picked the same "first" tenant regardless of which
* detail page the customer was viewing. Forcing callers to be
* explicit makes the bug structurally impossible to reintroduce.
*
* Filtering
* ---------
* LiteLLM's `/spend/logs/v2` accepts a server-side `key_alias` filter.
* We pass it through directly — no more "fetch all team pages and
* post-filter in JS" (which was O(team_total) memory per request and
* masked the routing bug above by being slow enough that nobody
* noticed which alias was actually being used).
*
* The team-level budget is still surfaced as the *org* budget, since
* teams are org-scoped post-Slice-2. That's intentional: the customer
* sees "your company has X budget remaining" alongside "this tenant
* cost Y this month".
*/ */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tenantName = req.nextUrl.searchParams.get("tenant");
let teamId: string | null = null; let teamId: string | null = null;
let keyAlias: string | null = null; let keyAlias: string | null = null;
if (user.isPlatform) { if (tenantName) {
teamId = req.nextUrl.searchParams.get("teamId") ?? null; // Path 1: resolve from tenant name with visibility check.
keyAlias = req.nextUrl.searchParams.get("keyAlias") ?? null; //
} // listVisibleTenants enforces the same visibility rules as every
// other read endpoint:
// For customers (or admins without explicit params): resolve from // - platform admins see everything
// the user's *visible* tenants. With Slice 6, a `user`-role member // - owners see all tenants in their org
// can only see usage for tenants they're assigned to — a non-assigned // - users see only the tenants they're assigned to (Slice 6)
// user defaults to "no active tenant" (404). //
// // Filtering through that list rather than reading the CR directly
// Owner and platform get the full org-scoped list and pick the first // means a malicious caller can't probe arbitrary tenant names to
// tenant, matching the dashboard's "current instance" semantics. // learn what exists in other orgs.
if (!teamId) {
const allTenants = await listTenants(); const allTenants = await listTenants();
const visible = await listVisibleTenants(user, allTenants); const visible = await listVisibleTenants(user, allTenants);
const orgTenant = visible.find((t) => !!t.status?.litellmTeamId); const tenant = visible.find((t) => t.metadata.name === tenantName);
if (!orgTenant?.status?.litellmTeamId) { if (!tenant) {
return NextResponse.json( return NextResponse.json(
{ error: "No active tenant found for your organization" }, { error: "Tenant not found or not accessible" },
{ status: 404 } { status: 404 }
); );
} }
teamId = orgTenant.status.litellmTeamId; if (!tenant.status?.litellmTeamId) {
// Tenant exists but the operator hasn't reconciled it yet.
// If the operator has populated the per-tenant key alias, filter by it. // Common right after onboarding; the customer should see a
// Falling back to team-level (no alias) will return the org total, which // friendly empty state, not a 500.
// is acceptable transitionally but means siblings' usage shows up here. return NextResponse.json(
if (orgTenant.status.litellmKeyAlias) { { error: "Tenant is still provisioning, no usage data yet" },
keyAlias = orgTenant.status.litellmKeyAlias; { status: 409 }
);
} }
teamId = tenant.status.litellmTeamId;
// litellmKeyAlias is set by the operator's LiteLLM reconcile step
// alongside litellmTeamId, so if teamId is present this should be
// too. Defensive fallback to team-level if missing — in that case
// the customer briefly sees company totals until the next operator
// reconcile, which is better than 500.
keyAlias = tenant.status.litellmKeyAlias ?? null;
} else if (user.isPlatform) {
// Path 2: admin escape hatch.
teamId = req.nextUrl.searchParams.get("teamId");
keyAlias = req.nextUrl.searchParams.get("keyAlias");
if (!teamId) {
return NextResponse.json(
{
error:
"Either ?tenant=<name> or ?teamId=<id> (admin) must be provided",
},
{ status: 400 }
);
}
} else {
// Path 3: no resolution possible. See doc above for why we don't
// pick a default.
return NextResponse.json(
{ error: "Tenant must be specified via ?tenant=<name>" },
{ status: 400 }
);
} }
// Month param: YYYY-MM, defaults to current month // Month param: YYYY-MM, defaults to current month.
const now = new Date(); const now = new Date();
const monthParam = const monthParam =
req.nextUrl.searchParams.get("month") || req.nextUrl.searchParams.get("month") ||
@@ -81,11 +126,11 @@ export async function GET(req: NextRequest) {
try { try {
const teamInfo = await getTeamInfo(teamId); const teamInfo = await getTeamInfo(teamId);
// Fetch all pages from the team. We always query at the team level — // Page through results — server-side filtered by key_alias when
// LiteLLM's /spend/logs/v2 doesn't filter by key_alias reliably across // provided. Pagination still needed because LiteLLM caps
// versions, so we paginate and post-filter in code. For pilot scale // page_size at 100, and a busy tenant can easily exceed that in
// this is cheap; if a single team ever exceeds ~10k entries/month we // a month. With server-side filtering this stays cheap regardless
// can revisit. // of how busy sibling tenants in the same team are.
const allRequests: any[] = []; const allRequests: any[] = [];
let page = 1; let page = 1;
while (true) { while (true) {
@@ -94,33 +139,25 @@ export async function GET(req: NextRequest) {
startStr, startStr,
endStr, endStr,
page, page,
100 100,
keyAlias
); );
allRequests.push(...(result.data || [])); allRequests.push(...(result.data || []));
if (page >= (result.total_pages || 1)) break; if (page >= (result.total_pages || 1)) break;
page++; page++;
// Defensive cap. A pathological response with bogus total_pages
// shouldn't be able to spin us forever. 50 pages × 100 = 5000
// entries/month/tenant is well above any realistic usage at
// pilot scale.
if (page > 50) break;
} }
// Apply key_alias post-filter when scoping to a single tenant. Match // Aggregate by day.
// both `key_alias` (newer LiteLLM) and `metadata.user_api_key_alias`
// (older builds nest it inside metadata).
const scoped = keyAlias
? allRequests.filter((r) => {
const alias =
r.key_alias ??
r.metadata?.user_api_key_alias ??
r.api_key_alias ??
null;
return alias === keyAlias;
})
: allRequests;
// Aggregate by day
const byDay: Record< const byDay: Record<
string, string,
{ inputTokens: number; outputTokens: number; spend: number } { inputTokens: number; outputTokens: number; spend: number }
> = {}; > = {};
for (const r of scoped) { for (const r of allRequests) {
const day = (r.startTime || r.endTime || "").slice(0, 10); const day = (r.startTime || r.endTime || "").slice(0, 10);
if (!day) continue; if (!day) continue;
if (!byDay[day]) if (!byDay[day])
@@ -134,30 +171,30 @@ export async function GET(req: NextRequest) {
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]) => ({ date, ...d })); .map(([date, d]) => ({ date, ...d }));
const totalInput = scoped.reduce( const totalInput = allRequests.reduce(
(s, r) => s + (r.prompt_tokens || 0), (s, r) => s + (r.prompt_tokens || 0),
0 0
); );
const totalOutput = scoped.reduce( const totalOutput = allRequests.reduce(
(s, r) => s + (r.completion_tokens || 0), (s, r) => s + (r.completion_tokens || 0),
0 0
); );
const totalSpend = scoped.reduce((s, r) => s + (r.spend || 0), 0); const totalSpend = allRequests.reduce((s, r) => s + (r.spend || 0), 0);
return NextResponse.json({ return NextResponse.json({
teamId, teamId,
keyAlias, // null when not filtering — useful for the client to know it sees company-wide data keyAlias, // null when admin queries team-wide (no specific tenant)
month: monthParam, month: monthParam,
currentPeriod: { currentPeriod: {
inputTokens: totalInput, inputTokens: totalInput,
outputTokens: totalOutput, outputTokens: totalOutput,
totalSpend, totalSpend,
requestCount: scoped.length, requestCount: allRequests.length,
}, },
// Budget is always team-level (= company budget). Spend reported // Budget is always team-level (= company budget). Spend reported
// here is the team total, not the per-key total — the customer // here is the team total, not the per-key total — the customer
// wants to see "how much of our company budget is left", not just // wants to see "how much of our company budget is left", not
// "how much has this one tenant cost". // just "how much has this one tenant cost".
budget: { budget: {
maxBudget: teamInfo?.team_info?.max_budget ?? null, maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.team_info?.spend ?? 0, spend: teamInfo?.team_info?.spend ?? 0,

View File

@@ -94,17 +94,27 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
/** /**
* Usage display widget. * Usage display widget.
* *
* - Customers: don't pass teamId or keyAlias — the backend resolves both * Pass `tenant=<name>` for the canonical path — works for both
* from the session-bound tenant. * customers and admins, the API resolves team+alias from the tenant
* - Admins inspecting a specific tenant: pass `teamId` (the org-level * CR's status. The visibility check on the API ensures users can't
* LiteLLM team id) AND `keyAlias` (the tenant's virtual-key alias). * query tenants they shouldn't see.
* Without `keyAlias`, the response includes spend from sibling tenants *
* in the same org, since teams are shared since Slice 2. * `teamId`/`keyAlias` remain available as a platform-admin escape
* hatch for cross-org debugging, but the tenant-detail and dashboard
* paths should always use `tenant`.
*
* Bug 19 fix: previous version omitted both props for customer
* sessions, expecting the API to "figure it out". The API's fallback
* was "first visible tenant", which meant siblings in the same org
* showed identical numbers regardless of which detail page was open.
* Now the page passes the tenant name explicitly; no fallback exists.
*/ */
export function UsageDisplay({ export function UsageDisplay({
tenant,
teamId, teamId,
keyAlias, keyAlias,
}: { }: {
tenant?: string | null;
teamId?: string | null; teamId?: string | null;
keyAlias?: string | null; keyAlias?: string | null;
}) { }) {
@@ -121,11 +131,13 @@ export function UsageDisplay({
setError(null); setError(null);
const params = new URLSearchParams({ month }); const params = new URLSearchParams({ month });
if (teamId) { if (tenant) {
params.set("tenant", tenant);
} else if (teamId) {
// Admin escape hatch — only honoured by the API when the
// viewer is platform-role.
params.set("teamId", teamId); params.set("teamId", teamId);
} if (keyAlias) params.set("keyAlias", keyAlias);
if (keyAlias) {
params.set("keyAlias", keyAlias);
} }
fetch(`/api/usage?${params}`) fetch(`/api/usage?${params}`)
@@ -133,7 +145,7 @@ export function UsageDisplay({
.then(setData) .then(setData)
.catch((e) => setError(e.message)) .catch((e) => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [teamId, keyAlias, month]); }, [tenant, teamId, keyAlias, month]);
useEffect(() => { fetchUsage(); }, [fetchUsage]); useEffect(() => { fetchUsage(); }, [fetchUsage]);

View File

@@ -12,6 +12,14 @@ interface OnboardingFlowProps {
*/ */
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
/**
* Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full
* shape and behavioural contract.
*/
editingRequest?: React.ComponentProps<
typeof OnboardingWizard
>["editingRequest"];
} }
/** /**
@@ -29,6 +37,7 @@ export function OnboardingFlow({
orgName, orgName,
userName, userName,
userEmail, userEmail,
editingRequest,
}: OnboardingFlowProps) { }: OnboardingFlowProps) {
const router = useRouter(); const router = useRouter();
@@ -37,6 +46,7 @@ export function OnboardingFlow({
orgName={orgName} orgName={orgName}
userName={userName} userName={userName}
userEmail={userEmail} userEmail={userEmail}
editingRequest={editingRequest}
onComplete={() => { onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The // Navigate back to /dashboard and re-fetch on the server. The
// parent server component will see the new `pending` row and // parent server component will see the new `pending` row and

View File

@@ -1,8 +1,11 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Modal } from "@/components/ui/modal";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
@@ -14,6 +17,7 @@ interface RequestSummary {
status: string; status: string;
adminNotes?: string; adminNotes?: string;
tenantName?: string; tenantName?: string;
dismissedAt?: string | null;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
@@ -36,21 +40,42 @@ interface SingleRequestState {
tenant: TenantSummary | null; tenant: TenantSummary | null;
} }
interface Props {
requestId: string;
/**
* Whether the viewer can act on this request — cancel a pending one,
* dismiss a rejected one, etc. True for owner + platform; false for
* `user`-role customers (who shouldn't see in-flight requests at all,
* but defence in depth — `canSeeInflightRequests` already gates the
* dashboard side).
*/
canAct: boolean;
}
/** /**
* ProvisioningStatus * ProvisioningStatus
* *
* Polls /api/onboarding?id=<requestId> every 5s until the request reaches * Polls /api/onboarding?id=<requestId> every 5s until the request reaches
* a terminal state. Slice 3: takes a `requestId` prop so multiple of these * a terminal state. Slice 3: takes a `requestId` prop so multiple of
* can render on the same dashboard for different in-flight requests. * these can render on the same dashboard for different in-flight
* requests.
* *
* The pre-Slice-3 version polled /api/onboarding with no params and * Slice 7 / Bug 6 + 13:
* assumed one-request-per-org — that endpoint shape is gone now. * - pending → cancel + edit buttons
* - rejected → admin notes block + dismiss button
* - cancelled → small acknowledgement card + dismiss button
* - terminal Ready/Active states unchanged
*/ */
export function ProvisioningStatus({ requestId }: { requestId: string }) { export function ProvisioningStatus({ requestId, canAct }: Props) {
const t = useTranslations("onboarding"); const t = useTranslations("onboarding");
const tCommon = useTranslations("common");
const f = useFormatter(); const f = useFormatter();
const router = useRouter();
const [data, setData] = useState<SingleRequestState | null>(null); const [data, setData] = useState<SingleRequestState | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [actionPending, setActionPending] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const poll = useCallback(async () => { const poll = useCallback(async () => {
try { try {
@@ -67,11 +92,11 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
useEffect(() => { useEffect(() => {
poll(); poll();
const status = data?.request?.status; const status = data?.request?.status;
const phase = data?.tenant?.phase; const phase = data?.tenant?.phase;
const terminal = const terminal =
status === "rejected" || status === "rejected" ||
status === "cancelled" ||
status === "active" || status === "active" ||
phase === "Ready" || phase === "Ready" ||
phase === "Running"; phase === "Running";
@@ -82,7 +107,54 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [poll, data?.request?.status, data?.tenant?.phase]); }, [poll, data?.request?.status, data?.tenant?.phase]);
if (error) { const handleCancel = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("cancelFailed"));
}
setConfirmCancel(false);
// Re-poll so the card transitions to "cancelled" state without a
// full route refresh — the dashboard's surrounding tenant cards
// are unaffected.
await poll();
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
const handleDismiss = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}/dismiss`,
{ method: "POST" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("dismissFailed"));
}
// Server-rendered list query (`listActiveTenantRequestsByOrgId`)
// filters out dismissed rows — refresh to drop this card.
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
if (error && !data) {
return ( return (
<Card> <Card>
<div className="text-xs text-red-400">{error}</div> <div className="text-xs text-red-400">{error}</div>
@@ -107,7 +179,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
data.request.tenantName || data.request.tenantName ||
data.request.agentName; data.request.agentName;
// Pending admin approval // ─── Pending: awaiting admin approval ───────────────────────────────
if (status === "pending") { if (status === "pending") {
return ( return (
<Card className="animate-in"> <Card className="animate-in">
@@ -131,7 +203,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("pendingTitle")} {t("pendingTitle")}
</h2> </h2>
{label && ( {label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p> <p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)} )}
<p className="text-sm text-text-secondary max-w-sm mx-auto"> <p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")} {t("pendingDescription")}
@@ -150,12 +224,71 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
</span> </span>
</p> </p>
)} )}
{/* Bug 6 — owner-only edit + cancel actions while still
pending. Once admin acts, both buttons disappear (the
status branch changes). */}
{canAct && (
<div className="flex justify-center gap-2 mt-5">
<Link
href={`/dashboard/edit/${encodeURIComponent(requestId)}`}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("editRequest")}
</Link>
<button
type="button"
onClick={() => setConfirmCancel(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
{t("cancelRequest")}
</button>
</div>
)}
{error && (
<p className="text-xs text-red-400 mt-3">{error}</p>
)}
</div> </div>
{confirmCancel && (
<Modal
open={confirmCancel}
onClose={() => setConfirmCancel(false)}
ariaLabel={t("cancelConfirmRequestTitle")}
>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmRequestTitle")}
</h3>
<p className="text-sm text-text-secondary mb-5">
{t("cancelConfirmRequestDescription")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmCancel(false)}
disabled={actionPending}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={handleCancel}
disabled={actionPending}
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
>
{actionPending
? tCommon("loading")
: t("cancelRequestConfirm")}
</button>
</div>
</Modal>
)}
</Card> </Card>
); );
} }
// Rejected // ─── Rejected: admin declined ───────────────────────────────────────
if (status === "rejected") { if (status === "rejected") {
return ( return (
<Card className="animate-in"> <Card className="animate-in">
@@ -179,22 +312,94 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("rejectedTitle")} {t("rejectedTitle")}
</h2> </h2>
{label && ( {label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p> <p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)} )}
<p className="text-sm text-text-secondary max-w-sm mx-auto"> <p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("rejectedDescription")} {t("rejectedDescription")}
</p> </p>
{data.request.adminNotes && ( {data.request.adminNotes && (
<p className="text-xs text-text-muted mt-3 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto"> <div className="text-left text-xs text-text-secondary mt-4 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
{data.request.adminNotes} <div className="font-semibold uppercase tracking-wider text-text-muted text-[10px] mb-1.5">
</p> {t("rejectionReason")}
</div>
<div className="whitespace-pre-wrap">
{data.request.adminNotes}
</div>
</div>
)} )}
{/* Bug 13: dismiss removes this card from the dashboard but
keeps the row in the DB for audit. The customer can also
just resubmit via the wizard — both paths are valid. */}
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div> </div>
</Card> </Card>
); );
} }
// Provisioning in progress (status approved/provisioning, optionally with tenant phase < Ready) // ─── Cancelled: customer cancelled before admin acted (Bug 6) ──────
if (status === "cancelled") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-text-muted/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-text-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelledTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("cancelledDescription")}
</p>
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div>
</Card>
);
}
// ─── Provisioning: approved, operator working ──────────────────────
if ( if (
status === "approved" || status === "approved" ||
status === "provisioning" || status === "provisioning" ||
@@ -213,7 +418,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("provisioningTitle")} {t("provisioningTitle")}
</h2> </h2>
{label && ( {label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p> <p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)} )}
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
{t("provisioningDescription")} {t("provisioningDescription")}
@@ -249,7 +456,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
); );
} }
// Active / Ready // ─── Active / Ready ─────────────────────────────────────────────────
if (status === "active") { if (status === "active") {
return ( return (
<Card className="animate-in"> <Card className="animate-in">
@@ -273,7 +480,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("readyTitle")} {t("readyTitle")}
</h2> </h2>
{label && ( {label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p> <p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)} )}
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4"> <p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")} {t("readyDescription")}

View File

@@ -64,6 +64,35 @@ interface WizardProps {
*/ */
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
/**
* Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is
* skipped (we trust the existing values), and the submit button
* PATCHes /api/onboarding/[id] instead of POSTing /api/onboarding.
*
* Per-package secrets are deliberately NOT pre-filled, even if the
* customer originally supplied them — server-side decryption to
* the client would be a security regression. The user re-enters
* any secrets they want to change; if they leave them blank, the
* existing encrypted blob in the DB is preserved by the PATCH
* endpoint.
*/
editingRequest?: {
id: string;
instanceName: string;
agentName: string;
soulMd: string;
agentsMd: string;
packages: string[];
billingAddress: {
company?: string;
street?: string;
city?: string;
postalCode?: string;
country?: string;
};
billingNotes: string;
};
onComplete: () => void; onComplete: () => void;
} }
@@ -71,6 +100,7 @@ export function OnboardingWizard({
orgName, orgName,
userName, userName,
userEmail, userEmail,
editingRequest,
onComplete, onComplete,
}: WizardProps) { }: WizardProps) {
const t = useTranslations("onboarding"); const t = useTranslations("onboarding");
@@ -91,30 +121,55 @@ export function OnboardingWizard({
orgName, orgName,
isPersonal, isPersonal,
}); });
const isEditing = Boolean(editingRequest);
const [step, setStep] = useState<Step>("welcome"); // Edit mode jumps straight to the configure step — the welcome step
// is a first-time onboarding affordance and only adds friction when
// the customer is fixing a typo.
const [step, setStep] = useState<Step>(isEditing ? "configure" : "welcome");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [defaultsLoaded, setDefaultsLoaded] = useState(false); // In edit mode we already have soulMd/agentsMd from the request;
// skip the workspace-defaults round trip that would overwrite them.
const [defaultsLoaded, setDefaultsLoaded] = useState(isEditing);
const [config, setConfig] = useState({ const [config, setConfig] = useState(() => {
instanceName: "", if (editingRequest) {
agentName: "Assistant", return {
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName), instanceName: editingRequest.instanceName,
agentsMd: FALLBACK_AGENTS, agentName: editingRequest.agentName,
packages: [] as string[], soulMd: editingRequest.soulMd,
billingAddress: { agentsMd: editingRequest.agentsMd,
// For personal accounts, leave the company field empty — it'll packages: editingRequest.packages,
// appear on invoices. The user can still type something if they billingAddress: {
// want to. company: editingRequest.billingAddress.company ?? "",
company: isPersonal ? "" : displayOrgName, street: editingRequest.billingAddress.street ?? "",
street: "", city: editingRequest.billingAddress.city ?? "",
city: "", postalCode: editingRequest.billingAddress.postalCode ?? "",
postalCode: "", country: editingRequest.billingAddress.country ?? "CH",
country: "CH", },
}, billingNotes: editingRequest.billingNotes,
billingNotes: "", };
}
return {
instanceName: "",
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS,
packages: [] as string[],
billingAddress: {
// For personal accounts, leave the company field empty — it'll
// appear on invoices. The user can still type something if they
// want to.
company: isPersonal ? "" : displayOrgName,
street: "",
city: "",
postalCode: "",
country: "CH",
},
billingNotes: "",
};
}); });
// TOOLS.md preview — readonly, auto-generated // TOOLS.md preview — readonly, auto-generated
@@ -308,8 +363,17 @@ export function OnboardingWizard({
} }
} }
const res = await fetch("/api/onboarding", { // Bug 6: edit mode targets the per-row endpoint with PATCH;
method: "POST", // create mode targets the collection endpoint with POST. Body
// shape is the same — both routes parse it through
// onboardingSchema.
const url = editingRequest
? `/api/onboarding/${encodeURIComponent(editingRequest.id)}`
: "/api/onboarding";
const method = editingRequest ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
...config, ...config,
@@ -1017,7 +1081,11 @@ export function OnboardingWizard({
disabled={submitting} disabled={submitting}
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
{submitting ? tCommon("loading") : t("submitRequest")} {submitting
? tCommon("loading")
: isEditing
? t("saveChanges")
: t("submitRequest")}
</button> </button>
</div> </div>
</Card> </Card>

View File

@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Modal } from "@/components/ui/modal";
interface Props { interface Props {
tenantName: string; tenantName: string;
@@ -102,55 +103,50 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
)} )}
{confirmOpen && ( {confirmOpen && (
<div <Modal
role="dialog" open={confirmOpen}
aria-modal="true" onClose={() => setConfirmOpen(false)}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" ariaLabel={t("cancelConfirmTitle")}
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmOpen(false);
}}
> >
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full"> <h3 className="font-display text-lg font-semibold text-text-primary mb-2">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2"> {t("cancelConfirmTitle")}
{t("cancelConfirmTitle")} </h3>
</h3> <p className="text-sm text-text-secondary mb-3">
<p className="text-sm text-text-secondary mb-3"> {t("cancelConfirmDescription")}
{t("cancelConfirmDescription")} </p>
</p> <ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5"> <li>{t("cancelConfirmBullet1")}</li>
<li>{t("cancelConfirmBullet1")}</li> <li>{t("cancelConfirmBullet2")}</li>
<li>{t("cancelConfirmBullet2")}</li> <li>{t("cancelConfirmBullet3")}</li>
<li>{t("cancelConfirmBullet3")}</li> </ul>
</ul>
{error && ( {error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3"> <div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error} {error}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmOpen(false)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={() => toggleSuspend(true)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("cancelSubscriptionConfirm")}
</button>
</div> </div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmOpen(false)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={() => toggleSuspend(true)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("cancelSubscriptionConfirm")}
</button>
</div> </div>
</div> </Modal>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface Props {
open: boolean;
/** Called when user clicks the backdrop or presses Escape. */
onClose: () => void;
children: React.ReactNode;
/**
* ARIA label fallback when no labelled element exists inside.
* Optional; if you have a heading inside the modal with id, set
* `aria-labelledby` on a wrapper instead.
*/
ariaLabel?: string;
}
/**
* Portal-based modal.
*
* Why a portal
* ------------
* `position: fixed` becomes positioned relative to a transformed
* ancestor's containing block, not the viewport, when ANY ancestor
* has a `transform`, `perspective`, or `filter` applied. Our
* `animate-in` utility sets `transform: translateY(0)` on a lot of
* dashboard/tenant-detail containers (because of the fade-up
* animation, which uses `animation-fill-mode: both` to keep the
* transform on after the animation finishes). That broke modals
* rendered as in-place children — they centred to the panel they
* lived in, not to the page.
*
* Rendering at `document.body` via `createPortal` escapes every
* containing-block ancestor and gives us true viewport coordinates.
*
* UX details
* ----------
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
* when the click target IS the backdrop, not the panel inside.)
* - Escape key triggers `onClose`. Standard modal expectation.
* - `body` overflow is locked while open so background content
* doesn't scroll behind the modal.
* - Renders nothing on first paint server-side, then mounts on
* client. `useEffect` gating ensures `document.body` is available;
* without it Next.js SSR would throw on `document` reference.
*/
export function Modal({ open, onClose, children, ariaLabel }: Props) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
if (!open) return;
// Lock background scroll. Restore on unmount/close.
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeRef.current();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKey);
};
}, [open]);
if (!open) return null;
if (typeof document === "undefined") return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
{children}
</div>
</div>,
document.body
);
}

View File

@@ -15,6 +15,11 @@ const phaseStyles: Record<string, string> = {
Running: "bg-success/10 text-success border-success/20", Running: "bg-success/10 text-success border-success/20",
Ready: "bg-success/10 text-success border-success/20", Ready: "bg-success/10 text-success border-success/20",
Provisioning: "bg-warning/10 text-warning border-warning/20", Provisioning: "bg-warning/10 text-warning border-warning/20",
// Reconfiguring shares the warning palette (yellow pulse) but renders
// a distinct label, so customers see it differently from first-time
// provisioning. Useful when packages or channel-users change and the
// pod restarts mid-life.
Reconfiguring: "bg-warning/10 text-warning border-warning/20",
Pending: "bg-text-muted/10 text-text-secondary border-border", Pending: "bg-text-muted/10 text-text-secondary border-border",
Suspended: "bg-amber-500/10 text-amber-400 border-amber-500/30", Suspended: "bg-amber-500/10 text-amber-400 border-amber-500/30",
Error: "bg-error/10 text-error border-error/20", Error: "bg-error/10 text-error border-error/20",
@@ -44,6 +49,9 @@ export function StatusBadge({ phase }: { phase: string }) {
{phase === "Provisioning" && ( {phase === "Provisioning" && (
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" /> <span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
)} )}
{phase === "Reconfiguring" && (
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
)}
{label} {label}
</span> </span>
); );

View File

@@ -0,0 +1,118 @@
"use client";
import { useTranslations } from "next-intl";
/**
* Tenant warning shape received from the operator's status.warnings.
* Mirror of the operator's `TenantWarning` type. See
* pieced-operator/api/v1alpha1/piecedtenant_types.go.
*/
export interface TenantWarning {
source: string;
reason?: string;
message?: string;
since?: string;
}
interface Props {
warnings: TenantWarning[];
}
/**
* Renders a small amber warning badge if there are any non-fatal
* warnings on the tenant. The badge sits visually next to the phase
* StatusBadge — they're separate concepts (phase = lifecycle, warnings
* = observed sub-issues) and may both be present at once (e.g. tenant
* is `Ready` but has a SkillPacksReady=False warning).
*
* Hover/focus reveals the warning detail. We don't truncate the message
* inside the tooltip; OCI/CRD condition messages tend to be short and
* include the actionable detail (which skill, which secret, which
* resolver). If a future warning source has a 5-line stacktrace as a
* message we'll need a different treatment; cross that bridge then.
*
* Returns null when there are no warnings — keep render-call sites
* simple, they don't have to gate on length themselves.
*/
export function WarningBadge({ warnings }: Props) {
const t = useTranslations("warnings");
if (!warnings || warnings.length === 0) return null;
const tooltipLabel = (() => {
try {
return warnings.length === 1
? t("oneTooltip")
: t("manyTooltip", { count: warnings.length });
} catch {
return warnings.length === 1
? "1 warning"
: `${warnings.length} warnings`;
}
})();
return (
<span className="relative group inline-flex">
<button
type="button"
// Button is non-actionable in itself — it exists purely to get
// keyboard focus for screen readers and keyboard users, so the
// tooltip isn't pointer-only. `aria-label` carries the summary;
// the full content is in the tooltip below for sighted users.
aria-label={tooltipLabel}
className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-400 hover:bg-amber-500/20 focus:outline-none focus:ring-1 focus:ring-amber-400 cursor-help"
// No onClick — this is informational, not actionable. Pure
// hover/focus widget. tabIndex defaults to 0 for buttons.
>
<svg
viewBox="0 0 24 24"
width={12}
height={12}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
</svg>
<span>{warnings.length}</span>
</button>
{/*
Tooltip. Hidden by default; shown on hover OR focus of the
sibling button. Positioned below-right so it doesn't collide with
the StatusBadge that typically sits left of this. Constrained
width so long messages wrap.
z-50 keeps it above table rows / cards.
*/}
<div
role="tooltip"
className="invisible group-hover:visible group-focus-within:visible absolute left-0 top-full mt-1 z-50 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-lg text-left"
>
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-2">
{tooltipLabel}
</div>
<ul className="space-y-2">
{warnings.map((w, i) => (
<li key={i} className="text-xs">
<div className="font-mono text-amber-400 break-all">
{w.source}
</div>
{w.reason && (
<div className="text-text-secondary">{w.reason}</div>
)}
{w.message && (
<div className="text-text-secondary mt-0.5 break-words">
{w.message}
</div>
)}
</li>
))}
</ul>
</div>
</span>
);
}

View File

@@ -1,5 +1,5 @@
import { Pool } from "pg"; import { Pool } from "pg";
import type { TenantRequest, TenantRequestStatus } from "@/types"; import type { BillingAddress, TenantRequest, TenantRequestStatus } from "@/types";
import { listTenants, getTenant } from "./k8s"; import { listTenants, getTenant } from "./k8s";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -72,6 +72,11 @@ const MIGRATION_SQL = `
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS is_personal BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS is_personal BOOLEAN NOT NULL DEFAULT FALSE;
-- Bug 13: customer-side dismissal of rejected requests. NULL means "still
-- visible on the dashboard"; non-null means "customer clicked Dismiss".
-- Pending/approved/active rows keep this NULL by definition — the field
-- is only meaningful for rejected and cancelled rows.
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
-- Slice 3: drop the legacy 1-org-1-request constraint if it exists -- Slice 3: drop the legacy 1-org-1-request constraint if it exists
ALTER TABLE tenant_requests DROP CONSTRAINT IF EXISTS tenant_requests_zitadel_org_id_key; ALTER TABLE tenant_requests DROP CONSTRAINT IF EXISTS tenant_requests_zitadel_org_id_key;
@@ -250,10 +255,21 @@ export async function listTenantRequestsByOrgId(
} }
/** /**
* As {@link listTenantRequestsByOrgId} but excludes terminal-failed states * As {@link listTenantRequestsByOrgId} but tuned for the customer's
* (rejected, deleted). Useful for the dashboard which wants to show * dashboard view.
* pending/approved/provisioning/active tenants and pending requests, not *
* historical rejections. * Returns:
* - All non-terminal rows (pending, approved, provisioning, active),
* because the customer needs to see what's in flight.
* - Terminal-failed rows (rejected, cancelled) that the customer
* hasn't dismissed yet (Bug 13). Without this, a rejection that
* happens while the customer isn't online would only be
* communicated by email — easy to miss.
*
* Excludes:
* - `deleted` rows (admin tore down the tenant — historical, not
* actionable).
* - Dismissed rejected/cancelled rows.
*/ */
export async function listActiveTenantRequestsByOrgId( export async function listActiveTenantRequestsByOrgId(
orgId: string orgId: string
@@ -262,7 +278,8 @@ export async function listActiveTenantRequestsByOrgId(
const result = await getPool().query<TenantRequest>( const result = await getPool().query<TenantRequest>(
`SELECT * FROM tenant_requests `SELECT * FROM tenant_requests
WHERE zitadel_org_id = $1 WHERE zitadel_org_id = $1
AND status NOT IN ('deleted', 'rejected') AND status <> 'deleted'
AND (status NOT IN ('rejected', 'cancelled') OR dismissed_at IS NULL)
ORDER BY created_at DESC`, ORDER BY created_at DESC`,
[orgId] [orgId]
); );
@@ -354,6 +371,96 @@ export async function clearEncryptedSecrets(requestId: string): Promise<void> {
); );
} }
/**
* Set dismissed_at = now() on a request row. Used when a customer
* clicks "Dismiss" on a rejected/cancelled card on their dashboard
* (Bug 13). The row stays in the database for history/audit but
* stops appearing in `listActiveTenantRequestsByOrgId`.
*
* Idempotent: dismissing an already-dismissed row is a no-op.
* Caller is responsible for verifying the row belongs to the user's
* org before calling.
*/
export async function dismissTenantRequest(id: string): Promise<void> {
await ensureSchema();
await getPool().query(
`UPDATE tenant_requests
SET dismissed_at = COALESCE(dismissed_at, now()),
updated_at = now()
WHERE id = $1`,
[id]
);
}
/**
* Update editable fields of a still-pending tenant request. Bug 6 — a
* customer who notices a typo or wants to add a package after submitting
* the wizard should be able to fix it without admin involvement.
*
* Only the customer-input fields are updateable. `status`, `tenant_name`,
* `admin_notes`, `encrypted_secrets`, `is_personal`, `zitadel_*` and
* timestamps are managed elsewhere and intentionally not here.
*
* The caller is responsible for:
* - verifying the row belongs to the user's org
* - verifying status === 'pending' (editing approved/provisioning rows
* would race against the operator)
*
* Returns the updated row, or null if the id didn't match anything.
*/
export async function updateTenantRequestEditableFields(
id: string,
fields: {
instanceName?: string | null;
agentName?: string;
soulMd?: string;
agentsMd?: string | null;
packages?: string[];
billingAddress?: BillingAddress;
billingNotes?: string;
encryptedSecrets?: Buffer | null;
}
): Promise<TenantRequest | null> {
await ensureSchema();
const sets: string[] = ["updated_at = now()"];
const values: any[] = [id];
let idx = 2;
// Map JS field names to SQL columns. Each entry is gated on
// `!== undefined` so passing only some fields just updates those.
const colMap: Array<[keyof typeof fields, string]> = [
["instanceName", "instance_name"],
["agentName", "agent_name"],
["soulMd", "soul_md"],
["agentsMd", "agents_md"],
["packages", "packages"],
["billingAddress", "billing_address"],
["billingNotes", "billing_notes"],
["encryptedSecrets", "encrypted_secrets"],
];
for (const [jsField, sqlCol] of colMap) {
const v = fields[jsField];
if (v === undefined) continue;
sets.push(`${sqlCol} = $${idx}`);
values.push(v);
idx++;
}
if (sets.length === 1) {
// No editable fields supplied — return the row unchanged rather
// than running a useless UPDATE that just bumps updated_at.
const cur = await getTenantRequestById(id);
return cur;
}
const result = await getPool().query<TenantRequest>(
`UPDATE tenant_requests SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
values
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
/** /**
* Wrapper around domain-check.ts that injects the portal's connection pool. * Wrapper around domain-check.ts that injects the portal's connection pool.
* Kept here so route handlers don't need direct access to the pool. * Kept here so route handlers don't need direct access to the pool.
@@ -446,6 +553,8 @@ function mapRow(row: any): TenantRequest {
tenantName: row.tenant_name, tenantName: row.tenant_name,
encryptedSecrets: row.encrypted_secrets ?? null, encryptedSecrets: row.encrypted_secrets ?? null,
isPersonal: row.is_personal ?? false, isPersonal: row.is_personal ?? false,
dismissedAt:
row.dismissed_at?.toISOString?.() ?? row.dismissed_at ?? null,
createdAt: row.created_at?.toISOString?.() ?? row.created_at, createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
}; };

View File

@@ -32,12 +32,43 @@ export async function getTeamSpendLogs(
return litellmFetch(`/global/spend/logs?${params}`); return litellmFetch(`/global/spend/logs?${params}`);
} }
/**
* Fetch one page of spend logs for a team, optionally narrowed to a
* single virtual key by alias.
*
* Slice 2 / Bug 19 context
* ------------------------
* Teams in LiteLLM are now org-scoped (one team per org), and each
* tenant in the org has its own virtual key with `key_alias = tenant
* CR name`. Without `keyAlias`, this returns the full team's spend —
* which mingles every tenant in the org. The portal's per-tenant
* usage view passes `keyAlias` to filter server-side via LiteLLM's
* native `key_alias` query param. Confirmed available on the
* `/spend/logs/v2` endpoint via OpenAPI introspection — no need to
* page-and-post-filter as the previous slice did.
*
* Why this matters
* ----------------
* Previous implementation fetched all team pages, then post-filtered
* by alias in JS. Two problems: (1) at any reasonable scale this is
* O(team_total) memory per request even when only one tenant's data
* is needed; (2) more importantly, when called from the customer
* dashboard without an explicit alias, the route's "pick the first
* visible tenant" fallback meant both Acme tenants showed identical
* numbers — the alias used was always the first tenant in the
* visible list, regardless of which tenant page was being viewed.
*
* The route layer above is responsible for resolving the tenant
* identity correctly and passing the right alias here. This
* function's only job is to pass it through to LiteLLM.
*/
export async function getTeamSpendLogsV2( export async function getTeamSpendLogsV2(
teamId: string, teamId: string,
startDate: string, startDate: string,
endDate: string, endDate: string,
page: number = 1, page: number = 1,
pageSize: number = 100 pageSize: number = 100,
keyAlias?: string | null
) { ) {
const params = new URLSearchParams({ const params = new URLSearchParams({
team_id: teamId, team_id: teamId,
@@ -46,6 +77,9 @@ export async function getTeamSpendLogsV2(
page: String(page), page: String(page),
page_size: String(pageSize), page_size: String(pageSize),
}); });
if (keyAlias) {
params.set("key_alias", keyAlias);
}
return litellmFetch(`/spend/logs/v2?${params}`); return litellmFetch(`/spend/logs/v2?${params}`);
} }

View File

@@ -100,7 +100,21 @@
"reviewInstanceDefault": "(Standard — verwendet Firmenname)", "reviewInstanceDefault": "(Standard — verwendet Firmenname)",
"reviewNoPackages": "Keine ausgewählt", "reviewNoPackages": "Keine ausgewählt",
"reviewBillingTo": "Rechnungsempfänger", "reviewBillingTo": "Rechnungsempfänger",
"reviewContactEmail": "Kontakt-E-Mail" "reviewContactEmail": "Kontakt-E-Mail",
"editRequestTitle": "Anfrage bearbeiten",
"editRequestDescription": "Passen Sie die Konfiguration an, bevor unser Team sie prüft.",
"editRequest": "Bearbeiten",
"cancelRequest": "Anfrage stornieren",
"cancelRequestConfirm": "Ja, Anfrage stornieren",
"cancelConfirmRequestTitle": "Diese Anfrage stornieren?",
"cancelConfirmRequestDescription": "Ihre ausstehende Anfrage wird als storniert markiert und aus der Warteschlange entfernt. Sie können jederzeit eine neue Anfrage einreichen.",
"cancelFailed": "Anfrage konnte nicht storniert werden.",
"cancelledTitle": "Anfrage storniert",
"cancelledDescription": "Sie haben diese Anfrage vor der Bearbeitung storniert. Es wurde keine Instanz erstellt.",
"dismiss": "Ausblenden",
"dismissFailed": "Konnte nicht ausgeblendet werden.",
"rejectionReason": "Angegebener Grund",
"saveChanges": "Änderungen speichern"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -345,6 +359,11 @@
"Ready": "Bereit", "Ready": "Bereit",
"Suspended": "Pausiert", "Suspended": "Pausiert",
"Error": "Fehler", "Error": "Fehler",
"Deleting": "Wird gelöscht" "Deleting": "Wird gelöscht",
"Reconfiguring": "Wird neu konfiguriert"
},
"warnings": {
"oneTooltip": "1 Warnung",
"manyTooltip": "{count} Warnungen"
} }
} }

View File

@@ -100,7 +100,21 @@
"reviewInstanceDefault": "(default — uses company name)", "reviewInstanceDefault": "(default — uses company name)",
"reviewNoPackages": "None selected", "reviewNoPackages": "None selected",
"reviewBillingTo": "Billing to", "reviewBillingTo": "Billing to",
"reviewContactEmail": "Contact email" "reviewContactEmail": "Contact email",
"editRequestTitle": "Edit your request",
"editRequestDescription": "Adjust the configuration before our team reviews it.",
"editRequest": "Edit",
"cancelRequest": "Cancel request",
"cancelRequestConfirm": "Yes, cancel request",
"cancelConfirmRequestTitle": "Cancel this request?",
"cancelConfirmRequestDescription": "Your pending request will be marked as cancelled and removed from the review queue. You can submit a new request at any time.",
"cancelFailed": "Could not cancel request.",
"cancelledTitle": "Request cancelled",
"cancelledDescription": "You cancelled this request before it was processed. No instance was created.",
"dismiss": "Dismiss",
"dismissFailed": "Could not dismiss.",
"rejectionReason": "Reason given",
"saveChanges": "Save changes"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -345,6 +359,11 @@
"Ready": "Ready", "Ready": "Ready",
"Suspended": "Suspended", "Suspended": "Suspended",
"Error": "Error", "Error": "Error",
"Deleting": "Deleting" "Deleting": "Deleting",
"Reconfiguring": "Reconfiguring"
},
"warnings": {
"oneTooltip": "1 warning",
"manyTooltip": "{count} warnings"
} }
} }

View File

@@ -100,7 +100,21 @@
"reviewInstanceDefault": "(par défaut — utilise le nom de l'entreprise)", "reviewInstanceDefault": "(par défaut — utilise le nom de l'entreprise)",
"reviewNoPackages": "Aucun sélectionné", "reviewNoPackages": "Aucun sélectionné",
"reviewBillingTo": "Facturer à", "reviewBillingTo": "Facturer à",
"reviewContactEmail": "E-mail de contact" "reviewContactEmail": "E-mail de contact",
"editRequestTitle": "Modifier votre demande",
"editRequestDescription": "Ajustez la configuration avant que notre équipe ne l'examine.",
"editRequest": "Modifier",
"cancelRequest": "Annuler la demande",
"cancelRequestConfirm": "Oui, annuler la demande",
"cancelConfirmRequestTitle": "Annuler cette demande ?",
"cancelConfirmRequestDescription": "Votre demande en attente sera marquée comme annulée et retirée de la file. Vous pouvez soumettre une nouvelle demande à tout moment.",
"cancelFailed": "Impossible d'annuler la demande.",
"cancelledTitle": "Demande annulée",
"cancelledDescription": "Vous avez annulé cette demande avant son traitement. Aucune instance n'a été créée.",
"dismiss": "Masquer",
"dismissFailed": "Impossible de masquer.",
"rejectionReason": "Motif indiqué",
"saveChanges": "Enregistrer les modifications"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -345,6 +359,11 @@
"Ready": "Prêt", "Ready": "Prêt",
"Suspended": "Suspendu", "Suspended": "Suspendu",
"Error": "Erreur", "Error": "Erreur",
"Deleting": "Suppression" "Deleting": "Suppression",
"Reconfiguring": "Reconfiguration"
},
"warnings": {
"oneTooltip": "1 avertissement",
"manyTooltip": "{count} avertissements"
} }
} }

View File

@@ -100,7 +100,21 @@
"reviewInstanceDefault": "(predefinito — usa il nome dell'azienda)", "reviewInstanceDefault": "(predefinito — usa il nome dell'azienda)",
"reviewNoPackages": "Nessuno selezionato", "reviewNoPackages": "Nessuno selezionato",
"reviewBillingTo": "Fatturare a", "reviewBillingTo": "Fatturare a",
"reviewContactEmail": "Email di contatto" "reviewContactEmail": "Email di contatto",
"editRequestTitle": "Modifica la sua richiesta",
"editRequestDescription": "Modifichi la configurazione prima che il nostro team la esamini.",
"editRequest": "Modifica",
"cancelRequest": "Annulla richiesta",
"cancelRequestConfirm": "Sì, annulla la richiesta",
"cancelConfirmRequestTitle": "Annullare questa richiesta?",
"cancelConfirmRequestDescription": "La sua richiesta in attesa sarà contrassegnata come annullata e rimossa dalla coda di revisione. Può inviare una nuova richiesta in qualsiasi momento.",
"cancelFailed": "Impossibile annullare la richiesta.",
"cancelledTitle": "Richiesta annullata",
"cancelledDescription": "Lei ha annullato questa richiesta prima dell'elaborazione. Nessuna istanza è stata creata.",
"dismiss": "Nascondi",
"dismissFailed": "Impossibile nascondere.",
"rejectionReason": "Motivo indicato",
"saveChanges": "Salva modifiche"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -345,6 +359,11 @@
"Ready": "Pronto", "Ready": "Pronto",
"Suspended": "Sospeso", "Suspended": "Sospeso",
"Error": "Errore", "Error": "Errore",
"Deleting": "Eliminazione" "Deleting": "Eliminazione",
"Reconfiguring": "Riconfigurazione"
},
"warnings": {
"oneTooltip": "1 avviso",
"manyTooltip": "{count} avvisi"
} }
} }

View File

@@ -83,6 +83,7 @@ export interface PiecedTenantStatus {
| "Provisioning" | "Provisioning"
| "Running" | "Running"
| "Ready" | "Ready"
| "Reconfiguring"
| "Suspended" | "Suspended"
| "Error" | "Error"
| "Deleting"; | "Deleting";
@@ -102,6 +103,21 @@ export interface PiecedTenantStatus {
litellmKeyAlias?: string; litellmKeyAlias?: string;
tenantNamespace?: string; tenantNamespace?: string;
enabledPackages?: string[]; enabledPackages?: string[];
/**
* Non-fatal issues from downstream resources surfaced by the operator
* (e.g. an OpenClawInstance sub-condition reporting failure). The
* tenant is still usable — these are informational, rendered as a
* warning badge alongside the phase.
*
* `source` is "<Kind>/<ConditionType>" e.g. "OpenClawInstance/SkillPacksReady".
* `message` is shown in the tooltip when the user hovers the badge.
*/
warnings?: Array<{
source: string;
reason?: string;
message?: string;
since?: string;
}>;
conditions?: Array<{ conditions?: Array<{
type: string; type: string;
status: string; status: string;
@@ -169,6 +185,7 @@ export type TenantRequestStatus =
| "provisioning" // PiecedTenant CR created, operator reconciling | "provisioning" // PiecedTenant CR created, operator reconciling
| "active" // Tenant running | "active" // Tenant running
| "rejected" // Admin rejected | "rejected" // Admin rejected
| "cancelled" // Customer cancelled before admin acted on it (Bug 6)
| "deleted"; // Tenant was deleted by admin | "deleted"; // Tenant was deleted by admin
export interface TenantRequest { export interface TenantRequest {
@@ -202,6 +219,14 @@ export interface TenantRequest {
* domain-uniqueness check on subsequent registrations. * domain-uniqueness check on subsequent registrations.
*/ */
isPersonal?: boolean; isPersonal?: boolean;
/**
* Bug 13: when set, the customer has explicitly dismissed a rejected
* request from their dashboard. Used by `listActiveTenantRequestsByOrgId`
* to keep showing rejected rows until they're dismissed (so a customer
* who wasn't online when the rejection happened still sees it on next
* login). Always null for non-rejected statuses.
*/
dismissedAt?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }