Compare commits

...

7 Commits

15 changed files with 997 additions and 144 deletions

View File

@@ -81,6 +81,7 @@ export default async function NewInstancePage() {
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling} existingOrgBilling={orgBilling}
setupFeeChf={pricing.tenantSetupFeeChf} setupFeeChf={pricing.tenantSetupFeeChf}
monthlyFeeChf={pricing.tenantMonthlyFeeChf}
/> />
</div> </div>
</div> </div>

View File

@@ -326,6 +326,7 @@ export default async function DashboardPage() {
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling} existingOrgBilling={orgBilling}
setupFeeChf={platformPricing.tenantSetupFeeChf} setupFeeChf={platformPricing.tenantSetupFeeChf}
monthlyFeeChf={platformPricing.tenantMonthlyFeeChf}
/> />
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
import { BackLink } from "@/components/ui/back-link"; import { BackLink } from "@/components/ui/back-link";
import { TeamList } from "@/components/team/team-list"; import { TeamList } from "@/components/team/team-list";
import { InviteForm } from "@/components/team/invite-form"; import { InviteForm } from "@/components/team/invite-form";
import { AccessOverview } from "@/components/team/access-overview";
/** /**
* /team — manage org members. * /team — manage org members.
@@ -70,6 +71,16 @@ export default async function TeamPage() {
canEditRoles={isCustomerOwner(user)} canEditRoles={isCustomerOwner(user)}
/> />
</section> </section>
{/* Access overview — single place to see which member can reach
which assistant, instead of checking each tenant page. */}
<section className="mt-8 animate-in animate-in-delay-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-1">
{t("accessTitle")}
</h2>
<p className="text-xs text-text-muted mb-3">{t("accessDescription")}</p>
<AccessOverview />
</section>
</div> </div>
); );
} }

View File

@@ -16,6 +16,7 @@ import { WorkspaceEditor } from "@/components/packages/workspace-editor";
import { ChannelUsers } from "@/components/channel-users/channel-users"; import { ChannelUsers } from "@/components/channel-users/channel-users";
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel"; import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle"; import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
import { ConnectPanel } from "@/components/tenants/connect-panel";
import { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages"; import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
@@ -216,6 +217,19 @@ export default async function TenantDetailPage({
</div> </div>
)} )}
{/* Connect: how the customer actually reaches their assistant.
The portal manages the assistant; the assistant lives in the
customer's messaging app. This bridges that gap right at the
top of the page (and calls out the case where no channel is
enabled, which would otherwise leave a running assistant
unreachable). */}
<section className="mb-8 animate-in animate-in-delay-1">
<ConnectPanel
enabledChannels={enabledChannels}
phase={tenant.status?.phase ?? "Pending"}
/>
</section>
{/* Usage */} {/* Usage */}
<section className="mb-8 animate-in animate-in-delay-1"> <section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3"> <h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import type { PiecedTenant, TenantRequest } from "@/types"; import type { PiecedTenant, TenantRequest } from "@/types";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { Modal } from "@/components/ui/modal";
import { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
@@ -35,6 +36,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const [rejectModal, setRejectModal] = useState<string | null>(null); const [rejectModal, setRejectModal] = useState<string | null>(null);
const [rejectNotes, setRejectNotes] = useState(""); const [rejectNotes, setRejectNotes] = useState("");
// Approve is the highest-consequence request action — it provisions
// real infrastructure and triggers the billable setup fee — so it now
// goes through a confirmation modal like reject/delete, instead of
// firing on a single click.
const [approveModal, setApproveModal] = useState<string | null>(null);
// Tenants state // Tenants state
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants); const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
@@ -47,6 +53,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
// Shared // Shared
const [error, setError] = useState(""); const [error, setError] = useState("");
// Action-scoped error — shown inside the active confirmation modal so
// a failed approve/reject/delete surfaces next to the action that
// caused it (and keeps the modal open), rather than as a detached
// panel-level banner that isn't tied to any row.
const [actionError, setActionError] = useState("");
// ─── Requests fetching ─── // ─── Requests fetching ───
const fetchRequests = useCallback(async () => { const fetchRequests = useCallback(async () => {
@@ -125,18 +136,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
// ─── Request actions ─── // ─── Request actions ───
const handleApprove = async (id: string) => { const handleApprove = async (id: string) => {
setActionLoading(id); setActionLoading(id);
setError(""); setActionError("");
try { try {
const res = await fetch(`/api/admin/requests/${id}/approve`, { const res = await fetch(`/api/admin/requests/${id}/approve`, {
method: "POST", method: "POST",
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Approve failed"); throw new Error(data.error || "Approve failed");
} }
setApproveModal(null);
await fetchRequests(); await fetchRequests();
} catch (e: any) { } catch (e: any) {
setError(e.message); // Keep the modal open so the admin sees why provisioning didn't
// start; the error renders inside the dialog next to the action.
setActionError(e.message);
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@@ -144,7 +158,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
const handleReject = async (id: string) => { const handleReject = async (id: string) => {
setActionLoading(id); setActionLoading(id);
setError(""); setActionError("");
try { try {
const res = await fetch(`/api/admin/requests/${id}/reject`, { const res = await fetch(`/api/admin/requests/${id}/reject`, {
method: "POST", method: "POST",
@@ -152,14 +166,14 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
body: JSON.stringify({ adminNotes: rejectNotes || undefined }), body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json(); const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Reject failed"); throw new Error(data.error || "Reject failed");
} }
setRejectModal(null); setRejectModal(null);
setRejectNotes(""); setRejectNotes("");
await fetchRequests(); await fetchRequests();
} catch (e: any) { } catch (e: any) {
setError(e.message); setActionError(e.message);
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@@ -189,7 +203,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
const handleDelete = async (name: string) => { const handleDelete = async (name: string) => {
setActionLoading(name); setActionLoading(name);
setError(""); setActionError("");
try { try {
const res = await fetch(`/api/admin/tenants/${name}/delete`, { const res = await fetch(`/api/admin/tenants/${name}/delete`, {
method: "POST", method: "POST",
@@ -216,7 +230,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
fetchTenants(); fetchTenants();
setTimeout(() => fetchTenants(), 1500); setTimeout(() => fetchTenants(), 1500);
} catch (e: any) { } catch (e: any) {
setError(e.message); setActionError(e.message);
} finally { } finally {
setActionLoading(null); setActionLoading(null);
} }
@@ -436,16 +450,20 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{req.status === "pending" && ( {req.status === "pending" && (
<> <>
<button <button
onClick={() => handleApprove(req.id)} onClick={() => {
setActionError("");
setApproveModal(req.id);
}}
disabled={actionLoading === req.id} disabled={actionLoading === req.id}
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50" className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
> >
{actionLoading === req.id {t("approve")}
? "…"
: t("approve")}
</button> </button>
<button <button
onClick={() => setRejectModal(req.id)} onClick={() => {
setActionError("");
setRejectModal(req.id);
}}
disabled={actionLoading === req.id} disabled={actionLoading === req.id}
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50" className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
> >
@@ -466,7 +484,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
)} )}
{req.status === "rejected" && ( {req.status === "rejected" && (
<button <button
onClick={() => handleApprove(req.id)} onClick={() => {
setActionError("");
setApproveModal(req.id);
}}
disabled={actionLoading === req.id} disabled={actionLoading === req.id}
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50" className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
> >
@@ -642,9 +663,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
: t("suspend")} : t("suspend")}
</button> </button>
<button <button
onClick={() => onClick={() => {
setDeleteModal(tenant.metadata.name) setActionError("");
} setDeleteModal(tenant.metadata.name);
}}
disabled={actionLoading === tenant.metadata.name} disabled={actionLoading === tenant.metadata.name}
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50" className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
> >
@@ -772,10 +794,75 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</> </>
)} )}
{/* ───── APPROVE MODAL ───── */}
<Modal
open={!!approveModal}
onClose={() => {
setApproveModal(null);
setActionError("");
}}
ariaLabel={t("approveTitle")}
>
{approveModal &&
(() => {
const req = requests.find((r) => r.id === approveModal);
const isReapprove = req?.status === "rejected";
return (
<>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("approveTitle")}
</h3>
<p className="text-sm text-text-secondary mb-2">
{isReapprove
? t("approveReapproveWarning")
: t("approveWarning")}
</p>
{req && (
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
{req.companyName}
{req.agentName ? ` · ${req.agentName}` : ""}
</p>
)}
{actionError && (
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
{actionError}
</p>
)}
<div className="flex gap-2 justify-end">
<button
onClick={() => {
setApproveModal(null);
setActionError("");
}}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t("cancelAction")}
</button>
<button
onClick={() => handleApprove(approveModal)}
disabled={actionLoading === approveModal}
className="px-4 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400 rounded-lg hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
>
{actionLoading === approveModal ? "…" : t("confirmApprove")}
</button>
</div>
</>
);
})()}
</Modal>
{/* ───── REJECT MODAL ───── */} {/* ───── REJECT MODAL ───── */}
{rejectModal && ( <Modal
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> open={!!rejectModal}
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"> onClose={() => {
setRejectModal(null);
setRejectNotes("");
setActionError("");
}}
ariaLabel={t("rejectTitle")}
>
{rejectModal && (
<>
<h3 className="font-display text-lg font-semibold text-text-primary mb-4"> <h3 className="font-display text-lg font-semibold text-text-primary mb-4">
{t("rejectTitle")} {t("rejectTitle")}
</h3> </h3>
@@ -789,11 +876,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
rows={3} rows={3}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4" className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4"
/> />
{actionError && (
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
{actionError}
</p>
)}
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button
onClick={() => { onClick={() => {
setRejectModal(null); setRejectModal(null);
setRejectNotes(""); setRejectNotes("");
setActionError("");
}} }}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors" className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
> >
@@ -807,14 +900,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{actionLoading === rejectModal ? "…" : t("confirmReject")} {actionLoading === rejectModal ? "…" : t("confirmReject")}
</button> </button>
</div> </div>
</div> </>
</div> )}
)} </Modal>
{/* ───── DELETE MODAL ───── */} {/* ───── DELETE MODAL ───── */}
{deleteModal && ( <Modal
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> open={!!deleteModal}
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"> onClose={() => {
setDeleteModal(null);
setActionError("");
}}
ariaLabel={t("deleteTitle")}
>
{deleteModal && (
<>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2"> <h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("deleteTitle")} {t("deleteTitle")}
</h3> </h3>
@@ -824,9 +924,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4"> <p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
{deleteModal} {deleteModal}
</p> </p>
{actionError && (
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
{actionError}
</p>
)}
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button
onClick={() => setDeleteModal(null)} onClick={() => {
setDeleteModal(null);
setActionError("");
}}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors" className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
> >
{t("cancelAction")} {t("cancelAction")}
@@ -839,9 +947,9 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{actionLoading === deleteModal ? "…" : t("confirmDelete")} {actionLoading === deleteModal ? "…" : t("confirmDelete")}
</button> </button>
</div> </div>
</div> </>
</div> )}
)} </Modal>
</> </>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card"; import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
@@ -84,42 +84,149 @@ function formatMonth(month: string, locale: string): string {
} }
function UsageChart({ data }: { data: DailyUsage[] }) { function UsageChart({ data }: { data: DailyUsage[] }) {
const t = useTranslations("usage");
const locale = useLocale();
// Which day's detail is shown in the readout. Defaults to the most
// recent day; hover (mouse), tap (touch) or focus (keyboard) all
// update it. The previous version put per-day numbers only in SVG
// <title> hover tooltips, which are unreachable on touch devices and
// invisible to keyboard users — this readout fixes both.
const [selected, setSelected] = useState<number | null>(null);
if (!data.length) return null; if (!data.length) return null;
const maxTokens = Math.max(...data.map((d) => d.inputTokens + d.outputTokens), 1);
const maxTokens = Math.max(
...data.map((d) => d.inputTokens + d.outputTokens),
1
);
const barW = Math.max(4, Math.floor(600 / data.length) - 2); const barW = Math.max(4, Math.floor(600 / data.length) - 2);
const h = 120; const h = 120;
const activeIndex = selected ?? data.length - 1;
const active = data[activeIndex];
const dayLabel = (iso: string) => {
const [y, m, dd] = iso.split("-").map(Number);
return new Date(y, m - 1, dd).toLocaleDateString(locale, {
month: "short",
day: "numeric",
});
};
const barAria = (d: DailyUsage) =>
`${dayLabel(d.date)}: ${fmt(d.inputTokens)} ${t("inputTokens")}, ${fmt(
d.outputTokens
)} ${t("outputTokens")}, ${chf(d.spend)}`;
return ( return (
<div className="overflow-x-auto"> <div>
<svg {/* Readout — the touch/keyboard-accessible equivalent of the old
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`} hover-only tooltip. Always reflects the active day. */}
className="w-full h-36" <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-xs">
preserveAspectRatio="xMinYMid meet" <span className="font-medium text-text-primary">
> {dayLabel(active.date)}
{data.map((d, i) => { </span>
const total = d.inputTokens + d.outputTokens; <span className="text-text-secondary tabular-nums">
const totalH = (total / maxTokens) * h; {fmt(active.inputTokens)} {t("inputTokens")}
const inputH = (d.inputTokens / maxTokens) * h; </span>
const x = i * (barW + 2); <span className="text-text-secondary tabular-nums">
return ( {fmt(active.outputTokens)} {t("outputTokens")}
<g key={d.date}> </span>
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out {chf(d.spend)}</title> <span className="text-accent tabular-nums">{chf(active.spend)}</span>
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} /> </div>
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
{i % 7 === 0 && ( <div className="overflow-x-auto">
<text x={x + barW / 2} y={h + 14} textAnchor="middle" fill="var(--color-text-muted)" fontSize="8">{d.date.slice(8)}</text> <svg
)} viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
</g> className="w-full h-36"
); preserveAspectRatio="xMinYMid meet"
})} role="group"
</svg> aria-label={t("dailyBreakdown")}
>
{data.map((d, i) => {
const total = d.inputTokens + d.outputTokens;
const totalH = (total / maxTokens) * h;
const inputH = (d.inputTokens / maxTokens) * h;
const x = i * (barW + 2);
const isActive = i === activeIndex;
return (
<g
key={d.date}
role="button"
tabIndex={0}
aria-label={barAria(d)}
aria-pressed={isActive}
className="cursor-pointer focus:outline-none"
onClick={() => setSelected(i)}
onMouseEnter={() => setSelected(i)}
onFocus={() => setSelected(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelected(i);
}
}}
>
<title>{barAria(d)}</title>
{/* Full-height transparent hit area so thin bars stay
easy to tap on touch screens. */}
<rect x={x} y={0} width={barW} height={h} fill="transparent" />
<rect
x={x}
y={h - totalH}
width={barW}
height={Math.max(0, totalH - inputH)}
rx={1}
fill="var(--color-accent)"
opacity={isActive ? 0.5 : 0.3}
/>
<rect
x={x}
y={h - inputH}
width={barW}
height={inputH}
rx={1}
fill="var(--color-accent)"
opacity={isActive ? 1 : 0.7}
/>
{isActive && (
<rect
x={x - 1}
y={Math.max(0, h - totalH) - 1}
width={barW + 2}
height={Math.max(2, totalH) + 1}
rx={1.5}
fill="none"
stroke="var(--color-accent)"
strokeWidth={1}
/>
)}
{i % 7 === 0 && (
<text
x={x + barW / 2}
y={h + 14}
textAnchor="middle"
fill="var(--color-text-muted)"
fontSize="8"
>
{d.date.slice(8)}
</text>
)}
</g>
);
})}
</svg>
</div>
<div className="flex items-center gap-4 text-xs text-text-muted mt-1"> <div className="flex items-center gap-4 text-xs text-text-muted mt-1">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" /> Input <span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" />{" "}
{t("legendInput")}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" /> Output <span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" />{" "}
{t("legendOutput")}
</span> </span>
<span className="ml-auto text-text-muted/70">{t("chartHint")}</span>
</div> </div>
</div> </div>
); );
@@ -161,6 +268,7 @@ export function UsageDisplay({
canEditBudget?: boolean; canEditBudget?: boolean;
}) { }) {
const t = useTranslations("usage"); const t = useTranslations("usage");
const locale = useLocale();
const [month, setMonth] = useState(getCurrentMonth); const [month, setMonth] = useState(getCurrentMonth);
const [data, setData] = useState<UsageData | null>(null); const [data, setData] = useState<UsageData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -202,7 +310,7 @@ export function UsageDisplay({
</button> </button>
<span className="font-display text-sm font-medium text-text-primary"> <span className="font-display text-sm font-medium text-text-primary">
{formatMonth(month, "en")} {formatMonth(month, locale)}
</span> </span>
<button <button
onClick={() => setMonth((m) => shiftMonth(m, 1))} onClick={() => setMonth((m) => shiftMonth(m, 1))}

View File

@@ -31,6 +31,12 @@ interface OnboardingFlowProps {
* step. Forwarded straight to the wizard. * step. Forwarded straight to the wizard.
*/ */
setupFeeChf?: number | null; setupFeeChf?: number | null;
/**
* Recurring per-tenant monthly fee (net CHF). Forwarded to the
* wizard's review-step cost summary so the customer sees the ongoing
* commitment, not just the one-time setup fee.
*/
monthlyFeeChf?: number | null;
/** /**
* Bug 6: when present, the wizard is rendered in edit mode against * Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full * the given pending request. See `OnboardingWizard` for the full
@@ -59,6 +65,7 @@ export function OnboardingFlow({
hasOrgBilling, hasOrgBilling,
existingOrgBilling, existingOrgBilling,
setupFeeChf, setupFeeChf,
monthlyFeeChf,
editingRequest, editingRequest,
}: OnboardingFlowProps) { }: OnboardingFlowProps) {
const router = useRouter(); const router = useRouter();
@@ -71,6 +78,7 @@ export function OnboardingFlow({
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling} existingOrgBilling={existingOrgBilling}
setupFeeChf={setupFeeChf} setupFeeChf={setupFeeChf}
monthlyFeeChf={monthlyFeeChf}
editingRequest={editingRequest} editingRequest={editingRequest}
onComplete={() => { onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The // Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -432,25 +432,35 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
<span className="text-xs text-text-muted">{t("phase")}</span> <span className="text-xs text-text-muted">{t("phase")}</span>
<StatusBadge phase={phase} /> <StatusBadge phase={phase} />
</div> </div>
{conditions.map((c, i) => ( {/* Setup progress. The operator reports readiness as a list of
<div internal K8s conditions (OpenBao policy, LiteLLM key, network
key={i} policy, …) — meaningful to operators, jargon to customers.
className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2" We surface the *shape* of that progress (how many steps are
> done) without leaking the internal names. */}
<span className="text-xs text-text-muted">{c.type}</span> {conditions.length > 0 &&
<span (() => {
className={`text-xs font-mono ${ const done = conditions.filter((c) => c.status === "True").length;
c.status === "True" const total = conditions.length;
? "text-emerald-400" const pct = Math.round((done / total) * 100);
: c.status === "False" return (
? "text-red-400" <div className="bg-surface-2 border border-border rounded-lg px-4 py-3">
: "text-text-muted" <div className="flex items-center justify-between mb-2">
}`} <span className="text-xs text-text-muted">
> {t("setupProgress")}
{c.reason || c.status} </span>
</span> <span className="text-xs font-medium text-text-secondary tabular-nums">
</div> {t("setupStepsComplete", { done, total })}
))} </span>
</div>
<div className="h-1.5 w-full rounded-full bg-surface-3 overflow-hidden">
<div
className="h-full bg-accent transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})()}
</div> </div>
</Card> </Card>
); );
@@ -487,12 +497,27 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4"> <p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")} {t("readyDescription")}
</p> </p>
<button {(() => {
onClick={() => window.location.reload()} // Prefer deep-linking straight to the tenant page, where the
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors" // ConnectPanel shows how to start chatting. Fall back to a
> // reload only if we somehow don't have a tenant name yet.
{t("goToDashboard")} const tenantName = data.tenant?.name || data.request.tenantName;
</button> return tenantName ? (
<Link
href={`/tenants/${tenantName}`}
className="inline-block py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("connectCta")}
</Link>
) : (
<button
onClick={() => window.location.reload()}
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("goToDashboard")}
</button>
);
})()}
</div> </div>
</Card> </Card>
); );

View File

@@ -117,6 +117,13 @@ interface WizardProps {
* the order skips the Checkout redirect (handled server-side). * the order skips the Checkout redirect (handled server-side).
*/ */
setupFeeChf?: number | null; setupFeeChf?: number | null;
/**
* The platform's recurring per-tenant monthly fee (net CHF, before
* VAT). Shown on the review step alongside the setup fee so the
* customer sees the ongoing commitment — not just the one-time
* charge — before submitting. Null/0 hides the monthly line.
*/
monthlyFeeChf?: number | null;
/** /**
* Bug 6: when present, the wizard renders in "edit" mode — fields * Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is * are pre-populated from the request, the SOUL.md auto-fetch is
@@ -157,6 +164,7 @@ export function OnboardingWizard({
hasOrgBilling, hasOrgBilling,
existingOrgBilling, existingOrgBilling,
setupFeeChf, setupFeeChf,
monthlyFeeChf,
editingRequest, editingRequest,
onComplete, onComplete,
}: WizardProps) { }: WizardProps) {
@@ -420,18 +428,51 @@ export function OnboardingWizard({
[] []
); );
// Validate that all secret-requiring enabled packages have complete credentials // Enabled packages that still need something from the user before the
const packageCredentialsValid = (): boolean => { // configure step can advance — a missing credential field or an
// unaccepted disclaimer. Returns the package defs so the UI can name
// exactly what's blocking the (otherwise silently disabled) Next
// button instead of greying it out with no explanation.
const incompletePackages = (): PackageDef[] => {
const out: PackageDef[] = [];
for (const pkgId of config.packages) { for (const pkgId of config.packages) {
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId); const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
if (!def?.requiresSecrets) continue; if (!def) continue;
const secrets = packageSecrets[pkgId] || {}; let incomplete = false;
for (const field of def.secrets || []) { if (def.requiresSecrets) {
if (!secrets[field.key]?.trim()) return false; const secrets = packageSecrets[pkgId] || {};
for (const field of def.secrets || []) {
if (!secrets[field.key]?.trim()) {
incomplete = true;
break;
}
}
} }
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false; if (def.disclaimerKey && !disclaimerAccepted[pkgId]) incomplete = true;
if (incomplete) out.push(def);
} }
return true; return out;
};
const packageCredentialsValid = (): boolean =>
incompletePackages().length === 0;
// Map zod field paths to human labels for the confirm-step error
// summary, so a stray validation failure reads "Postal code" rather
// than "billingAddress.postalCode". Unknown paths fall back to the
// raw path (this defence-in-depth list should rarely render at all).
const fieldLabel = (path: string): string => {
const map: Record<string, string> = {
instanceName: t("instanceName"),
agentName: t("agentName"),
"billingAddress.company": t("billingCompany"),
"billingAddress.street": t("billingStreet"),
"billingAddress.postalCode": t("billingPostalCode"),
"billingAddress.city": t("billingCity"),
"billingAddress.country": t("billingCountry"),
"billingAddress.vatNumber": t("billingVatNumber"),
};
return map[path] ?? path;
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -984,20 +1025,33 @@ export function OnboardingWizard({
</div> </div>
</div> </div>
<div className="flex justify-between mt-6"> <div className="mt-6">
<button {(() => {
onClick={goBack} const blocking = incompletePackages();
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors" if (blocking.length === 0) return null;
> return (
{t("back")} <p className="text-xs text-amber-400/90 mb-3 text-right">
</button> {t("packagesIncompleteHint", {
<button packages: blocking.map((p) => p.name).join(", "),
onClick={goNext} })}
disabled={!packageCredentialsValid()} </p>
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed" );
> })()}
{t("next")} <div className="flex justify-between">
</button> <button
onClick={goBack}
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t("back")}
</button>
<button
onClick={goNext}
disabled={!packageCredentialsValid()}
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("next")}
</button>
</div>
</div> </div>
</Card> </Card>
)} )}
@@ -1336,28 +1390,46 @@ export function OnboardingWizard({
<p className="text-xs text-text-muted">{t("confirmNote")}</p> <p className="text-xs text-text-muted">{t("confirmNote")}</p>
{/* Phase 9b: order-time setup-fee notice + amount. The {/* Cost summary. Surfaces the full commitment before
figure shown is the net platform fee (before VAT); submitting — not just the one-time setup fee but the
VAT is added server-side based on the billing recurring monthly per-assistant fee and the fact that
country. We show "+ VAT" rather than a computed AI usage is billed by consumption (with the budget-cap
gross to avoid mis-displaying a country-dependent control as the reassurance). All figures are net (before
total. If setupFeeChf is null/0, no charge happens VAT); VAT is added server-side per billing country, so
and the whole block is suppressed. */} we show "+ VAT" rather than a country-dependent gross.
{typeof setupFeeChf === "number" && setupFeeChf > 0 && ( The block is suppressed only when there are no fixed
fees at all. */}
{((typeof setupFeeChf === "number" && setupFeeChf > 0) ||
(typeof monthlyFeeChf === "number" && monthlyFeeChf > 0)) && (
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4"> <div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
<strong className="block text-text-primary mb-1"> <strong className="block text-text-primary mb-2">
{t("setupFeeNoticeHeading")} {t("costSummaryHeading")}
</strong> </strong>
<div className="flex items-baseline justify-between mb-2 pb-2 border-b border-accent/20"> {typeof setupFeeChf === "number" && setupFeeChf > 0 && (
<span>{t("setupFeeAmountLabel")}</span> <div className="flex items-baseline justify-between mb-1.5">
<span className="text-sm font-semibold text-text-primary"> <span>{t("costSetupLabel")}</span>
CHF {setupFeeChf.toFixed(2)}{" "} <span className="text-sm font-semibold text-text-primary">
<span className="text-[10px] font-normal text-text-muted"> CHF {setupFeeChf.toFixed(2)}{" "}
{t("setupFeePlusVat")} <span className="text-[10px] font-normal text-text-muted">
{t("setupFeePlusVat")}
</span>
</span> </span>
</span> </div>
)}
{typeof monthlyFeeChf === "number" && monthlyFeeChf > 0 && (
<div className="flex items-baseline justify-between mb-1.5">
<span>{t("costMonthlyLabel")}</span>
<span className="text-sm font-semibold text-text-primary">
CHF {monthlyFeeChf.toFixed(2)}{" "}
<span className="text-[10px] font-normal text-text-muted">
{t("setupFeePlusVat")}
</span>
</span>
</div>
)}
<div className="mt-2 pt-2 border-t border-accent/20 leading-relaxed">
{t("costUsageNote")}
</div> </div>
{t("setupFeeNoticeBody")}
</div> </div>
)} )}
</div> </div>
@@ -1380,7 +1452,8 @@ export function OnboardingWizard({
<ul className="list-disc list-inside space-y-0.5"> <ul className="list-disc list-inside space-y-0.5">
{Object.entries(errors).map(([path, msg]) => ( {Object.entries(errors).map(([path, msg]) => (
<li key={path}> <li key={path}>
<span className="font-mono">{path}</span>: {msg} <span className="font-medium">{fieldLabel(path)}</span>:{" "}
{msg}
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -0,0 +1,219 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
/**
* AccessOverview
*
* Read-only "who can reach which assistant" matrix for owners. Access
* was previously only visible per-tenant (the AssignedUsersPanel on each
* tenant page) and per-member (the team roster) — with no single place
* to see the whole picture, which made it easy to lose track across
* several tenants and members.
*
* This composes existing endpoints only (no new API surface):
* - GET /api/team → org members
* - GET /api/tenants → the org's tenants
* - GET /api/tenants/{name}/assignments → per-tenant assignees
*
* Owners implicitly see every tenant, so their row is marked
* "all assistants" rather than per-cell.
*/
interface Member {
userId: string;
email: string;
displayName?: string;
roles: string[];
}
interface TenantLite {
name: string;
displayName: string;
}
export function AccessOverview() {
const t = useTranslations("team");
const [members, setMembers] = useState<Member[] | null>(null);
const [tenants, setTenants] = useState<TenantLite[] | null>(null);
// tenant name → set of assigned userIds
const [assignments, setAssignments] = useState<Record<string, Set<string>>>(
{}
);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [teamRes, tenantsRes] = await Promise.all([
fetch("/api/team"),
fetch("/api/tenants"),
]);
if (!teamRes.ok || !tenantsRes.ok) throw new Error("load");
const teamData = await teamRes.json();
const tenantsData = await tenantsRes.json();
const mem: Member[] = teamData.members ?? [];
const ten: TenantLite[] = (tenantsData ?? []).map((x: any) => ({
name: x.metadata.name,
displayName: x.spec?.displayName || x.metadata.name,
}));
// Per-tenant assignment lookups in parallel. A failed lookup
// degrades to "no assignees" for that tenant rather than
// failing the whole view.
const entries = await Promise.all(
ten.map(async (tn) => {
try {
const r = await fetch(
`/api/tenants/${encodeURIComponent(tn.name)}/assignments`
);
if (!r.ok) return [tn.name, new Set<string>()] as const;
const data = await r.json();
const ids = new Set<string>(
(data.assignments ?? data ?? []).map((a: any) => a.userId)
);
return [tn.name, ids] as const;
} catch {
return [tn.name, new Set<string>()] as const;
}
})
);
if (cancelled) return;
setMembers(mem);
setTenants(ten);
setAssignments(Object.fromEntries(entries));
} catch {
if (!cancelled) setError(t("accessLoadFailed"));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [t]);
if (loading) {
return (
<div className="bg-surface-1 border border-border rounded-xl p-6 animate-pulse">
<div className="h-4 w-40 bg-surface-3 rounded mb-4" />
<div className="h-24 bg-surface-2 rounded" />
</div>
);
}
if (error) {
return (
<div className="bg-surface-1 border border-border rounded-xl p-6">
<p className="text-sm text-text-secondary">{error}</p>
</div>
);
}
if (!tenants || tenants.length === 0) {
return (
<div className="bg-surface-1 border border-border rounded-xl p-6">
<p className="text-sm text-text-secondary">{t("accessNoTenants")}</p>
</div>
);
}
const isOwner = (m: Member) => m.roles?.includes("owner");
return (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted sticky left-0 bg-surface-1">
{t("accessMemberCol")}
</th>
{tenants.map((tn) => (
<th
key={tn.name}
className="px-3 py-3 text-center text-xs font-semibold text-text-secondary min-w-[7rem]"
title={tn.name}
>
{tn.displayName}
</th>
))}
</tr>
</thead>
<tbody>
{(members ?? []).map((m) => (
<tr
key={m.userId}
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
>
<td className="px-4 py-3 sticky left-0 bg-surface-1">
<div className="text-sm text-text-primary truncate max-w-[14rem]">
{m.displayName || m.email}
</div>
<div className="text-xs text-text-muted truncate max-w-[14rem]">
{m.email}
</div>
</td>
{tenants.map((tn) => {
const owner = isOwner(m);
const has = owner || assignments[tn.name]?.has(m.userId);
const label = owner
? t("accessOwnerAll")
: has
? t("accessHasLabel")
: t("accessHasNotLabel");
return (
<td
key={tn.name}
className="px-3 py-3 text-center"
title={label}
>
<span className="sr-only">{label}</span>
{owner ? (
<span aria-hidden="true" className="text-accent">
</span>
) : has ? (
<span
aria-hidden="true"
className="text-emerald-400 font-semibold"
>
</span>
) : (
<span aria-hidden="true" className="text-text-muted/50">
</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="px-4 py-2.5 border-t border-border flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-muted">
<span className="flex items-center gap-1.5">
<span className="text-accent"></span> {t("accessOwnerAll")}
</span>
<span className="flex items-center gap-1.5">
<span className="text-emerald-400 font-semibold"></span>{" "}
{t("accessHasLabel")}
</span>
<span className="flex items-center gap-1.5">
<span className="text-text-muted/50"></span> {t("accessHasNotLabel")}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useTranslations } from "next-intl";
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
/**
* ConnectPanel
*
* The portal is a *management* console — config, billing, usage — but
* the assistant itself lives in the customer's messaging app. Nothing
* previously told the customer how to actually start talking to the
* thing they just provisioned ("Your assistant is ready… now what?").
*
* This panel closes that gap on the tenant-detail page: for each
* enabled channel it shows the concrete first-contact steps, and when
* NO channel is enabled it says so explicitly (a running assistant with
* no channel is unreachable).
*
* It is intentionally complementary to ChannelUsers below it:
* - ConnectPanel → "how do *I* reach the assistant"
* - ChannelUsers → "*who* is allowed to reach it"
* The Threema/Telegram/Discord steps reference the authorised-users
* list rather than duplicating it.
*/
// Render order is fixed (not the order packages happen to appear in
// spec.packages) so the panel layout is stable across tenants.
const CHANNEL_ORDER = ["threema", "telegram", "discord"] as const;
const CHANNEL_NAMES: Record<string, string> = {
threema: "Threema",
telegram: "Telegram",
discord: "Discord",
};
// Per-channel instruction key in the `connect` message namespace.
const CHANNEL_STEPS_KEY: Record<string, string> = {
threema: "threemaSteps",
telegram: "telegramSteps",
discord: "discordSteps",
};
export function ConnectPanel({
enabledChannels,
phase,
}: {
enabledChannels: string[];
/** Tenant phase — connection details only "work" once it's Ready. */
phase: string;
}) {
const t = useTranslations("connect");
const channels = CHANNEL_ORDER.filter((c) => enabledChannels.includes(c));
const ready = phase === "Ready" || phase === "Running" || phase === "Active";
// No channel at all → the assistant is unreachable. Make it loud.
if (channels.length === 0) {
return (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5">
<div className="flex items-start gap-3">
<svg
className="h-5 w-5 text-amber-400 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zM12 15.75h.008v.008H12v-.008z"
/>
</svg>
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-300">
{t("noChannelsTitle")}
</div>
<p className="text-xs text-text-secondary mt-1 leading-relaxed">
{t("noChannelsBody")}
</p>
</div>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-accent/30 bg-accent/5 p-5">
<h2 className="font-display text-base font-semibold text-text-primary mb-1">
{t("title")}
</h2>
<p className="text-xs text-text-secondary mb-4 leading-relaxed">
{t("description")}
</p>
{!ready && (
<p className="text-xs text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-lg px-3 py-2 mb-4 leading-relaxed">
{t("notReadyNote")}
</p>
)}
<div className="space-y-3">
{channels.map((c) => (
<div
key={c}
className="rounded-lg border border-border bg-surface-1 p-3"
>
<div className="text-sm font-medium text-text-primary mb-1.5">
{CHANNEL_NAMES[c]}
</div>
{c === "threema" ? (
<div className="flex items-start gap-3">
<div className="bg-white p-1.5 rounded-md shrink-0">
{/* Shared gateway QR — identical for every tenant, so
it can render before/after provisioning alike.
eslint-disable-next-line @next/next/no-img-element */}
<img
src={THREEMA_GATEWAY.qrCodePath}
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
width={88}
height={88}
style={{ display: "block" }}
/>
</div>
<div className="text-xs text-text-secondary leading-relaxed">
<div className="mb-1.5">
<span className="text-text-muted">
{t("threemaBotIdLabel")}:{" "}
</span>
<span className="font-mono text-sm text-accent">
{THREEMA_GATEWAY.displayName}
</span>
</div>
<div className="whitespace-pre-line">{t("threemaSteps")}</div>
</div>
</div>
) : (
<p className="text-xs text-text-secondary leading-relaxed whitespace-pre-line">
{t(CHANNEL_STEPS_KEY[c])}
</p>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -94,7 +94,7 @@
"provisioningDescription": "Ihr KI-Assistent wird bereitgestellt. Dies dauert in der Regel wenige Minuten.", "provisioningDescription": "Ihr KI-Assistent wird bereitgestellt. Dies dauert in der Regel wenige Minuten.",
"phase": "Phase", "phase": "Phase",
"readyTitle": "Ihr Assistent ist bereit!", "readyTitle": "Ihr Assistent ist bereit!",
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.", "readyDescription": "Ihr KI-Assistent wurde bereitgestellt und läuft. Verbinden Sie ihn als Nächstes mit Ihrer Messaging-App, um den Chat zu starten.",
"goToDashboard": "Zum Dashboard", "goToDashboard": "Zum Dashboard",
"submittedAt": "Eingereicht", "submittedAt": "Eingereicht",
"instanceName": "Instanzname", "instanceName": "Instanzname",
@@ -143,7 +143,15 @@
"telegram": "Öffnen Sie Telegram, schreiben Sie an @userinfobot und fügen Sie die zurückgegebene numerische ID hier ein. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.", "telegram": "Öffnen Sie Telegram, schreiben Sie an @userinfobot und fügen Sie die zurückgegebene numerische ID hier ein. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.", "discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
"threema": "Die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Sobald Ihr Mandant freigegeben ist und Threema aktiviert wurde, können Sie aus diesem Account heraus mit dem Assistenten chatten. Weitere autorisierte IDs können später auf der Mandantenseite hinzugefügt werden." "threema": "Die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Sobald Ihr Mandant freigegeben ist und Threema aktiviert wurde, können Sie aus diesem Account heraus mit dem Assistenten chatten. Weitere autorisierte IDs können später auf der Mandantenseite hinzugefügt werden."
} },
"connectCta": "Assistenten verbinden",
"packagesIncompleteHint": "Bitte ergänzen Sie die erforderlichen Angaben für: {packages}",
"setupProgress": "Einrichtungsfortschritt",
"setupStepsComplete": "{done} von {total} Schritten",
"costSummaryHeading": "Was Sie bezahlen",
"costSetupLabel": "Einmalige Einrichtung",
"costMonthlyLabel": "Monatlich, pro Assistent",
"costUsageNote": "Zuzüglich nutzungsabhängiger KI-Kosten, monatlich in CHF abgerechnet. Sie können jederzeit ein Ausgabenlimit pro Assistent festlegen."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -226,7 +234,10 @@
"budgetCadence_1mo": "Monatlich", "budgetCadence_1mo": "Monatlich",
"budgetCadence_1y": "Jährlich", "budgetCadence_1y": "Jährlich",
"budgetInvalid": "Bitte einen positiven Betrag eingeben.", "budgetInvalid": "Bitte einen positiven Betrag eingeben.",
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen." "budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen.",
"legendInput": "Input",
"legendOutput": "Output",
"chartHint": "Für Details auf einen Balken tippen"
}, },
"workspace": { "workspace": {
"save": "Speichern", "save": "Speichern",
@@ -421,7 +432,11 @@
"openclawTool": "OpenClaw-Versionen", "openclawTool": "OpenClaw-Versionen",
"billingTool": "Abrechnung →", "billingTool": "Abrechnung →",
"skillsQueueTool": "Aktivierungs-Warteschlange", "skillsQueueTool": "Aktivierungs-Warteschlange",
"cronTool": "Automatisierung" "cronTool": "Automatisierung",
"approveTitle": "Anfrage genehmigen?",
"approveWarning": "Dadurch wird die Infrastruktur des Mandanten bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt. Bitte prüfen Sie die Angaben, bevor Sie fortfahren.",
"approveReapproveWarning": "Dies genehmigt eine zuvor abgelehnte Anfrage erneut: Die Infrastruktur des Mandanten wird bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt.",
"confirmApprove": "Genehmigen & bereitstellen"
}, },
"channelUsers": { "channelUsers": {
"title": "Autorisierte Benutzer", "title": "Autorisierte Benutzer",
@@ -468,7 +483,15 @@
"roleUpdateFailed": "Rolle konnte nicht aktualisiert werden.", "roleUpdateFailed": "Rolle konnte nicht aktualisiert werden.",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"save": "Speichern", "save": "Speichern",
"selfChangeBlocked": "Sie können Ihre eigene Rolle nicht ändern." "selfChangeBlocked": "Sie können Ihre eigene Rolle nicht ändern.",
"accessTitle": "Zugriffsübersicht",
"accessDescription": "Welches Mitglied auf welchen Assistenten zugreifen kann.",
"accessMemberCol": "Mitglied",
"accessOwnerAll": "Alle Assistenten (Eigentümer)",
"accessHasLabel": "Zugriff",
"accessHasNotLabel": "Kein Zugriff",
"accessNoTenants": "Noch keine Assistenten.",
"accessLoadFailed": "Zugriffsübersicht konnte nicht geladen werden."
}, },
"assignments": { "assignments": {
"loading": "Zuweisungen werden geladen…", "loading": "Zuweisungen werden geladen…",
@@ -972,5 +995,16 @@
"backToDashboard": "Zurück zum Dashboard", "backToDashboard": "Zurück zum Dashboard",
"notFoundTitle": "Seite nicht gefunden", "notFoundTitle": "Seite nicht gefunden",
"notFoundDescription": "Die angeforderte Seite existiert nicht oder wurde verschoben." "notFoundDescription": "Die angeforderte Seite existiert nicht oder wurde verschoben."
},
"connect": {
"title": "Mit Ihrem Assistenten verbinden",
"description": "Ihr Assistent läuft in Ihrer Messaging-App. So beginnen Sie den Chat mit ihm.",
"notReadyNote": "Ihr Assistent wird noch eingerichtet. Diese Verbindungsdetails funktionieren, sobald er bereit ist.",
"noChannelsTitle": "Noch kein Messaging-Kanal",
"noChannelsBody": "Ihr Assistent läuft, hat aber keinen Kanal zum Chatten. Aktivieren Sie unten im Bereich Pakete einen Kanal Threema, Telegram oder Discord , um ihn zu nutzen.",
"threemaBotIdLabel": "Threema-ID",
"threemaSteps": "1. Öffnen Sie Threema und scannen Sie diesen QR-Code (oder fügen Sie die obige ID als Kontakt hinzu).\n2. Senden Sie eine Nachricht, um den Chat zu starten.\nStellen Sie sicher, dass Ihre eigene Threema-ID in der Liste der autorisierten Benutzer unten steht nur gelistete IDs erhalten eine Antwort.",
"telegramSteps": "Öffnen Sie den verbundenen Telegram-Bot und senden Sie ihm eine Nachricht, um den Chat zu starten. Nur die Benutzer-IDs in der Liste der autorisierten Benutzer unten erhalten eine Antwort.",
"discordSteps": "Schreiben Sie dem verbundenen Discord-Bot oder erwähnen Sie ihn in einem Kanal, dem er beigetreten ist. Nur die Benutzer-IDs in der Liste der autorisierten Benutzer unten erhalten eine Antwort."
} }
} }

View File

@@ -94,7 +94,7 @@
"provisioningDescription": "Your AI assistant is being provisioned. This usually takes a few minutes.", "provisioningDescription": "Your AI assistant is being provisioned. This usually takes a few minutes.",
"phase": "Phase", "phase": "Phase",
"readyTitle": "Your assistant is ready!", "readyTitle": "Your assistant is ready!",
"readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.", "readyDescription": "Your AI assistant has been provisioned and is running. Next, connect it to your messaging app to start chatting.",
"goToDashboard": "Go to Dashboard", "goToDashboard": "Go to Dashboard",
"submittedAt": "Submitted", "submittedAt": "Submitted",
"instanceName": "Instance name", "instanceName": "Instance name",
@@ -143,7 +143,15 @@
"telegram": "Open Telegram, message @userinfobot, and paste the numeric id it returns. You can add more users later from the tenant page.", "telegram": "Open Telegram, message @userinfobot, and paste the numeric id it returns. You can add more users later from the tenant page.",
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.", "discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
"threema": "The 8 characters shown in your Threema app under Settings → My Threema ID. Once your tenant is approved and Threema is enabled, you'll be able to chat with the assistant from this account. More authorized IDs can be added later from the tenant page." "threema": "The 8 characters shown in your Threema app under Settings → My Threema ID. Once your tenant is approved and Threema is enabled, you'll be able to chat with the assistant from this account. More authorized IDs can be added later from the tenant page."
} },
"connectCta": "Connect your assistant",
"packagesIncompleteHint": "Add the required details for: {packages}",
"setupProgress": "Setup progress",
"setupStepsComplete": "{done} of {total} steps",
"costSummaryHeading": "What you'll pay",
"costSetupLabel": "One-time setup",
"costMonthlyLabel": "Monthly, per assistant",
"costUsageNote": "Plus usage-based AI costs, billed monthly in CHF. You can set a spending cap per assistant at any time."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -226,7 +234,10 @@
"budgetCadence_1mo": "Monthly", "budgetCadence_1mo": "Monthly",
"budgetCadence_1y": "Yearly", "budgetCadence_1y": "Yearly",
"budgetInvalid": "Please enter a positive amount.", "budgetInvalid": "Please enter a positive amount.",
"budgetSaveFailed": "Could not save budget. Please try again." "budgetSaveFailed": "Could not save budget. Please try again.",
"legendInput": "Input",
"legendOutput": "Output",
"chartHint": "Tap a bar for that day"
}, },
"workspace": { "workspace": {
"save": "Save", "save": "Save",
@@ -421,7 +432,11 @@
"openclawTool": "OpenClaw versions", "openclawTool": "OpenClaw versions",
"billingTool": "Billing →", "billingTool": "Billing →",
"skillsQueueTool": "Activation Queue", "skillsQueueTool": "Activation Queue",
"cronTool": "Automation" "cronTool": "Automation",
"approveTitle": "Approve request?",
"approveWarning": "This provisions the tenant's infrastructure, charges the setup fee, and notifies the customer. Check the request details are correct before continuing.",
"approveReapproveWarning": "This re-approves a previously rejected request: it provisions the tenant's infrastructure, charges the setup fee, and notifies the customer.",
"confirmApprove": "Approve & provision"
}, },
"channelUsers": { "channelUsers": {
"title": "Authorized Users", "title": "Authorized Users",
@@ -468,7 +483,15 @@
"roleUpdateFailed": "Could not update role.", "roleUpdateFailed": "Could not update role.",
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save", "save": "Save",
"selfChangeBlocked": "You cannot change your own role." "selfChangeBlocked": "You cannot change your own role.",
"accessTitle": "Access overview",
"accessDescription": "Which member can reach which assistant.",
"accessMemberCol": "Member",
"accessOwnerAll": "All assistants (owner)",
"accessHasLabel": "Has access",
"accessHasNotLabel": "No access",
"accessNoTenants": "No assistants yet.",
"accessLoadFailed": "Couldn't load the access overview."
}, },
"assignments": { "assignments": {
"loading": "Loading assignments…", "loading": "Loading assignments…",
@@ -972,5 +995,16 @@
"backToDashboard": "Back to dashboard", "backToDashboard": "Back to dashboard",
"notFoundTitle": "Page not found", "notFoundTitle": "Page not found",
"notFoundDescription": "The page you're looking for doesn't exist or has moved." "notFoundDescription": "The page you're looking for doesn't exist or has moved."
},
"connect": {
"title": "Connect to your assistant",
"description": "Your assistant runs inside your messaging app. Here's how to start chatting with it.",
"notReadyNote": "Your assistant is still being set up. These connection details will work as soon as it's ready.",
"noChannelsTitle": "No messaging channel yet",
"noChannelsBody": "Your assistant is running but has no channel to chat through. Enable a channel — Threema, Telegram, or Discord — in the Packages section below to start using it.",
"threemaBotIdLabel": "Threema ID",
"threemaSteps": "1. Open Threema and scan this QR code (or add the ID above as a contact).\n2. Send it a message to start chatting.\nMake sure your own Threema ID is on the authorised users list below — only listed IDs get a reply.",
"telegramSteps": "Open the Telegram bot you connected and send it a message to start chatting. Only the user IDs on the authorised users list below get a reply.",
"discordSteps": "Message the Discord bot you connected, or mention it in a channel it has joined. Only the user IDs on the authorised users list below get a reply."
} }
} }

View File

@@ -94,7 +94,7 @@
"provisioningDescription": "Votre assistant IA est en cours de mise en service. Cela prend généralement quelques minutes.", "provisioningDescription": "Votre assistant IA est en cours de mise en service. Cela prend généralement quelques minutes.",
"phase": "Phase", "phase": "Phase",
"readyTitle": "Votre assistant est prêt !", "readyTitle": "Votre assistant est prêt !",
"readyDescription": "Votre assistant IA a été mis en service et est actif. Vous pouvez maintenant le gérer depuis le tableau de bord.", "readyDescription": "Votre assistant IA a été provisionné et fonctionne. Connectez-le maintenant à votre application de messagerie pour commencer à discuter.",
"goToDashboard": "Aller au tableau de bord", "goToDashboard": "Aller au tableau de bord",
"submittedAt": "Soumis", "submittedAt": "Soumis",
"instanceName": "Nom de l'instance", "instanceName": "Nom de l'instance",
@@ -143,7 +143,15 @@
"telegram": "Ouvrez Telegram, écrivez à @userinfobot et collez l'ID numérique qu'il retourne. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.", "telegram": "Ouvrez Telegram, écrivez à @userinfobot et collez l'ID numérique qu'il retourne. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.", "discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
"threema": "Les 8 caractères affichés dans votre app Threema sous Réglages → Mon identifiant Threema. Une fois votre tenant approuvé et Threema activé, vous pourrez discuter avec l'assistant depuis ce compte. D'autres ID autorisés peuvent être ajoutés plus tard depuis la page du tenant." "threema": "Les 8 caractères affichés dans votre app Threema sous Réglages → Mon identifiant Threema. Une fois votre tenant approuvé et Threema activé, vous pourrez discuter avec l'assistant depuis ce compte. D'autres ID autorisés peuvent être ajoutés plus tard depuis la page du tenant."
} },
"connectCta": "Connecter votre assistant",
"packagesIncompleteHint": "Complétez les informations requises pour : {packages}",
"setupProgress": "Progression de la configuration",
"setupStepsComplete": "{done} sur {total} étapes",
"costSummaryHeading": "Ce que vous paierez",
"costSetupLabel": "Installation unique",
"costMonthlyLabel": "Mensuel, par assistant",
"costUsageNote": "Plus les coûts d'IA à l'usage, facturés mensuellement en CHF. Vous pouvez définir un plafond de dépenses par assistant à tout moment."
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -226,7 +234,10 @@
"budgetCadence_1mo": "Mensuelle", "budgetCadence_1mo": "Mensuelle",
"budgetCadence_1y": "Annuelle", "budgetCadence_1y": "Annuelle",
"budgetInvalid": "Veuillez saisir un montant positif.", "budgetInvalid": "Veuillez saisir un montant positif.",
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer." "budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer.",
"legendInput": "Entrée",
"legendOutput": "Sortie",
"chartHint": "Touchez une barre pour le détail"
}, },
"workspace": { "workspace": {
"save": "Enregistrer", "save": "Enregistrer",
@@ -421,7 +432,11 @@
"openclawTool": "Versions OpenClaw", "openclawTool": "Versions OpenClaw",
"billingTool": "Facturation →", "billingTool": "Facturation →",
"skillsQueueTool": "File d'activation", "skillsQueueTool": "File d'activation",
"cronTool": "Automatisation" "cronTool": "Automatisation",
"approveTitle": "Approuver la demande ?",
"approveWarning": "Cela provisionne l'infrastructure du locataire, facture les frais d'installation et notifie le client. Vérifiez l'exactitude des détails de la demande avant de continuer.",
"approveReapproveWarning": "Ceci réapprouve une demande précédemment rejetée : l'infrastructure du locataire est provisionnée, les frais d'installation sont facturés et le client est notifié.",
"confirmApprove": "Approuver et provisionner"
}, },
"channelUsers": { "channelUsers": {
"title": "Utilisateurs autorisés", "title": "Utilisateurs autorisés",
@@ -468,7 +483,15 @@
"roleUpdateFailed": "Impossible de mettre à jour le rôle.", "roleUpdateFailed": "Impossible de mettre à jour le rôle.",
"cancel": "Annuler", "cancel": "Annuler",
"save": "Enregistrer", "save": "Enregistrer",
"selfChangeBlocked": "Vous ne pouvez pas modifier votre propre rôle." "selfChangeBlocked": "Vous ne pouvez pas modifier votre propre rôle.",
"accessTitle": "Aperçu des accès",
"accessDescription": "Quel membre peut accéder à quel assistant.",
"accessMemberCol": "Membre",
"accessOwnerAll": "Tous les assistants (propriétaire)",
"accessHasLabel": "Accès",
"accessHasNotLabel": "Aucun accès",
"accessNoTenants": "Aucun assistant pour l'instant.",
"accessLoadFailed": "Impossible de charger l'aperçu des accès."
}, },
"assignments": { "assignments": {
"loading": "Chargement des attributions…", "loading": "Chargement des attributions…",
@@ -972,5 +995,16 @@
"backToDashboard": "Retour au tableau de bord", "backToDashboard": "Retour au tableau de bord",
"notFoundTitle": "Page introuvable", "notFoundTitle": "Page introuvable",
"notFoundDescription": "La page que vous recherchez n'existe pas ou a été déplacée." "notFoundDescription": "La page que vous recherchez n'existe pas ou a été déplacée."
},
"connect": {
"title": "Connectez-vous à votre assistant",
"description": "Votre assistant fonctionne dans votre application de messagerie. Voici comment commencer à discuter avec lui.",
"notReadyNote": "Votre assistant est encore en cours de configuration. Ces informations de connexion fonctionneront dès qu'il sera prêt.",
"noChannelsTitle": "Aucun canal de messagerie",
"noChannelsBody": "Votre assistant fonctionne mais n'a aucun canal pour discuter. Activez un canal — Threema, Telegram ou Discord — dans la section Forfaits ci-dessous pour commencer à l'utiliser.",
"threemaBotIdLabel": "Identifiant Threema",
"threemaSteps": "1. Ouvrez Threema et scannez ce QR code (ou ajoutez l'identifiant ci-dessus comme contact).\n2. Envoyez-lui un message pour commencer à discuter.\nAssurez-vous que votre propre identifiant Threema figure dans la liste des utilisateurs autorisés ci-dessous — seuls les identifiants listés reçoivent une réponse.",
"telegramSteps": "Ouvrez le bot Telegram que vous avez connecté et envoyez-lui un message pour commencer à discuter. Seuls les identifiants utilisateur de la liste des utilisateurs autorisés ci-dessous reçoivent une réponse.",
"discordSteps": "Écrivez au bot Discord que vous avez connecté, ou mentionnez-le dans un salon qu'il a rejoint. Seuls les identifiants utilisateur de la liste des utilisateurs autorisés ci-dessous reçoivent une réponse."
} }
} }

View File

@@ -94,7 +94,7 @@
"provisioningDescription": "Il suo assistente IA è in fase di attivazione. Di solito richiede pochi minuti.", "provisioningDescription": "Il suo assistente IA è in fase di attivazione. Di solito richiede pochi minuti.",
"phase": "Fase", "phase": "Fase",
"readyTitle": "Il suo assistente è pronto!", "readyTitle": "Il suo assistente è pronto!",
"readyDescription": "Il suo assistente IA è stato attivato ed è operativo. Ora può gestirlo dalla dashboard.", "readyDescription": "Il tuo assistente IA è stato provisionato ed è in funzione. Ora collegalo alla tua app di messaggistica per iniziare a chattare.",
"goToDashboard": "Vada alla dashboard", "goToDashboard": "Vada alla dashboard",
"submittedAt": "Inviato", "submittedAt": "Inviato",
"instanceName": "Nome istanza", "instanceName": "Nome istanza",
@@ -143,7 +143,15 @@
"telegram": "Apra Telegram, scriva a @userinfobot e incolli qui l'ID numerico restituito. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.", "telegram": "Apra Telegram, scriva a @userinfobot e incolli qui l'ID numerico restituito. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.", "discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
"threema": "Gli 8 caratteri mostrati nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato il suo tenant e attivato Threema, potrà chattare con l'assistente da questo account. Altri ID autorizzati possono essere aggiunti in seguito dalla pagina del tenant." "threema": "Gli 8 caratteri mostrati nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato il suo tenant e attivato Threema, potrà chattare con l'assistente da questo account. Altri ID autorizzati possono essere aggiunti in seguito dalla pagina del tenant."
} },
"connectCta": "Collega il tuo assistente",
"packagesIncompleteHint": "Completa i dettagli richiesti per: {packages}",
"setupProgress": "Avanzamento configurazione",
"setupStepsComplete": "{done} di {total} passaggi",
"costSummaryHeading": "Quanto pagherai",
"costSetupLabel": "Attivazione una tantum",
"costMonthlyLabel": "Mensile, per assistente",
"costUsageNote": "Più i costi dell'IA in base all'utilizzo, fatturati mensilmente in CHF. Puoi impostare un limite di spesa per assistente in qualsiasi momento."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -226,7 +234,10 @@
"budgetCadence_1mo": "Mensile", "budgetCadence_1mo": "Mensile",
"budgetCadence_1y": "Annuale", "budgetCadence_1y": "Annuale",
"budgetInvalid": "Inserisca un importo positivo.", "budgetInvalid": "Inserisca un importo positivo.",
"budgetSaveFailed": "Impossibile salvare il budget. Riprova." "budgetSaveFailed": "Impossibile salvare il budget. Riprova.",
"legendInput": "Input",
"legendOutput": "Output",
"chartHint": "Tocca una barra per i dettagli"
}, },
"workspace": { "workspace": {
"save": "Salvi", "save": "Salvi",
@@ -421,7 +432,11 @@
"openclawTool": "Versioni OpenClaw", "openclawTool": "Versioni OpenClaw",
"billingTool": "Fatturazione →", "billingTool": "Fatturazione →",
"skillsQueueTool": "Coda di attivazione", "skillsQueueTool": "Coda di attivazione",
"cronTool": "Automazione" "cronTool": "Automazione",
"approveTitle": "Approvare la richiesta?",
"approveWarning": "Questa operazione effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente. Verifica che i dettagli della richiesta siano corretti prima di continuare.",
"approveReapproveWarning": "Questo riapprova una richiesta precedentemente rifiutata: effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente.",
"confirmApprove": "Approva e avvia provisioning"
}, },
"channelUsers": { "channelUsers": {
"title": "Utenti autorizzati", "title": "Utenti autorizzati",
@@ -468,7 +483,15 @@
"roleUpdateFailed": "Impossibile aggiornare il ruolo.", "roleUpdateFailed": "Impossibile aggiornare il ruolo.",
"cancel": "Annulli", "cancel": "Annulli",
"save": "Salvi", "save": "Salvi",
"selfChangeBlocked": "Non può modificare il suo ruolo." "selfChangeBlocked": "Non può modificare il suo ruolo.",
"accessTitle": "Panoramica accessi",
"accessDescription": "Quale membro può accedere a quale assistente.",
"accessMemberCol": "Membro",
"accessOwnerAll": "Tutti gli assistenti (proprietario)",
"accessHasLabel": "Accesso",
"accessHasNotLabel": "Nessun accesso",
"accessNoTenants": "Ancora nessun assistente.",
"accessLoadFailed": "Impossibile caricare la panoramica degli accessi."
}, },
"assignments": { "assignments": {
"loading": "Caricamento assegnazioni…", "loading": "Caricamento assegnazioni…",
@@ -972,5 +995,16 @@
"backToDashboard": "Torna alla dashboard", "backToDashboard": "Torna alla dashboard",
"notFoundTitle": "Pagina non trovata", "notFoundTitle": "Pagina non trovata",
"notFoundDescription": "La pagina che stai cercando non esiste o è stata spostata." "notFoundDescription": "La pagina che stai cercando non esiste o è stata spostata."
},
"connect": {
"title": "Collegati al tuo assistente",
"description": "Il tuo assistente funziona all'interno della tua app di messaggistica. Ecco come iniziare a chattare con lui.",
"notReadyNote": "Il tuo assistente è ancora in fase di configurazione. Questi dettagli di connessione funzioneranno non appena sarà pronto.",
"noChannelsTitle": "Nessun canale di messaggistica",
"noChannelsBody": "Il tuo assistente è in funzione ma non ha alcun canale per chattare. Attiva un canale — Threema, Telegram o Discord — nella sezione Pacchetti qui sotto per iniziare a usarlo.",
"threemaBotIdLabel": "ID Threema",
"threemaSteps": "1. Apri Threema e scansiona questo codice QR (oppure aggiungi l'ID sopra come contatto).\n2. Inviagli un messaggio per iniziare a chattare.\nAssicurati che il tuo ID Threema sia presente nell'elenco degli utenti autorizzati qui sotto: solo gli ID elencati ricevono una risposta.",
"telegramSteps": "Apri il bot Telegram che hai collegato e inviagli un messaggio per iniziare a chattare. Solo gli ID utente nell'elenco degli utenti autorizzati qui sotto ricevono una risposta.",
"discordSteps": "Scrivi al bot Discord che hai collegato, oppure menzionalo in un canale a cui si è unito. Solo gli ID utente nell'elenco degli utenti autorizzati qui sotto ricevono una risposta."
} }
} }