Compare commits

...

8 Commits

Author SHA1 Message Date
a13af83655 Adjust skills
All checks were successful
Build and Push / build (push) Successful in 1m31s
2026-05-11 21:25:09 +02:00
b58bdadad4 feat(openclaw): per-tenant tag override + platform default ConfigMap (tag-only)
All checks were successful
Build and Push / build (push) Successful in 1m52s
2026-05-10 21:15:53 +02:00
d375a099f0 Limit by tenant and org
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-02 23:43:02 +02:00
666dd64580 Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m33s
2026-05-02 23:25:24 +02:00
188bef2ece Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-02 23:16:14 +02:00
57258bca92 Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m31s
2026-05-02 22:59:51 +02:00
c7ab4c6b4e Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-02 22:33:35 +02:00
b77dd04b15 EMail templates rework
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-02 22:03:19 +02:00
22 changed files with 1942 additions and 158 deletions

View File

@@ -0,0 +1,71 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listTenants, getOpenClawDefaults } from "@/lib/k8s";
import { OpenClawAdminPanel } from "@/components/admin/openclaw-admin-panel";
/**
* /admin/openclaw — platform-default OpenClaw image + per-tenant
* overrides table.
*
* Two sections:
* 1. Default — readable from `pieced-openclaw-config` ConfigMap.
* Editable via the same form. Empty fields show as "(unset)"
* and the operator falls back to its built-in default in that
* case (intentionally invisible to the portal — the binary's
* baked version moves with releases and we don't want the UI
* to claim a misleading "current default").
* 2. Tenant table — every tenant in the cluster with its current
* override (or "follows default"). Clicking a row opens a small
* inline editor.
*
* Authorization is gated server-side: `user.isPlatform` only. Any
* other user gets redirected to /dashboard.
*/
export default async function OpenClawAdminPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("openclawAdmin");
// Parallel fetch — defaults and tenants are independent.
const [defaults, tenants] = await Promise.all([
getOpenClawDefaults(),
listTenants(),
]);
// Sort tenants: overridden first (more interesting to review),
// then alphabetically by display name. Helps the admin spot which
// tenants are off the platform default at a glance.
const sorted = [...tenants].sort((a, b) => {
const aOverride = a.spec.openClawImage ? 1 : 0;
const bOverride = b.spec.openClawImage ? 1 : 0;
if (aOverride !== bOverride) return bOverride - aOverride;
return (a.spec.displayName || a.metadata.name).localeCompare(
b.spec.displayName || b.metadata.name
);
});
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
<OpenClawAdminPanel
initialDefaults={defaults}
tenants={sorted.map((tn) => ({
name: tn.metadata.name,
displayName: tn.spec.displayName || tn.metadata.name,
phase: tn.status?.phase ?? "Unknown",
override: tn.spec.openClawImage?.tag
? { tag: tn.spec.openClawImage.tag }
: null,
}))}
/>
</main>
);
}

View File

@@ -22,11 +22,22 @@ export default async function AdminPage() {
return (
<div>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
<div className="mb-8 animate-in flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
</div>
{/* Sub-tools: links to other admin pages. Plain links rather
than nav-shell entries — these are platform-team utilities,
not main navigation. */}
<a
href="/admin/openclaw"
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("openclawTool")}
</a>
</div>
<div className="animate-in animate-in-delay-1">

View File

@@ -13,8 +13,15 @@ import { ChannelUsers } from "@/components/channel-users/channel-users";
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
import { formatDateTime, formatRelative } from "@/lib/format";
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
// CHANNEL_PACKAGES used to be a hardcoded literal here
// (`["telegram", "discord", "email"]`). It now derives from the
// portal-side catalog so adding a new channel anywhere only requires
// editing src/lib/packages.ts. The `email` channel was dropped as
// part of the Phase A package-model rework — IMAP/SMTP is now the
// `mail` skill instead.
const CHANNEL_PACKAGES = CHANNEL_PACKAGE_IDS;
export default async function TenantDetailPage({
params,
@@ -199,7 +206,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")}
</h2>
<UsageDisplay tenant={name} />
<UsageDisplay tenant={name} canEditBudget={canEdit} />
</section>
{/* Packages */}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { getOpenClawDefaults, setOpenClawDefaults } from "@/lib/k8s";
import { safeError } from "@/lib/errors";
/**
* Platform-wide default OpenClaw image tag (admin-only).
*
* GET — read the current default tag from the
* `pieced-openclaw-config` ConfigMap. Can be empty string if no
* default is configured; the operator uses its built-in fallback
* in that case.
*
* PATCH — update the tag. Send "" to clear. The operator watches
* this ConfigMap and re-enqueues all tenants without a per-tenant
* override on change, so existing tenants roll forward to the new
* default automatically. Tenants WITH an override are unaffected.
*
* Tag-only by design — see operator notes.
*/
const patchSchema = z.object({
defaultTag: z.string().trim().max(256),
});
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!user.isPlatform) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
return NextResponse.json(await getOpenClawDefaults());
} catch (e: any) {
console.error("Failed to read openclaw defaults:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to read defaults") },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!user.isPlatform) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const next = await setOpenClawDefaults({
defaultTag: parsed.data.defaultTag,
});
return NextResponse.json(next);
} catch (e: any) {
console.error("Failed to update openclaw defaults:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update defaults") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { safeError } from "@/lib/errors";
/**
* Per-tenant OpenClaw image override (admin-only).
*
* Why admin-only: customers cannot pick OpenClaw versions. This
* exists so the platform team can A/B-test new releases on specific
* tenants without rolling them out fleet-wide. The endpoint enforces
* `user.isPlatform`; even owners of the tenant's org cannot use it.
*
* PATCH body shapes:
* - { tag: "2026.4.22" } → use this tag
* - { tag: "" } or empty body → clear override (revert to platform
* default)
*
* Tag-only by design — see operator notes for rationale.
*/
const patchSchema = z.object({
tag: z.string().trim().max(256).optional(),
});
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!user.isPlatform) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(body ?? {});
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const tag = parsed.data.tag ?? "";
const isClearing = tag === "";
// Merge-patch semantics: openClawImage: null removes the field
// from the spec; openClawImage: { tag } sets it.
const spec: any = isClearing
? { openClawImage: null }
: { openClawImage: { tag } };
try {
const updated = await patchTenantSpec(name, spec);
return NextResponse.json({
message: isClearing
? "Override cleared; tenant follows platform default."
: "Override set.",
openClawImage: updated.spec.openClawImage ?? null,
});
} catch (e: any) {
console.error("Failed to set tenant openclaw image:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update tenant image") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { findKeyByAlias, updateKeyBudget } from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
* Update the per-tenant budget — operates on the LiteLLM virtual
* key, NOT on the team.
*
* Why per-key
* -----------
* Each tenant in an org has its own virtual key
* (`key_alias = tenant.metadata.name`); the team that owns those
* keys is org-scoped and shared across all the org's tenants. A
* budget on the team would cap the whole org; a budget on the key
* caps just this one tenant. Customers landing on the tenant detail
* page reasonably expect "edit budget" to mean "the budget of THIS
* tenant" — so we put it on the key.
*
* The team-level (org-wide) budget is a separate control that lives
* in /settings (not yet implemented) — the two coexist: LiteLLM
* applies whichever cap is hit first.
*
* Schema:
* - maxBudget: number > 0 (set a cap), or null (remove the cap).
* - budgetDuration: one of "30d", "1mo", "1y", or null (lifetime).
*
* Authorization: owners and platform admins.
*/
const patchSchema = z.object({
// > 0 because LiteLLM rejects 0 and a zero cap would lock the key
// out instantly. Upper bound 1M as a typo guard.
maxBudget: z.number().positive().max(1_000_000).nullable(),
budgetDuration: z.enum(["30d", "1mo", "1y"]).nullable(),
});
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ name: 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 { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!(await canUserSeeTenant(user, tenant))) {
// Don't leak existence — same 404 a non-visible tenant gets.
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const teamId = tenant.status?.litellmTeamId;
if (!teamId) {
return NextResponse.json(
{
error:
"Tenant has no LiteLLM team yet. Please wait until provisioning completes.",
},
{ status: 409 }
);
}
const body = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
// Defensive: removing the cap should null out the duration too —
// a reset cadence on an unlimited budget is meaningless and would
// confuse LiteLLM's bookkeeping.
const maxBudget = parsed.data.maxBudget;
const budgetDuration =
maxBudget === null ? null : parsed.data.budgetDuration;
// Look up the key by alias (= tenant name). The token returned is
// what /key/update wants in the `key` field.
let keyInfo;
try {
keyInfo = await findKeyByAlias(teamId, name);
} catch (e: any) {
console.error("Failed to look up tenant key:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to look up tenant key") },
{ status: 500 }
);
}
if (!keyInfo) {
return NextResponse.json(
{
error:
"Tenant has no virtual key yet. Please wait until provisioning completes.",
},
{ status: 409 }
);
}
try {
await updateKeyBudget(keyInfo.token, { maxBudget, budgetDuration });
return NextResponse.json({
message: maxBudget === null ? "Budget removed." : "Budget updated.",
maxBudget,
budgetDuration,
});
} catch (e: any) {
console.error("Failed to update key budget:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update budget") },
{ status: 500 }
);
}
}

View File

@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { listVisibleTenants } from "@/lib/visibility";
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
import {
getTeamInfo,
getTeamSpendLogsV2,
findKeyByAlias,
} from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
@@ -126,6 +130,16 @@ export async function GET(req: NextRequest) {
try {
const teamInfo = await getTeamInfo(teamId);
// Per-tenant budget lives on the virtual key, not the team
// (Feature 7 fix). When the request is scoped to a specific
// tenant (keyAlias provided), look up the key so we can return
// the per-tenant cap. Tolerate failure — older LiteLLM builds
// or short-lived race conditions during provisioning shouldn't
// 500 the whole usage page; we degrade to "no key info".
const keyInfo = keyAlias
? await findKeyByAlias(teamId, keyAlias).catch(() => null)
: null;
// Page through results — server-side filtered by key_alias when
// provided. Pagination still needed because LiteLLM caps
// page_size at 100, and a busy tenant can easily exceed that in
@@ -191,17 +205,38 @@ export async function GET(req: NextRequest) {
totalSpend,
requestCount: allRequests.length,
},
// Budget is always team-level (= company budget). Spend reported
// 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 "how much has this one tenant cost".
budget: {
maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.team_info?.spend ?? 0,
remaining: teamInfo?.team_info?.max_budget
? teamInfo.team_info.max_budget - (teamInfo.team_info.spend ?? 0)
: null,
},
// Budget reporting (Feature 7).
//
// When the caller scopes to a specific tenant (keyAlias set),
// we report THAT tenant's per-key budget — that's what the
// tenant detail page renders, and what the customer expects
// when they see "Budget" on a tenant's page.
//
// When unscoped (admin / org-wide view), we fall back to the
// team budget — that's the org-wide cap, conceptually different
// but the only thing meaningful at that scope.
//
// The two cases display the same way; the editor button gates
// on whether we know which tenant we're on (= keyAlias set).
budget: keyAlias && keyInfo
? {
maxBudget: keyInfo.maxBudget,
spend: keyInfo.spend,
remaining:
keyInfo.maxBudget !== null
? keyInfo.maxBudget - keyInfo.spend
: null,
budgetDuration: keyInfo.budgetDuration,
}
: {
maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.team_info?.spend ?? 0,
remaining: teamInfo?.team_info?.max_budget
? teamInfo.team_info.max_budget -
(teamInfo.team_info.spend ?? 0)
: null,
budgetDuration: teamInfo?.team_info?.budget_duration ?? null,
},
rateLimits: {
rpm: teamInfo?.team_info?.rpm_limit ?? null,
tpm: teamInfo?.team_info?.tpm_limit ?? null,

View File

@@ -0,0 +1,277 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { OpenClawDefaults } from "@/lib/k8s";
interface TenantRow {
name: string;
displayName: string;
phase: string;
override: { tag: string } | null;
}
interface Props {
initialDefaults: OpenClawDefaults;
tenants: TenantRow[];
}
/**
* Two-section admin UI:
* - Default editor card at the top — single input for the tag.
* - Tenant table below — each row has an inline edit/clear control.
*
* No optimistic updates: every save round-trips to the API and we
* router.refresh() to re-render the server-side state. Keeps the UI
* honest about what's actually applied (controller-runtime watch
* latency can be a couple of seconds).
*
* Tag-only by design — see operator notes for rationale.
*/
export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
const t = useTranslations("openclawAdmin");
const tCommon = useTranslations("common");
const router = useRouter();
const [defaults, setDefaults] = useState(initialDefaults);
const [defaultTag, setDefaultTag] = useState(initialDefaults.defaultTag);
const [savingDefault, setSavingDefault] = useState(false);
const [defaultError, setDefaultError] = useState("");
const [defaultSaved, setDefaultSaved] = useState(false);
const onSaveDefault = async (e: React.FormEvent) => {
e.preventDefault();
setSavingDefault(true);
setDefaultError("");
setDefaultSaved(false);
try {
const res = await fetch("/api/admin/openclaw", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaultTag: defaultTag.trim() }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("saveFailed"));
}
const next = await res.json();
setDefaults(next);
setDefaultSaved(true);
} catch (e: any) {
setDefaultError(e.message);
} finally {
setSavingDefault(false);
}
};
return (
<div className="space-y-8">
{/* Default editor */}
<section className="animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("defaultSection")}
</h2>
<Card>
<p className="text-sm text-text-secondary mb-4">
{t("defaultDescription")}
</p>
<form onSubmit={onSaveDefault} className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldTag")}
</label>
<input
type="text"
value={defaultTag}
onChange={(e) => setDefaultTag(e.target.value)}
placeholder="2026.4.22"
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
/>
<p className="text-xs text-text-muted mt-1">{t("emptyHint")}</p>
</div>
{defaultError && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{defaultError}
</div>
)}
{defaultSaved && !defaultError && (
<div className="text-xs text-success bg-success/10 border border-success/20 rounded-lg px-3 py-2">
{t("defaultSaved")}
</div>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={savingDefault}
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{savingDefault ? tCommon("loading") : t("saveDefault")}
</button>
</div>
</form>
</Card>
</section>
{/* Tenant overrides */}
<section className="animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("overridesSection")}
</h2>
<Card>
{tenants.length === 0 ? (
<p className="text-sm text-text-secondary text-center py-6">
{t("noTenants")}
</p>
) : (
<div className="space-y-2">
{tenants.map((tn) => (
<TenantOverrideRow
key={tn.name}
tenant={tn}
platformDefault={defaults}
onChanged={() => router.refresh()}
/>
))}
</div>
)}
</Card>
</section>
</div>
);
}
/**
* Single row in the tenants table. Collapsed by default; click to
* expand the inline editor.
*/
function TenantOverrideRow({
tenant,
platformDefault,
onChanged,
}: {
tenant: TenantRow;
platformDefault: OpenClawDefaults;
onChanged: () => void;
}) {
const t = useTranslations("openclawAdmin");
const tCommon = useTranslations("common");
const [expanded, setExpanded] = useState(false);
const [tag, setTag] = useState(tenant.override?.tag ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const submit = async (clear = false) => {
setSaving(true);
setError("");
try {
const res = await fetch(
`/api/admin/tenants/${encodeURIComponent(tenant.name)}/openclaw-image`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(clear ? {} : { tag: tag.trim() }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("saveFailed"));
}
setExpanded(false);
onChanged();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
};
const effective = tenant.override?.tag
? tenant.override.tag
: platformDefault.defaultTag || t("builtinFallback");
return (
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-surface-1 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="font-medium text-text-primary truncate">
{tenant.displayName}
</div>
<div className="text-xs text-text-muted font-mono truncate mt-0.5">
{tenant.name}
</div>
</div>
<div className="text-right ml-4 min-w-0">
{tenant.override ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-amber-400/15 text-amber-400 border border-amber-400/20">
{t("statusOverridden")}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-400/15 text-blue-400 border border-blue-400/20">
{t("statusFollowsDefault")}
</span>
)}
<div className="text-xs text-text-muted font-mono truncate max-w-[260px] mt-1">
{effective}
</div>
</div>
</button>
{expanded && (
<div className="px-4 pb-4 pt-1 border-t border-border bg-surface-1">
<div className="mb-3">
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldTag")}
</label>
<input
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
placeholder={
platformDefault.defaultTag
? `${t("defaultPrefix")} ${platformDefault.defaultTag}`
: ""
}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
/>
</div>
{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">
{error}
</div>
)}
<div className="flex flex-wrap gap-2 justify-end">
{tenant.override && (
<button
type="button"
onClick={() => submit(true)}
disabled={saving}
className="text-xs px-3 py-1.5 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
>
{saving ? tCommon("loading") : t("clearOverride")}
</button>
)}
<button
type="button"
onClick={() => submit(false)}
disabled={saving || !tag.trim()}
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{saving ? tCommon("loading") : t("saveOverride")}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -8,7 +8,10 @@ import { useRouter } from "next/navigation";
const CHANNEL_ID_HELP: Record<string, string> = {
telegram: "telegramIdHelp",
discord: "discordIdHelp",
email: "emailIdHelp",
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
// the `mail` skill (category=skill, not channel), so it never appears
// in `enabledChannels`. If a future channel is added to the catalog,
// give it an entry here so the help blurb renders.
};
interface ChannelUsersProps {

View File

@@ -0,0 +1,275 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Modal } from "@/components/ui/modal";
/**
* Format remaining budget as CHF. Same adaptive precision rule as the
* usage display: 2 decimals for amounts ≥ 1, 4 for smaller values
* so per-request residuals don't round to zero. The currency comes
* from LiteLLM via our CHF pricing config — see chf() in
* usage-display.tsx for the full reasoning.
*/
function formatRemaining(n: number): string {
const decimals = Math.abs(n) >= 1 ? 2 : 4;
return `CHF ${n.toFixed(decimals)}`;
}
interface Props {
tenantName: string;
maxBudget: number | null;
remaining: number | null;
budgetDuration: string | null;
/** Called after a successful save so the parent re-fetches usage. */
onSaved: () => void;
}
/**
* Clickable Budget StatCard with edit modal (Feature 7).
*
* The display side mirrors the read-only StatCard layout exactly so
* the grid stays uniform. The "click to edit" hint is implicit via
* hover state — a "Set" / "Edit" link in the corner would be louder
* but adds clutter on a tile that's already busy. Customers who
* mouse over discover it.
*
* Important UX note shown in the modal: the budget is org-scoped,
* not per-tenant. All tenants in the same ZITADEL org share the
* underlying LiteLLM team. Without that callout, a customer with
* multiple tenants might think they're capping just one.
*/
export function BudgetEditableCard({
tenantName,
maxBudget,
remaining,
budgetDuration,
onSaved,
}: Props) {
const t = useTranslations("usage");
const tCommon = useTranslations("common");
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
// Form state. Mode = "unlimited" | "capped". When unlimited, the
// duration dropdown is hidden because LiteLLM's reset cadence is
// meaningless without a cap.
const [mode, setMode] = useState<"unlimited" | "capped">(
maxBudget !== null ? "capped" : "unlimited"
);
const [budgetInput, setBudgetInput] = useState<string>(
maxBudget !== null ? String(maxBudget) : ""
);
const [duration, setDuration] = useState<"30d" | "1mo" | "1y">(
(budgetDuration === "30d" ||
budgetDuration === "1mo" ||
budgetDuration === "1y")
? budgetDuration
: "1mo"
);
// Reset form when modal opens — picks up any change made elsewhere
// (e.g. another browser tab) since this card was last re-rendered.
useEffect(() => {
if (open) {
setMode(maxBudget !== null ? "capped" : "unlimited");
setBudgetInput(maxBudget !== null ? String(maxBudget) : "");
setDuration(
(budgetDuration === "30d" ||
budgetDuration === "1mo" ||
budgetDuration === "1y")
? budgetDuration
: "1mo"
);
setError("");
}
}, [open, maxBudget, budgetDuration]);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
try {
let body: { maxBudget: number | null; budgetDuration: string | null };
if (mode === "unlimited") {
body = { maxBudget: null, budgetDuration: null };
} else {
const parsed = parseFloat(budgetInput);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(t("budgetInvalid"));
}
body = { maxBudget: parsed, budgetDuration: duration };
}
const res = await fetch(
`/api/tenants/${encodeURIComponent(tenantName)}/budget`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("budgetSaveFailed"));
}
setOpen(false);
onSaved();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
};
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="bg-surface-1 border border-accent/40 rounded-xl p-4 text-left hover:border-accent transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 group block w-full"
>
<div className="text-xs text-text-muted mb-1 flex items-center justify-between">
<span>{t("budget")}</span>
<span className="text-[10px] text-accent inline-flex items-center gap-1">
{/* Pencil icon — unambiguous "this is editable" affordance.
Visible at all times (was hover-only before, which on
touch devices and at-a-glance scanning gave no
indication the card was clickable). */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
{t("budgetEdit")}
</span>
</div>
<div className="text-lg font-semibold text-text-primary tabular-nums">
{remaining !== null ? formatRemaining(remaining) : t("noLimit")}
</div>
</button>
<Modal open={open} onClose={() => setOpen(false)} ariaLabel={t("budgetEditTitle")}>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("budgetEditTitle")}
</h3>
<p className="text-sm text-text-secondary mb-5">
{t("budgetEditDescription")}
</p>
<form onSubmit={onSubmit} className="space-y-4">
{/* Mode toggle: unlimited vs capped. Two radios are
clearer than a single "max" field where 0 means
unlimited (which would conflict with our zod
validation requiring positive). */}
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm text-text-primary cursor-pointer">
<input
type="radio"
name="budget-mode"
checked={mode === "unlimited"}
onChange={() => setMode("unlimited")}
className="mt-1"
/>
<span>
<span className="font-medium">{t("budgetModeUnlimited")}</span>
<span className="block text-xs text-text-muted">
{t("budgetModeUnlimitedDescription")}
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm text-text-primary cursor-pointer">
<input
type="radio"
name="budget-mode"
checked={mode === "capped"}
onChange={() => setMode("capped")}
className="mt-1"
/>
<span>
<span className="font-medium">{t("budgetModeCapped")}</span>
<span className="block text-xs text-text-muted">
{t("budgetModeCappedDescription")}
</span>
</span>
</label>
</div>
{mode === "capped" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("budgetAmount")} <span className="text-red-400">*</span>
</label>
<div className="relative">
<span className="absolute left-3 top-2 text-sm text-text-muted font-medium">
CHF
</span>
<input
type="number"
min="0.01"
max="1000000"
step="0.01"
required
value={budgetInput}
onChange={(e) => setBudgetInput(e.target.value)}
className="w-full pl-12 pr-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
/>
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("budgetResetCadence")}
</label>
<select
value={duration}
onChange={(e) =>
setDuration(e.target.value as "30d" | "1mo" | "1y")
}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm focus:outline-none focus:border-text-secondary"
>
<option value="30d">{t("budgetCadence_30d")}</option>
<option value="1mo">{t("budgetCadence_1mo")}</option>
<option value="1y">{t("budgetCadence_1y")}</option>
</select>
</div>
</div>
)}
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setOpen(false)}
disabled={saving}
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="submit"
disabled={saving}
className="text-sm px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{saving ? tCommon("loading") : tCommon("save")}
</button>
</div>
</form>
</Modal>
</>
);
}

View File

@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { useEffect, useState, useCallback } from "react";
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
interface DailyUsage {
date: string;
@@ -18,7 +19,17 @@ interface UsageData {
totalSpend: number;
requestCount: number;
};
budget: { maxBudget: number | null; spend: number; remaining: number | null };
budget: {
maxBudget: number | null;
spend: number;
remaining: number | null;
/**
* Feature 7: budget reset cadence as stored on LiteLLM.
* Strings: "30d" / "1mo" / "1y" / null (no reset). UI maps these
* to user-friendly labels.
*/
budgetDuration: string | null;
};
rateLimits: { rpm: number | null; tpm: number | null };
dailyUsage: DailyUsage[];
}
@@ -29,8 +40,31 @@ function fmt(n: number): string {
return n.toString();
}
function usd(n: number): string {
return `$${n.toFixed(4)}`;
/**
* Format a numeric amount as CHF.
*
* Note on currency labelling: LiteLLM stores raw cost numbers it
* receives from upstream (OpenAI/Anthropic), which originate as USD.
* The PieCed pricing config (Slice 5) converts those numbers to
* CHF before LiteLLM persists them, so the values flowing through
* here are already CHF amounts. We label them as such in the UI;
* "USD" or "$" anywhere in the customer-facing experience would
* be misleading.
*
* Precision is adaptive:
* - Amounts ≥ 1 CHF: 2 decimals (typical money formatting).
* - Smaller amounts: 4 decimals — per-request inference costs are
* routinely sub-rappen, and rounding to 2dp
* would render CHF 0.0042 as "CHF 0.00",
* which obscures real costs from customers
* looking at the daily breakdown.
*
* This is a customer-facing display helper; for storage and
* comparisons keep using the raw number.
*/
function chf(n: number): string {
const decimals = Math.abs(n) >= 1 ? 2 : 4;
return `CHF ${n.toFixed(decimals)}`;
}
function getCurrentMonth(): string {
@@ -69,7 +103,7 @@ function UsageChart({ data }: { data: DailyUsage[] }) {
const x = i * (barW + 2);
return (
<g key={d.date}>
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out {usd(d.spend)}</title>
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out {chf(d.spend)}</title>
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
{i % 7 === 0 && (
@@ -113,10 +147,18 @@ export function UsageDisplay({
tenant,
teamId,
keyAlias,
canEditBudget = false,
}: {
tenant?: string | null;
teamId?: string | null;
keyAlias?: string | null;
/**
* Feature 7: when true, the Budget StatCard becomes clickable and
* opens the budget editor. Off by default — owners and platform
* admins get it on; `user` role customers see the budget read-only.
* Server component decides this via canMutate(user).
*/
canEditBudget?: boolean;
}) {
const t = useTranslations("usage");
const [month, setMonth] = useState(getCurrentMonth);
@@ -185,11 +227,25 @@ export function UsageDisplay({
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label={t("inputTokens")} value={fmt(data.currentPeriod.inputTokens)} />
<StatCard label={t("outputTokens")} value={fmt(data.currentPeriod.outputTokens)} />
<StatCard label={t("totalSpend")} value={usd(data.currentPeriod.totalSpend)} accent />
<StatCard
label={t("budget")}
value={data.budget.remaining !== null ? usd(data.budget.remaining) : t("noLimit")}
/>
<StatCard label={t("totalSpend")} value={chf(data.currentPeriod.totalSpend)} accent />
{canEditBudget && tenant ? (
<BudgetEditableCard
tenantName={tenant}
maxBudget={data.budget.maxBudget}
remaining={data.budget.remaining}
budgetDuration={data.budget.budgetDuration}
onSaved={fetchUsage}
/>
) : (
<StatCard
label={t("budget")}
value={
data.budget.remaining !== null
? chf(data.budget.remaining)
: t("noLimit")
}
/>
)}
</div>
<div className="bg-surface-1 border border-border rounded-xl p-5">

View File

@@ -3,7 +3,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
import {
configureStepSchema,
@@ -69,6 +69,7 @@ translation, and general question answering.
`;
const CATEGORIES = [
{ key: "core" as const, labelKey: "categories.core" },
{ key: "channel" as const, labelKey: "categories.channels" },
{ key: "skill" as const, labelKey: "categories.skills" },
] as const;
@@ -198,7 +199,11 @@ export function OnboardingWizard({
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS,
packages: [] as string[],
// CORE defaults: heartbeat + cron pre-selected so the assistant
// can be proactive and run scheduled tasks out of the box.
// Customers can untoggle either before submitting. core-voice
// stays unselected — its toggle is disabled until Phase B.
packages: [...DEFAULT_PACKAGE_IDS] as string[],
billingAddress: {
// For personal accounts, leave the company field empty — it'll
// appear on invoices. The user can still type something if they
@@ -691,7 +696,7 @@ export function OnboardingWizard({
<button
type="button"
onClick={() => togglePackage(pkg.id)}
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-surface-3/30 transition-colors"
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors cursor-pointer hover:bg-surface-3/30"
>
<div className="text-left">
<span

View File

@@ -15,6 +15,7 @@ interface Props {
}
const CATEGORIES = [
{ key: "core" as const, labelKey: "categories.core" },
{ key: "channel" as const, labelKey: "categories.channels" },
{ key: "skill" as const, labelKey: "categories.skills" },
] as const;

View File

@@ -11,6 +11,17 @@
* SMTP_PASS — App Password
* SMTP_FROM — e.g. "PieCed <noreply@pieced.ch>"
* ADMIN_NOTIFICATION_EMAIL — e.g. admin@pieced.ch (optional)
* SUPPORT_CONTACT_EMAIL — e.g. support@pieced.ch (optional)
* Customer-facing address for "have
* questions?" follow-ups in
* transactional emails. The from
* address itself (SMTP_USER) is
* typically a noreply mailbox, so we
* don't tell customers to "reply to
* this email" — instead we point them
* at this monitored address. If
* unset, the contact-prompt line is
* simply omitted from emails.
*/
import nodemailer from "nodemailer";
@@ -42,6 +53,12 @@ function getFrom(): string {
);
}
/** Returns the customer-facing support email address, or null if unset. */
function getSupportContactEmail(): string | null {
const v = process.env.SUPPORT_CONTACT_EMAIL?.trim();
return v && v.length > 0 ? v : null;
}
/**
* Escape HTML entities to prevent injection in HTML emails.
*/
@@ -125,6 +142,21 @@ export async function sendRejectionEmail(
</div>`
: "";
const supportEmail = getSupportContactEmail();
// The customer here is rejected pre-onboarding — they don't yet
// have a portal account, so we can't send them to /support.
// Instead point at the configured support address (if set).
// If unset (e.g. early pilot before a support inbox exists), we
// omit the follow-up line entirely rather than promise something
// that goes nowhere — telling the customer to "reply to this
// email" would be misleading because we send from a noreply box.
const contactLineText = supportEmail
? `If you have questions or would like to discuss this further, please contact us at ${supportEmail}.`
: "";
const contactLineHtml = supportEmail
? `<p>If you have questions or would like to discuss this further, please contact us at <a href="mailto:${escapeHtml(supportEmail)}" style="color: #3b82f6;">${escapeHtml(supportEmail)}</a>.</p>`
: "";
await getTransporter().sendMail({
from: getFrom(),
to,
@@ -134,18 +166,20 @@ export async function sendRejectionEmail(
"",
`Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for ${companyName} at this time.`,
notesBlock,
"If you have questions or would like to discuss this further, please reply to this email.",
contactLineText,
"",
"Best regards,",
"PieCed IT",
].join("\n"),
]
.filter((s) => s !== "")
.join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 560px; margin: 0 auto; color: #e0e0e0; background: #1a1a1a; padding: 32px; border-radius: 12px;">
<h2 style="color: #ffffff; margin-top: 0;">Update on your onboarding request</h2>
<p>Hello ${safeName},</p>
<p>Thank you for your interest in PieCed IT. Unfortunately, we were unable to approve your onboarding request for <strong>${safeCompany}</strong> at this time.</p>
${notesHtml}
<p>If you have questions or would like to discuss this further, please reply to this email.</p>
${contactLineHtml}
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>
@@ -237,6 +271,15 @@ export async function sendResumeRejectionEmail(
</div>`
: "";
// The customer has portal access (their tenant exists, they
// just had a resume request rejected), so direct them to the
// support ticket system for follow-up. We never tell them to
// "reply to this email" because the from address is a noreply
// mailbox.
const contactLineText =
"If you have questions, open a support ticket at https://app.pieced.ch/support.";
const contactLineHtml = `<p>If you have questions, <a href="https://app.pieced.ch/support" style="color: #3b82f6;">open a support ticket</a>.</p>`;
await getTransporter().sendMail({
from: getFrom(),
to,
@@ -248,7 +291,7 @@ export async function sendResumeRejectionEmail(
notesBlock,
"Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.",
"",
"If you have questions, please reply to this email.",
contactLineText,
"",
"Best regards,",
"PieCed IT",
@@ -260,7 +303,7 @@ export async function sendResumeRejectionEmail(
<p>Thank you for your reactivation request for <strong>${safeCompany}</strong>. Unfortunately, we were unable to approve it at this time.</p>
${notesHtml}
<p>Your tenant remains suspended. As a reminder, your data is preserved for 60 days from the original cancellation date, after which it will be permanently deleted. You can submit a new reactivation request at any time before then.</p>
<p>If you have questions, please reply to this email.</p>
${contactLineHtml}
<hr style="border: none; border-top: 1px solid #333; margin: 24px 0;" />
<p style="color: #666; font-size: 12px;">PieCed IT — Hosted on-premises in Switzerland</p>
</div>

View File

@@ -173,3 +173,115 @@ export async function setTenantAnnotation(
}
return res.json() as Promise<PiecedTenant>;
}
// ---------------------------------------------------------------------------
// OpenClaw config ConfigMap helpers (admin-only feature: per-tenant version
// override + platform default).
//
// The ConfigMap lives in the operator's namespace (`pieced-system`). The
// portal's ServiceAccount needs `get/patch` on configmaps in that namespace
// — rules added in the gitops repo.
//
// Tag-only by design — see operator notes for rationale.
// ---------------------------------------------------------------------------
const OPENCLAW_CONFIGMAP_NAME = "pieced-openclaw-config";
/**
* Operator namespace. Reads the env var so the portal can be deployed in
* non-default namespaces without code changes; defaults to "pieced-system"
* matching the operator's chart default.
*/
function getOperatorNamespace(): string {
return process.env.OPERATOR_NAMESPACE ?? "pieced-system";
}
export interface OpenClawDefaults {
/** Image tag (e.g. "2026.4.22"). Empty string means unset. */
defaultTag: string;
}
/**
* Read the platform-default OpenClaw image tag. Returns empty string
* if unset, and `{ defaultTag: "" }` if the ConfigMap doesn't exist yet
* — the operator's built-in fallback is invisible to the portal by
* design (we don't want the UI to claim "current default: 2026.x" when
* it's actually the operator binary's baked-in version; that would be
* misleading once the binary updates).
*/
export async function getOpenClawDefaults(): Promise<OpenClawDefaults> {
const ns = getOperatorNamespace();
const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`;
const res = await fetch(url, {
headers: { Accept: "application/json", ...getAuthHeaders() },
});
if (res.status === 404) {
return { defaultTag: "" };
}
if (!res.ok) {
const text = await res.text();
const err = new Error(
`K8s GET configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}`
);
(err as any).statusCode = res.status;
throw err;
}
const cm = (await res.json()) as { data?: Record<string, string> };
return { defaultTag: cm.data?.defaultTag ?? "" };
}
/**
* Update the platform-default OpenClaw image tag. Empty string clears
* the field (operator falls back to its built-in default).
*
* Creates the ConfigMap if it doesn't exist (PATCH on missing resource
* 404s; we retry as POST). Keeps the admin UI usable on a fresh install
* where the helm-shipped CM was deleted or never created.
*/
export async function setOpenClawDefaults(
defaults: OpenClawDefaults
): Promise<OpenClawDefaults> {
const ns = getOperatorNamespace();
const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`;
const patch = { data: { defaultTag: defaults.defaultTag } };
const res = await fetch(url, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/merge-patch+json",
...getAuthHeaders(),
},
body: JSON.stringify(patch),
});
if (res.status === 404) {
const createUrl = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps`;
const createRes = await fetch(createUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...getAuthHeaders(),
},
body: JSON.stringify({
apiVersion: "v1",
kind: "ConfigMap",
metadata: { name: OPENCLAW_CONFIGMAP_NAME, namespace: ns },
data: patch.data,
}),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new Error(
`K8s POST configmap ${OPENCLAW_CONFIGMAP_NAME}: ${createRes.status} ${text}`
);
}
return defaults;
}
if (!res.ok) {
const text = await res.text();
throw new Error(
`K8s PATCH configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}`
);
}
return defaults;
}

View File

@@ -93,6 +93,94 @@ export async function listTeams(): Promise<any[]> {
return Array.isArray(data) ? data : data?.data ?? data?.teams ?? [];
}
/**
* Find a virtual key on a team by its alias and return its current
* state (token, spend, budget cap, reset cadence). Returns null if
* the alias doesn't match any key on the team.
*
* Why we need this
* ----------------
* Per-tenant budgets live on the virtual key, not the team. The
* portal needs to:
* 1. Display the current key's `max_budget` / `budget_duration` /
* `spend` on the tenant detail page.
* 2. Pass the key's `token` to `/key/update` when the customer
* changes the budget.
*
* The token is opaque to the customer; the operator's
* `FindKeyByAlias` does the same lookup for stale-key cleanup. We
* mirror its API call here.
*/
export async function findKeyByAlias(
teamId: string,
keyAlias: string
): Promise<{
token: string;
spend: number;
maxBudget: number | null;
budgetDuration: string | null;
} | null> {
const data = await litellmFetch(
`/key/list?team_id=${encodeURIComponent(teamId)}&return_full_object=true&include_team_keys=true`
);
const keys: any[] = Array.isArray(data?.keys)
? data.keys
: Array.isArray(data?.data)
? data.data
: Array.isArray(data)
? data
: [];
for (const k of keys) {
if (typeof k !== "object" || k === null) continue;
const alias = k.key_alias ?? k.keyAlias;
if (alias !== keyAlias) continue;
if (typeof k.token !== "string" || !k.token) continue;
return {
token: k.token,
spend: typeof k.spend === "number" ? k.spend : Number(k.spend) || 0,
maxBudget:
typeof k.max_budget === "number"
? k.max_budget
: k.max_budget == null
? null
: Number(k.max_budget) || null,
budgetDuration:
typeof k.budget_duration === "string" ? k.budget_duration : null,
};
}
return null;
}
/**
* Update a virtual key's budget cap and reset duration.
*
* Pass `maxBudget: null` to remove the cap. Pass `budgetDuration:
* null` to make the budget never reset (lifetime cap).
*
* Identified by `key` parameter — accepts either the raw `sk-...`
* token or its hash (LiteLLM accepts both shapes on /key/update).
* The portal flow uses the hash returned by `findKeyByAlias`.
*/
export async function updateKeyBudget(
key: string,
changes: {
maxBudget?: number | null;
budgetDuration?: string | null;
}
): Promise<void> {
const body: Record<string, any> = { key };
if (changes.maxBudget !== undefined) {
body.max_budget = changes.maxBudget;
}
if (changes.budgetDuration !== undefined) {
body.budget_duration = changes.budgetDuration;
}
await litellmFetch("/key/update", {
method: "POST",
body: JSON.stringify(body),
});
}
/**
* Get LiteLLM health status.
*/

View File

@@ -1,9 +1,32 @@
/**
* Portal-side package catalog. Hardcoded mirror of the operator-side
* catalog ConfigMap (deploy/helm/pieced-operator/templates/catalog-cm.yaml).
*
* The two have to stay in sync:
* - `id` here must match the catalog key in the ConfigMap.
* - `secrets[].key` here must match the catalog's env_vars[].secret_key
* so that POST /api/tenants/:name/secrets writes to the same OpenBao
* path the operator's ExternalSecret reads from.
* - `requiresSecrets` is true when the catalog declares any env_var
* that is a secret (vault_path_suffix set, no default value).
*
* Category model (Phase A rework):
* - core — platform-behaviour toggles (heartbeat, cron,
* active-memory, voice). Mostly no secrets. core-voice is
* a catalog stub in Phase A — toggling stores customer
* intent only; the OCI config_patch lands in Phase B.
* - channel — messaging integration.
* - skill — ClawHub skill install.
*/
export interface PackageSecretField {
key: string;
labelKey: string;
placeholderKey: string;
}
export type PackageCategory = "core" | "channel" | "skill";
export interface PackageDef {
id: string;
name: string;
@@ -12,10 +35,45 @@ export interface PackageDef {
secrets?: PackageSecretField[];
instructionsKey?: string;
disclaimerKey?: string;
category: "channel" | "skill";
category: PackageCategory;
}
export const PACKAGE_CATALOG: PackageDef[] = [
// -------------------------------------------------------------------------
// CORE
// -------------------------------------------------------------------------
{
id: "core-heartbeat",
name: "Heartbeat (Proactive Checks)",
descriptionKey: "packages.coreHeartbeat.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-cron",
name: "Scheduled Tasks (Cron)",
descriptionKey: "packages.coreCron.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-active-memory",
name: "Active Memory",
descriptionKey: "packages.coreActiveMemory.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-voice",
name: "Voice Interaction",
descriptionKey: "packages.coreVoice.description",
requiresSecrets: false,
category: "core",
},
// -------------------------------------------------------------------------
// CHANNELS
// -------------------------------------------------------------------------
{
id: "telegram",
name: "Telegram",
@@ -43,42 +101,181 @@ export const PACKAGE_CATALOG: PackageDef[] = [
labelKey: "packages.discord.botTokenLabel",
placeholderKey: "packages.discord.botTokenPlaceholder",
},
// app-id was missing from the portal catalog historically while the
// operator catalog declared DISCORD_APP_ID as a required env var.
// Tenants who enabled Discord ended up with the env var blank
// because the secrets POST never wrote an `app-id` key to OpenBao
// and the operator's ExternalSecret couldn't populate it. Added
// here as part of the Phase A rework to close the alignment gap;
// not strictly secret (the application ID is visible in the bot's
// profile URL) but stored alongside the bot token for convenience.
{
key: "app-id",
labelKey: "packages.discord.appIdLabel",
placeholderKey: "packages.discord.appIdPlaceholder",
},
],
instructionsKey: "packages.discord.instructions",
disclaimerKey: "packages.discord.disclaimer",
category: "channel",
},
// -------------------------------------------------------------------------
// SKILLS
// -------------------------------------------------------------------------
{
id: "email",
name: "Email",
descriptionKey: "packages.email.description",
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" },
],
instructionsKey: "packages.email.instructions",
disclaimerKey: "packages.email.disclaimer",
category: "channel",
},
{
id: "web-search",
name: "Web Search",
descriptionKey: "packages.webSearch.description",
id: "git-cli",
name: "Git CLI",
descriptionKey: "packages.gitCli.description",
requiresSecrets: false,
category: "skill",
},
{
id: "document-processing",
name: "Document Processing",
descriptionKey: "packages.documentProcessing.description",
id: "github",
name: "GitHub (gh CLI)",
descriptionKey: "packages.github.description",
requiresSecrets: true,
secrets: [
{
key: "token",
labelKey: "packages.github.tokenLabel",
placeholderKey: "packages.github.tokenPlaceholder",
},
],
instructionsKey: "packages.github.instructions",
category: "skill",
},
{
id: "gitea",
name: "Gitea",
descriptionKey: "packages.gitea.description",
requiresSecrets: true,
secrets: [
{
key: "token",
labelKey: "packages.gitea.tokenLabel",
placeholderKey: "packages.gitea.tokenPlaceholder",
},
],
instructionsKey: "packages.gitea.instructions",
category: "skill",
},
{
id: "whisper-self-hosted",
name: "Whisper (Self-Hosted Transcription)",
descriptionKey: "packages.whisperSelfHosted.description",
requiresSecrets: false,
category: "skill",
},
{
id: "searxng-local-search",
name: "Web Search (SearXNG)",
descriptionKey: "packages.searxngLocalSearch.description",
requiresSecrets: false,
category: "skill",
},
{
id: "gog",
name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description",
requiresSecrets: true,
secrets: [
{
key: "client-id",
labelKey: "packages.gog.clientIdLabel",
placeholderKey: "packages.gog.clientIdPlaceholder",
},
{
key: "client-secret",
labelKey: "packages.gog.clientSecretLabel",
placeholderKey: "packages.gog.clientSecretPlaceholder",
},
{
key: "refresh-token",
labelKey: "packages.gog.refreshTokenLabel",
placeholderKey: "packages.gog.refreshTokenPlaceholder",
},
],
instructionsKey: "packages.gog.instructions",
disclaimerKey: "packages.gog.disclaimer",
category: "skill",
},
{
id: "mail",
name: "Email (IMAP / SMTP)",
descriptionKey: "packages.mail.description",
requiresSecrets: true,
secrets: [
{
key: "imap-host",
labelKey: "packages.mail.imapHostLabel",
placeholderKey: "packages.mail.imapHostPlaceholder",
},
{
key: "imap-user",
labelKey: "packages.mail.imapUserLabel",
placeholderKey: "packages.mail.imapUserPlaceholder",
},
{
key: "imap-pass",
labelKey: "packages.mail.imapPassLabel",
placeholderKey: "packages.mail.imapPassPlaceholder",
},
{
key: "smtp-host",
labelKey: "packages.mail.smtpHostLabel",
placeholderKey: "packages.mail.smtpHostPlaceholder",
},
{
key: "smtp-user",
labelKey: "packages.mail.smtpUserLabel",
placeholderKey: "packages.mail.smtpUserPlaceholder",
},
{
key: "smtp-pass",
labelKey: "packages.mail.smtpPassLabel",
placeholderKey: "packages.mail.smtpPassPlaceholder",
},
],
instructionsKey: "packages.mail.instructions",
disclaimerKey: "packages.mail.disclaimer",
category: "skill",
},
];
export function getPackageDef(id: string): PackageDef | undefined {
return PACKAGE_CATALOG.find((p) => p.id === id);
}
/**
* IDs of channel-category packages. Derived from the catalog so it
* cannot drift from the source of truth (previously hardcoded as
* `["telegram", "discord", "email"]` in tenants/[name]/page.tsx —
* removed as part of the Phase A package-model rework).
*
* Consumers: tenant detail page (filter spec.packages to channel set
* before rendering the channel-users panel).
*/
export const CHANNEL_PACKAGE_IDS: string[] = PACKAGE_CATALOG
.filter((p) => p.category === "channel")
.map((p) => p.id);
/**
* Default packages selected when the wizard opens a fresh onboarding
* request. The three CORE behaviours that make the assistant feel
* "smart out of the box":
* - heartbeat: proactive checks (otherwise the assistant is purely
* reactive).
* - cron: scheduled tasks (daily briefings, reminders).
* - active-memory: long-term recall of stable preferences and habits.
*
* Each adds some token cost — active-memory the most (one extra
* sub-agent turn per inbound message) — so customers can untoggle any
* of them before submitting. core-voice is deliberately excluded from
* defaults until its config_patch lands in Phase B.
*/
export const DEFAULT_PACKAGE_IDS: string[] = [
"core-heartbeat",
"core-cron",
"core-active-memory",
];

View File

@@ -189,7 +189,21 @@
"last30Days": "Letzte 30 Tage",
"noData": "Keine Nutzungsdaten verfügbar.",
"dailyBreakdown": "Tagesübersicht",
"requests": "Anfragen"
"requests": "Anfragen",
"budgetEdit": "Bearbeiten",
"budgetEditTitle": "Budget festlegen",
"budgetEditDescription": "Begrenzen Sie, wie viel die Assistenten dieses Tenants ausgeben können, bevor Anfragen abgelehnt werden.",
"budgetModeUnlimited": "Kein Limit",
"budgetModeUnlimitedDescription": "Beliebige Ausgaben, kein Limit.",
"budgetModeCapped": "Limit festlegen",
"budgetModeCappedDescription": "Anfragen ablehnen, sobald die Ausgaben diesen Betrag erreichen.",
"budgetAmount": "Betrag",
"budgetResetCadence": "Zurücksetzen",
"budgetCadence_30d": "Alle 30 Tage",
"budgetCadence_1mo": "Monatlich",
"budgetCadence_1y": "Jährlich",
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen."
},
"workspace": {
"save": "Speichern",
@@ -200,7 +214,8 @@
"packages": {
"categories": {
"channels": "Kanäle",
"skills": "Fähigkeiten"
"skills": "Fähigkeiten",
"core": "Kern"
},
"enable": "Aktivieren",
"disable": "Deaktivieren",
@@ -225,29 +240,73 @@
"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",
"disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden."
},
"email": {
"description": "Ermöglichen Sie Ihrem KI-Assistenten, E-Mails zu senden und 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 SMTP- und IMAP-Zugangsdaten an. Der Assistent nutzt diese zum Senden und Empfangen von Nachrichten.",
"disclaimer": "Ich bestätige, dass ich berechtigt bin, diese E-Mail-Zugangsdaten zu verwenden und dass PieCed IT auf dieses Postfach zugreifen darf."
},
"webSearch": {
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web zu suchen."
},
"documentProcessing": {
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
"disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden.",
"appIdLabel": "Discord-Anwendungs-ID",
"appIdPlaceholder": "1819-stellige numerische ID aus dem Developer Portal"
},
"statusEnabled": "aktiviert",
"statusDisabled": "deaktiviert"
"statusDisabled": "deaktiviert",
"coreHeartbeat": {
"description": "Periodischer Agentenlauf alle 30 Minuten, der es dem Assistenten erlaubt, Posteingang, Kalender und andere konfigurierte Quellen zu prüfen und proaktiv Bescheid zu geben, wenn etwas Aufmerksamkeit braucht. Ohne diese Option reagiert der Assistent nur, wenn Sie ihn ansprechen."
},
"coreCron": {
"description": "Erlaubt dem Assistenten, geplante Aufgaben auszuführen (tägliche Briefings, wiederkehrende Erinnerungen, periodische Berichte). Standardmässig deaktiviert. Bei Deaktivierung bleibt das Cron-Werkzeug verfügbar, aber keine geplante Aufgabe wird ausgeführt."
},
"coreActiveMemory": {
"description": "Erlaubt dem Assistenten, stabile Präferenzen, wiederkehrende Gewohnheiten und langfristigen Kontext aus früheren Gesprächen abzurufen. Nutzt einen zusätzlichen Sub-Agent-Lauf pro eingehender Nachricht, um den Memory-Store abzufragen. Nur Direktnachrichten. Kleiner Mehraufwand an Tokens im Tausch gegen Kontinuität und Personalisierung."
},
"coreVoice": {
"description": "Spracherkennung für eingehende Sprachnachrichten und Sprachsynthese für Antworten, über das PieCed-LiteLLM-Gateway, damit Audiokosten pro Mandant erfasst werden. Die Laufzeit-Integration kommt im nächsten Plattform-Release; das Umschalten speichert die Auswahl für diese Auslieferung."
},
"gitCli": {
"description": "Eigenständige Git-Kommandozeilenoperationen (clone, commit, branch, diff, log, status). Für private Repositories konfigurieren Sie die Zugangsdaten in Ihrem Workspace."
},
"github": {
"description": "Interaktion mit GitHub-Repositories über die gh-CLI — Issues, Pull Requests, CI-Läufe, Releases, Gists. Erfordert ein persönliches Zugriffstoken.",
"tokenLabel": "GitHub Persönliches Zugriffstoken",
"tokenPlaceholder": "ghp_… oder github_pat_…",
"instructions": "1. Öffnen Sie https://github.com/settings/tokens\n2. Erstellen Sie ein fein abgestimmtes persönliches Zugriffstoken mit den gewünschten Repo-Berechtigungen\n3. Kopieren Sie das Token (es wird nur einmal angezeigt)"
},
"gitea": {
"description": "Interaktion mit einer Gitea-Instanz — Repositories, Issues, Pull Requests, Releases. Standardmässig die PieCed-Plattform-Gitea unter git.c5ai.ch.",
"tokenLabel": "Gitea-Zugriffstoken",
"tokenPlaceholder": "Erstellt unter Einstellungen → Anwendungen",
"instructions": "1. Melden Sie sich bei Ihrer Gitea-Instanz an (Standard https://git.c5ai.ch)\n2. Gehen Sie zu Einstellungen → Anwendungen → Neues Token erstellen\n3. Vergeben Sie die gewünschten Berechtigungen (repo, issue, user)\n4. Kopieren Sie das Token"
},
"whisperSelfHosted": {
"description": "Transkribieren Sie Audiodateien über die plattformeigene Whisper-Instanz. Nützlich für Ad-hoc-Transkriptionsaufgaben aus dem Chat heraus."
},
"searxngLocalSearch": {
"description": "Datenschutzfreundliche Web-Suche über die interne SearXNG-Instanz der Plattform. Durchsuchen Sie Web, Bilder und News ohne externe API-Aufrufe oder Tracker."
},
"gog": {
"description": "Gebündelter Zugriff auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte via Google OAuth. Setup erfordert ein Google-Cloud-Projekt — wenden Sie sich an den PieCed-Support für die Einrichtung.",
"clientIdLabel": "Google OAuth Client-ID",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Google OAuth Client-Secret",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh-Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Die Google-Workspace-Integration verwendet OAuth und erfordert derzeit manuelles Onboarding. Bitte eröffnen Sie ein Support-Ticket, um den Setup-Prozess zu starten — wir tauschen die Client-Zugangsdaten und ein Refresh-Token offline aus und aktivieren dann dieses Paket für Ihren Mandanten.",
"disclaimer": "Mit der Aktivierung der Google-Workspace-Integration autorisieren Sie PieCed, in Ihrem Namen auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte zuzugreifen. Daten fliessen über die Google-APIs, vorbehaltlich der Google-Bedingungen."
},
"mail": {
"description": "E-Mails über IMAP lesen, suchen und verwalten; senden über SMTP. Funktioniert mit Gmail (mit App-Passwort), Outlook, Fastmail und jedem standardkonformen IMAP/SMTP-Host.",
"imapHostLabel": "IMAP-Host",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "IMAP-Benutzername",
"imapUserPlaceholder": "benutzer@example.com",
"imapPassLabel": "IMAP-Passwort",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "SMTP-Host",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "SMTP-Benutzername",
"smtpUserPlaceholder": "benutzer@example.com",
"smtpPassLabel": "SMTP-Passwort",
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Für Gmail: Aktivieren Sie die 2-Faktor-Authentifizierung, erstellen Sie dann unter https://myaccount.google.com/apppasswords ein App-Passwort und verwenden Sie es als IMAP- und SMTP-Passwort.\n2. Für Outlook / Microsoft 365 mit MFA: Generieren Sie ein App-Passwort in den Sicherheitseinstellungen Ihres Kontos.\n3. Für andere Anbieter: Konsultieren Sie deren IMAP/SMTP-Dokumentation für Hostnamen und Ports.\n4. Typische IMAP-Hosts: imap.gmail.com, outlook.office365.com.\n5. Typische SMTP-Hosts: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "Der Assistent erhält Lese- und Schreibzugriff auf das von Ihnen konfigurierte Postfach. Verwenden Sie eine dedizierte Adresse anstelle eines persönlichen Postfachs, wenn Sie den Umfang einschränken möchten."
}
},
"admin": {
"title": "Plattform-Admin",
@@ -319,7 +378,8 @@
"statusDown": "Ausgefallen",
"spendChf": "Kosten (CHF)",
"resumeRequestBadge": "Wieder",
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft."
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
"openclawTool": "OpenClaw-Versionen"
},
"channelUsers": {
"title": "Autorisierte Benutzer",
@@ -330,8 +390,7 @@
"remove": "Entfernen",
"alreadyAdded": "Diese Benutzer-ID ist bereits autorisiert.",
"telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein",
"discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein",
"emailIdHelp": "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll."
"discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein"
},
"team": {
"title": "Team",
@@ -459,5 +518,24 @@
"resolvedBanner": "Dieses Ticket ist erledigt. Antworten Sie unten, falls Sie nachfragen möchten — das öffnet es erneut.",
"adminControlsTitle": "Admin-Steuerung",
"updateFailed": "Änderungen konnten nicht gespeichert werden. Bitte erneut versuchen."
},
"openclawAdmin": {
"title": "OpenClaw-Versionen",
"subtitle": "Plattform-Standard-Tag und Tenant-spezifische Overrides für das Testen neuer Releases konfigurieren.",
"defaultSection": "Plattform-Standard",
"defaultDescription": "Wird von jedem Tenant ohne eigenen Override verwendet.",
"fieldTag": "Tag",
"emptyHint": "Leer lassen, um den eingebauten Operator-Standard zu verwenden.",
"saveDefault": "Standard speichern",
"defaultSaved": "Standard gespeichert. Tenants ohne Override übernehmen den Wert beim nächsten Reconcile.",
"saveFailed": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
"overridesSection": "Tenant-Overrides",
"noTenants": "Keine Tenants im Cluster.",
"statusOverridden": "Override",
"statusFollowsDefault": "Folgt Standard",
"builtinFallback": "(eingebauter Fallback)",
"defaultPrefix": "Standard:",
"saveOverride": "Override speichern",
"clearOverride": "Override entfernen"
}
}

View File

@@ -189,7 +189,21 @@
"last30Days": "Last 30 Days",
"noData": "No usage data available.",
"dailyBreakdown": "Daily Breakdown",
"requests": "requests"
"requests": "requests",
"budgetEdit": "Edit",
"budgetEditTitle": "Set spending budget",
"budgetEditDescription": "Cap how much this tenant's assistants can spend before requests start being declined.",
"budgetModeUnlimited": "No limit",
"budgetModeUnlimitedDescription": "Spend as much as needed; no cap.",
"budgetModeCapped": "Set a cap",
"budgetModeCappedDescription": "Stop accepting requests once spend reaches this amount.",
"budgetAmount": "Amount",
"budgetResetCadence": "Reset",
"budgetCadence_30d": "Every 30 days",
"budgetCadence_1mo": "Monthly",
"budgetCadence_1y": "Yearly",
"budgetInvalid": "Please enter a positive amount.",
"budgetSaveFailed": "Could not save budget. Please try again."
},
"workspace": {
"save": "Save",
@@ -200,7 +214,8 @@
"packages": {
"categories": {
"channels": "Channels",
"skills": "Skills"
"skills": "Skills",
"core": "Core"
},
"enable": "Enable",
"disable": "Disable",
@@ -225,29 +240,73 @@
"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",
"disclaimer": "I confirm I own this Discord bot and authorize PieCed IT to connect it to my AI assistant."
"disclaimer": "I confirm I own this Discord bot and authorize PieCed IT to connect it to my AI assistant.",
"appIdLabel": "Discord Application ID",
"appIdPlaceholder": "18-19 digit numeric ID from Developer Portal"
},
"email": {
"description": "Enable your AI assistant to send and receive email.",
"statusEnabled": "enabled",
"statusDisabled": "disabled",
"coreHeartbeat": {
"description": "Periodic agent run every 30 minutes that lets your assistant check inbox, calendar, and other configured sources and message you proactively when something needs attention. Without this, the assistant only responds when you message it first."
},
"coreCron": {
"description": "Allow the assistant to run scheduled tasks (daily briefings, recurring reminders, periodic reports). Off by default. When off, the agent's cron tool stays available but no scheduled job ever fires."
},
"coreActiveMemory": {
"description": "Lets the assistant recall stable preferences, recurring habits, and long-term context from past conversations during a chat. Uses an extra sub-agent turn per inbound message to query the memory store. Direct-message sessions only. Adds a small token cost in exchange for continuity and personalisation."
},
"coreVoice": {
"description": "Speech-to-text on incoming voice notes and text-to-speech on replies, routed through the PieCed LiteLLM gateway so audio cost is tracked per tenant alongside chat. Runtime wiring lands in the next platform release; toggling now stores the preference for that rollout."
},
"gitCli": {
"description": "Standalone git command-line operations (clone, commit, branch, diff, log, status). For private repositories, configure credentials in your workspace."
},
"github": {
"description": "Interact with GitHub repositories via the gh CLI — issues, pull requests, CI runs, releases, gists. Requires a personal access token.",
"tokenLabel": "GitHub Personal Access Token",
"tokenPlaceholder": "ghp_… or github_pat_…",
"instructions": "1. Open https://github.com/settings/tokens\n2. Generate a fine-grained personal access token with the repo scopes you want the assistant to use\n3. Copy the token (it's shown only once)"
},
"gitea": {
"description": "Interact with a Gitea instance — repositories, issues, pull requests, releases. Defaults to the PieCed-platform Gitea at git.c5ai.ch.",
"tokenLabel": "Gitea Access Token",
"tokenPlaceholder": "Generated under Settings → Applications",
"instructions": "1. Log in to your Gitea instance (default https://git.c5ai.ch)\n2. Go to Settings → Applications → Generate New Token\n3. Grant the scopes you want the assistant to use (repo, issue, user)\n4. Copy the token"
},
"whisperSelfHosted": {
"description": "Transcribe audio files via the platform's self-hosted Whisper instance. Useful for ad-hoc transcription tasks initiated from chat."
},
"searxngLocalSearch": {
"description": "Privacy-respecting web search via the platform's internal SearXNG instance. Search the web, images, and news without external API calls or trackers."
},
"gog": {
"description": "Bundled access to Gmail, Calendar, Drive, Docs, Sheets, and Contacts via Google OAuth. Setup requires a Google Cloud project — contact PieCed support to onboard.",
"clientIdLabel": "Google OAuth Client ID",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Google OAuth Client Secret",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Google Workspace integration uses OAuth and requires manual onboarding for now. Please open a support ticket to start the setup — we'll exchange the client credentials and a refresh token offline, then enable this package on your tenant.",
"disclaimer": "By enabling Google Workspace integration you authorize PieCed to access Gmail, Calendar, Drive, Docs, Sheets, and Contacts on your behalf. Data flows through Google's APIs subject to Google's terms."
},
"mail": {
"description": "Read, search, and manage email via IMAP; send via SMTP. Works with Gmail (with an app password), Outlook, Fastmail, and any standard IMAP/SMTP host.",
"imapHostLabel": "IMAP Host",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "IMAP Username",
"imapUserPlaceholder": "user@example.com",
"imapPassLabel": "IMAP Password",
"imapPassPlaceholder": "••••••••",
"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 SMTP and IMAP credentials. The assistant uses these to send and monitor messages.",
"disclaimer": "I confirm I am authorized to use these email credentials and that PieCed IT may access this mailbox."
},
"webSearch": {
"description": "Give your AI assistant the ability to search the web."
},
"documentProcessing": {
"description": "Enable document parsing, summarization, and extraction."
},
"statusEnabled": "enabled",
"statusDisabled": "disabled"
"smtpPassLabel": "SMTP Password",
"smtpPassPlaceholder": "••••••••",
"instructions": "1. For Gmail: enable 2-Step Verification, then create an App Password at https://myaccount.google.com/apppasswords and use it as both IMAP and SMTP password.\n2. For Outlook / Microsoft 365 with MFA: generate an app password in your account's security settings.\n3. For other providers: refer to their IMAP/SMTP documentation for host names and ports.\n4. Typical IMAP hosts: imap.gmail.com, outlook.office365.com.\n5. Typical SMTP hosts: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "The assistant gains read/write access to the mailbox you configure. Consider using a dedicated address rather than a personal inbox if you want to limit scope."
}
},
"admin": {
"title": "Platform Admin",
@@ -319,7 +378,8 @@
"statusDown": "Down",
"spendChf": "Spend (CHF)",
"resumeRequestBadge": "Resume",
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs."
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
"openclawTool": "OpenClaw versions"
},
"channelUsers": {
"title": "Authorized Users",
@@ -330,8 +390,7 @@
"remove": "Remove",
"alreadyAdded": "This user ID is already authorized.",
"telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here",
"discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here",
"emailIdHelp": "Enter the email address that should be authorized to interact with the assistant."
"discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here"
},
"team": {
"title": "Team",
@@ -459,5 +518,24 @@
"resolvedBanner": "This ticket is resolved. Reply below if you need to follow up — that will reopen it.",
"adminControlsTitle": "Admin controls",
"updateFailed": "Could not save changes. Please try again."
},
"openclawAdmin": {
"title": "OpenClaw versions",
"subtitle": "Configure the platform-default OpenClaw image tag and per-tenant overrides for testing new releases.",
"defaultSection": "Platform default",
"defaultDescription": "Used by every tenant that doesn't have its own override.",
"fieldTag": "Tag",
"emptyHint": "Leave empty to fall back to the operator's built-in default.",
"saveDefault": "Save default",
"defaultSaved": "Default saved. Tenants without overrides will pick this up on the next reconcile.",
"saveFailed": "Could not save. Please try again.",
"overridesSection": "Tenant overrides",
"noTenants": "No tenants in the cluster.",
"statusOverridden": "Override",
"statusFollowsDefault": "Follows default",
"builtinFallback": "(operator built-in fallback)",
"defaultPrefix": "Default:",
"saveOverride": "Save override",
"clearOverride": "Clear override"
}
}

View File

@@ -189,7 +189,21 @@
"last30Days": "30 derniers jours",
"noData": "Aucune donnée d'utilisation disponible.",
"dailyBreakdown": "Détail journalier",
"requests": "requêtes"
"requests": "requêtes",
"budgetEdit": "Modifier",
"budgetEditTitle": "Définir un budget",
"budgetEditDescription": "Limitez la dépense des assistants de ce locataire avant que les requêtes ne soient refusées.",
"budgetModeUnlimited": "Aucune limite",
"budgetModeUnlimitedDescription": "Dépense libre, sans plafond.",
"budgetModeCapped": "Définir un plafond",
"budgetModeCappedDescription": "Refuser les requêtes une fois ce montant atteint.",
"budgetAmount": "Montant",
"budgetResetCadence": "Réinitialisation",
"budgetCadence_30d": "Tous les 30 jours",
"budgetCadence_1mo": "Mensuelle",
"budgetCadence_1y": "Annuelle",
"budgetInvalid": "Veuillez saisir un montant positif.",
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer."
},
"workspace": {
"save": "Enregistrer",
@@ -200,7 +214,8 @@
"packages": {
"categories": {
"channels": "Canaux",
"skills": "Compétences"
"skills": "Compétences",
"core": "Cœur"
},
"enable": "Activer",
"disable": "Désactiver",
@@ -225,29 +240,73 @@
"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",
"disclaimer": "Je confirme que je possède ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA."
"disclaimer": "Je confirme que je possède ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA.",
"appIdLabel": "ID d'application Discord",
"appIdPlaceholder": "ID numérique de 1819 chiffres depuis le Developer Portal"
},
"email": {
"description": "Permettez à votre assistant IA d'envoyer et de recevoir des e-mails.",
"statusEnabled": "activé",
"statusDisabled": "désactivé",
"coreHeartbeat": {
"description": "Exécution périodique de l'agent toutes les 30 minutes pour vérifier votre boîte mail, votre agenda et d'autres sources configurées, et vous notifier de manière proactive lorsqu'une attention est requise. Sans cette option, l'assistant ne répond que lorsque vous lui écrivez."
},
"coreCron": {
"description": "Permet à l'assistant d'exécuter des tâches programmées (briefings quotidiens, rappels récurrents, rapports périodiques). Désactivé par défaut. Lorsqu'il est désactivé, l'outil cron reste disponible mais aucune tâche planifiée ne s'exécute."
},
"coreActiveMemory": {
"description": "Permet à l'assistant de se rappeler des préférences stables, des habitudes récurrentes et du contexte à long terme issu de conversations passées. Utilise un tour de sous-agent supplémentaire par message entrant pour interroger la mémoire. Uniquement en messages directs. Légère consommation de tokens supplémentaire en échange de continuité et de personnalisation."
},
"coreVoice": {
"description": "Reconnaissance vocale sur les notes vocales entrantes et synthèse vocale sur les réponses, via la passerelle PieCed LiteLLM pour un suivi du coût audio par tenant. L'intégration runtime arrive dans la prochaine version de la plateforme ; basculer le commutateur enregistre dès maintenant la préférence."
},
"gitCli": {
"description": "Opérations git en ligne de commande autonomes (clone, commit, branch, diff, log, status). Pour les dépôts privés, configurez les identifiants dans votre espace de travail."
},
"github": {
"description": "Interagissez avec les dépôts GitHub via la CLI gh — issues, pull requests, exécutions CI, releases, gists. Nécessite un jeton d'accès personnel.",
"tokenLabel": "Jeton d'accès personnel GitHub",
"tokenPlaceholder": "ghp_… ou github_pat_…",
"instructions": "1. Ouvrez https://github.com/settings/tokens\n2. Générez un jeton d'accès personnel fin avec les portées de dépôt souhaitées\n3. Copiez le jeton (il n'est affiché qu'une fois)"
},
"gitea": {
"description": "Interagissez avec une instance Gitea — dépôts, issues, pull requests, releases. Par défaut, l'instance Gitea PieCed à git.c5ai.ch.",
"tokenLabel": "Jeton d'accès Gitea",
"tokenPlaceholder": "Généré sous Paramètres → Applications",
"instructions": "1. Connectez-vous à votre instance Gitea (par défaut https://git.c5ai.ch)\n2. Allez dans Paramètres → Applications → Générer un nouveau jeton\n3. Accordez les portées souhaitées (repo, issue, user)\n4. Copiez le jeton"
},
"whisperSelfHosted": {
"description": "Transcrivez des fichiers audio via l'instance Whisper auto-hébergée de la plateforme. Utile pour les transcriptions ad hoc initiées depuis le chat."
},
"searxngLocalSearch": {
"description": "Recherche web respectueuse de la vie privée via l'instance SearXNG interne de la plateforme. Recherchez le web, les images et les actualités sans appels d'API externes ni traqueurs."
},
"gog": {
"description": "Accès groupé à Gmail, Agenda, Drive, Docs, Sheets et Contacts via Google OAuth. La configuration nécessite un projet Google Cloud — contactez le support PieCed pour l'intégration.",
"clientIdLabel": "ID client Google OAuth",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Secret client Google OAuth",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Jeton de rafraîchissement Google OAuth",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "L'intégration de Google Workspace utilise OAuth et nécessite actuellement une intégration manuelle. Veuillez ouvrir un ticket de support pour démarrer la configuration — nous échangerons hors ligne les identifiants client et un jeton de rafraîchissement, puis activerons ce package sur votre tenant.",
"disclaimer": "En activant l'intégration de Google Workspace, vous autorisez PieCed à accéder à Gmail, Agenda, Drive, Docs, Sheets et Contacts en votre nom. Les données transitent par les API de Google, soumises aux conditions de Google."
},
"mail": {
"description": "Lisez, recherchez et gérez vos e-mails via IMAP ; envoyez via SMTP. Compatible avec Gmail (avec un mot de passe d'application), Outlook, Fastmail et tout hôte IMAP/SMTP standard.",
"imapHostLabel": "Hôte IMAP",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "Nom d'utilisateur IMAP",
"imapUserPlaceholder": "utilisateur@example.com",
"imapPassLabel": "Mot de passe IMAP",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "Hôte SMTP",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Nom d'utilisateur SMTP",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "Mot de passe SMTP",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "Hôte IMAP",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Fournissez les identifiants SMTP et IMAP. L'assistant les utilise pour envoyer et surveiller les messages.",
"disclaimer": "Je confirme que je suis autorisé à utiliser ces identifiants e-mail et que PieCed IT peut accéder à cette boîte mail."
},
"webSearch": {
"description": "Donnez à votre assistant IA la capacité de rechercher sur le web."
},
"documentProcessing": {
"description": "Activez l'analyse, le résumé et l'extraction de documents."
},
"statusEnabled": "activé",
"statusDisabled": "désactivé"
"smtpUserPlaceholder": "utilisateur@example.com",
"smtpPassLabel": "Mot de passe SMTP",
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Pour Gmail : activez la validation en deux étapes, puis créez un mot de passe d'application sur https://myaccount.google.com/apppasswords et utilisez-le comme mot de passe IMAP et SMTP.\n2. Pour Outlook / Microsoft 365 avec MFA : générez un mot de passe d'application dans les paramètres de sécurité de votre compte.\n3. Pour les autres fournisseurs : consultez leur documentation IMAP/SMTP pour les noms d'hôte et les ports.\n4. Hôtes IMAP typiques : imap.gmail.com, outlook.office365.com.\n5. Hôtes SMTP typiques : smtp.gmail.com, smtp.office365.com.",
"disclaimer": "L'assistant obtient un accès en lecture/écriture à la boîte aux lettres que vous configurez. Envisagez d'utiliser une adresse dédiée plutôt qu'une boîte personnelle si vous souhaitez limiter la portée."
}
},
"admin": {
"title": "Admin plateforme",
@@ -319,7 +378,8 @@
"statusDown": "Hors service",
"spendChf": "Coûts (CHF)",
"resumeRequestBadge": "Reprise",
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute."
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
"openclawTool": "Versions OpenClaw"
},
"channelUsers": {
"title": "Utilisateurs autorisés",
@@ -330,8 +390,7 @@
"remove": "Supprimer",
"alreadyAdded": "Cet identifiant est déjà autorisé.",
"telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici",
"discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici",
"emailIdHelp": "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant."
"discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici"
},
"team": {
"title": "Équipe",
@@ -459,5 +518,24 @@
"resolvedBanner": "Ce ticket est résolu. Répondez ci-dessous si vous avez besoin d'un suivi — cela le rouvrira.",
"adminControlsTitle": "Contrôles admin",
"updateFailed": "Impossible d'enregistrer les modifications. Veuillez réessayer."
},
"openclawAdmin": {
"title": "Versions OpenClaw",
"subtitle": "Configurer le tag par défaut de la plateforme et les surcharges par locataire pour tester les nouvelles versions.",
"defaultSection": "Défaut de la plateforme",
"defaultDescription": "Utilisé par tous les locataires sans surcharge propre.",
"fieldTag": "Tag",
"emptyHint": "Laisser vide pour utiliser le défaut intégré de l'opérateur.",
"saveDefault": "Enregistrer le défaut",
"defaultSaved": "Défaut enregistré. Les locataires sans surcharge l'appliqueront au prochain réconcile.",
"saveFailed": "Échec de l'enregistrement. Veuillez réessayer.",
"overridesSection": "Surcharges par locataire",
"noTenants": "Aucun locataire dans le cluster.",
"statusOverridden": "Surcharge",
"statusFollowsDefault": "Suit le défaut",
"builtinFallback": "(repli intégré)",
"defaultPrefix": "Défaut :",
"saveOverride": "Enregistrer la surcharge",
"clearOverride": "Supprimer la surcharge"
}
}

View File

@@ -189,7 +189,21 @@
"last30Days": "Ultimi 30 giorni",
"noData": "Nessun dato di utilizzo disponibile.",
"dailyBreakdown": "Dettaglio giornaliero",
"requests": "richieste"
"requests": "richieste",
"budgetEdit": "Modifica",
"budgetEditTitle": "Imposta budget",
"budgetEditDescription": "Limita quanto gli assistenti di questo tenant possono spendere prima che le richieste vengano rifiutate.",
"budgetModeUnlimited": "Nessun limite",
"budgetModeUnlimitedDescription": "Spesa libera, nessun tetto.",
"budgetModeCapped": "Imposta un tetto",
"budgetModeCappedDescription": "Rifiuta le richieste una volta raggiunto questo importo.",
"budgetAmount": "Importo",
"budgetResetCadence": "Ripristino",
"budgetCadence_30d": "Ogni 30 giorni",
"budgetCadence_1mo": "Mensile",
"budgetCadence_1y": "Annuale",
"budgetInvalid": "Inserisci un importo positivo.",
"budgetSaveFailed": "Impossibile salvare il budget. Riprova."
},
"workspace": {
"save": "Salva",
@@ -200,7 +214,8 @@
"packages": {
"categories": {
"channels": "Canali",
"skills": "Capacità"
"skills": "Capacità",
"core": "Core"
},
"enable": "Attiva",
"disable": "Disattiva",
@@ -225,29 +240,73 @@
"botTokenLabel": "Token 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",
"disclaimer": "Confermo di possedere questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA."
},
"email": {
"description": "Permetti al tuo assistente IA di inviare e ricevere e-mail.",
"smtpHostLabel": "Host SMTP",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Nome utente SMTP",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "Password SMTP",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "Host IMAP",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Fornisci le credenziali SMTP e IMAP. L'assistente le usa per inviare e monitorare i messaggi.",
"disclaimer": "Confermo di essere autorizzato a utilizzare queste credenziali e-mail e che PieCed IT può accedere a questa casella di posta."
},
"webSearch": {
"description": "Dai al tuo assistente IA la capacità di cercare nel web."
},
"documentProcessing": {
"description": "Attiva l'analisi, il riassunto e l'estrazione di documenti."
"disclaimer": "Confermo di possedere questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA.",
"appIdLabel": "ID applicazione Discord",
"appIdPlaceholder": "ID numerico di 1819 cifre dal Developer Portal"
},
"statusEnabled": "abilitato",
"statusDisabled": "disabilitato"
"statusDisabled": "disabilitato",
"coreHeartbeat": {
"description": "Esecuzione periodica dell'agente ogni 30 minuti che consente all'assistente di controllare posta, calendario e altre fonti configurate e di avvisarti proattivamente quando serve attenzione. Senza questa opzione, l'assistente risponde solo quando lo contatti."
},
"coreCron": {
"description": "Consente all'assistente di eseguire attività pianificate (briefing giornalieri, promemoria ricorrenti, report periodici). Disattivato per impostazione predefinita. Quando è disattivato, lo strumento cron resta disponibile ma nessuna attività pianificata viene eseguita."
},
"coreActiveMemory": {
"description": "Consente all'assistente di richiamare preferenze stabili, abitudini ricorrenti e contesto a lungo termine dalle conversazioni precedenti. Utilizza un turno extra di sub-agente per ogni messaggio in entrata per interrogare lo store di memoria. Solo messaggi diretti. Aggiunge un piccolo costo in token in cambio di continuità e personalizzazione."
},
"coreVoice": {
"description": "Riconoscimento vocale sui messaggi audio in entrata e sintesi vocale sulle risposte, instradati attraverso il gateway PieCed LiteLLM per tracciare il costo audio per tenant. L'integrazione runtime arriverà nel prossimo rilascio della piattaforma; attivare ora salva la preferenza per quel rilascio."
},
"gitCli": {
"description": "Operazioni git da riga di comando autonome (clone, commit, branch, diff, log, status). Per i repository privati, configura le credenziali nel tuo workspace."
},
"github": {
"description": "Interagisci con repository GitHub tramite la CLI gh — issue, pull request, esecuzioni CI, release, gist. Richiede un token di accesso personale.",
"tokenLabel": "Token di accesso personale GitHub",
"tokenPlaceholder": "ghp_… o github_pat_…",
"instructions": "1. Apri https://github.com/settings/tokens\n2. Genera un token di accesso personale fine con gli ambiti repo desiderati\n3. Copia il token (viene mostrato una sola volta)"
},
"gitea": {
"description": "Interagisci con un'istanza Gitea — repository, issue, pull request, release. Per impostazione predefinita, l'istanza Gitea PieCed su git.c5ai.ch.",
"tokenLabel": "Token di accesso Gitea",
"tokenPlaceholder": "Generato in Impostazioni → Applicazioni",
"instructions": "1. Accedi alla tua istanza Gitea (predefinito https://git.c5ai.ch)\n2. Vai a Impostazioni → Applicazioni → Genera nuovo token\n3. Concedi gli ambiti desiderati (repo, issue, user)\n4. Copia il token"
},
"whisperSelfHosted": {
"description": "Trascrivi file audio tramite l'istanza Whisper auto-ospitata della piattaforma. Utile per attività di trascrizione ad hoc avviate dalla chat."
},
"searxngLocalSearch": {
"description": "Ricerca web rispettosa della privacy tramite l'istanza SearXNG interna della piattaforma. Cerca sul web, nelle immagini e nelle notizie senza chiamate ad API esterne né tracker."
},
"gog": {
"description": "Accesso integrato a Gmail, Calendar, Drive, Docs, Sheets e Contatti tramite Google OAuth. La configurazione richiede un progetto Google Cloud — contatta il supporto PieCed per l'onboarding.",
"clientIdLabel": "ID client Google OAuth",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Client secret Google OAuth",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Token di refresh Google OAuth",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "L'integrazione con Google Workspace utilizza OAuth e richiede attualmente un onboarding manuale. Apri un ticket di supporto per avviare la configurazione — scambieremo le credenziali del client e un token di refresh offline, quindi abiliteremo questo pacchetto sul tuo tenant.",
"disclaimer": "Abilitando l'integrazione con Google Workspace autorizzi PieCed ad accedere per tuo conto a Gmail, Calendar, Drive, Docs, Sheets e Contatti. I dati transitano attraverso le API di Google, soggetti ai termini di Google."
},
"mail": {
"description": "Leggi, cerca e gestisci le e-mail via IMAP; invia tramite SMTP. Funziona con Gmail (con una password per app), Outlook, Fastmail e qualsiasi host IMAP/SMTP standard.",
"imapHostLabel": "Host IMAP",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "Username IMAP",
"imapUserPlaceholder": "utente@example.com",
"imapPassLabel": "Password IMAP",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "Host SMTP",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Username SMTP",
"smtpUserPlaceholder": "utente@example.com",
"smtpPassLabel": "Password SMTP",
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Per Gmail: abilita la verifica in due passaggi, quindi crea una password per app su https://myaccount.google.com/apppasswords e usala come password IMAP e SMTP.\n2. Per Outlook / Microsoft 365 con MFA: genera una password per app nelle impostazioni di sicurezza del tuo account.\n3. Per altri provider: consulta la loro documentazione IMAP/SMTP per nomi host e porte.\n4. Host IMAP tipici: imap.gmail.com, outlook.office365.com.\n5. Host SMTP tipici: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "L'assistente ottiene accesso in lettura/scrittura alla casella di posta che configuri. Valuta l'uso di un indirizzo dedicato anziché di una casella personale se vuoi limitare la portata."
}
},
"admin": {
"title": "Admin piattaforma",
@@ -319,7 +378,8 @@
"statusDown": "Non disponibile",
"spendChf": "Costi (CHF)",
"resumeRequestBadge": "Ripresa",
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning."
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
"openclawTool": "Versioni OpenClaw"
},
"channelUsers": {
"title": "Utenti autorizzati",
@@ -330,8 +390,7 @@
"remove": "Rimuovi",
"alreadyAdded": "Questo ID utente è già autorizzato.",
"telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui",
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui",
"emailIdHelp": "Inserisci l'indirizzo e-mail che deve essere autorizzato a interagire con l'assistente."
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui"
},
"team": {
"title": "Team",
@@ -459,5 +518,24 @@
"resolvedBanner": "Questo ticket è risolto. Rispondi qui sotto se hai bisogno di un seguito — questo lo riaprirà.",
"adminControlsTitle": "Controlli admin",
"updateFailed": "Impossibile salvare le modifiche. Riprova."
},
"openclawAdmin": {
"title": "Versioni OpenClaw",
"subtitle": "Configura il tag predefinito della piattaforma e gli override per tenant per testare nuove release.",
"defaultSection": "Predefinito piattaforma",
"defaultDescription": "Usato da ogni tenant senza override proprio.",
"fieldTag": "Tag",
"emptyHint": "Lascia vuoto per usare il predefinito integrato dell'operatore.",
"saveDefault": "Salva predefinito",
"defaultSaved": "Predefinito salvato. I tenant senza override lo applicheranno al prossimo reconcile.",
"saveFailed": "Salvataggio fallito. Riprova.",
"overridesSection": "Override per tenant",
"noTenants": "Nessun tenant nel cluster.",
"statusOverridden": "Override",
"statusFollowsDefault": "Segue predefinito",
"builtinFallback": "(fallback integrato)",
"defaultPrefix": "Predefinito:",
"saveOverride": "Salva override",
"clearOverride": "Rimuovi override"
}
}

View File

@@ -75,6 +75,18 @@ export interface PiecedTenantSpec {
workspaceFiles?: Record<string, string>;
channelUsers?: Record<string, string[]>;
suspend?: boolean;
/**
* Per-tenant OpenClaw image override (tag). Set only by platform
* admins via the portal admin UI. Customers never see this field.
* When unset or with empty Tag, the operator uses the platform
* default from the pieced-openclaw-config ConfigMap.
*
* Tag-only by design — see operator notes for rationale (single
* image-selector field avoids SSA field-ownership ambiguity).
*/
openClawImage?: {
tag?: string;
};
}
export interface PiecedTenantStatus {