Group C+ fixes
All checks were successful
Build and Push / build (push) Successful in 1m24s

This commit is contained in:
2026-04-29 21:34:52 +02:00
parent 49d81190d4
commit 9c50c9f054
12 changed files with 556 additions and 43 deletions

View File

@@ -9,6 +9,7 @@ import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor";
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";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
@@ -40,6 +41,11 @@ export default async function TenantDetailPage({
// the same page but with edit controls hidden / fields read-only.
const canEdit = canMutate(user);
// Bug 31: customer-side cancel/resume control. Same gate as canEdit
// — only owners (or platform staff) may toggle the subscription.
// The current state comes from spec.suspend on the CR.
const isSuspended = Boolean(tenant.spec.suspend);
// Bug 7: assigned-users panel is meaningless for personal tenants
// (sole-owner by definition; the only "assignee" is the owner
// themselves). We hide the panel when EITHER the CR carries the
@@ -102,6 +108,41 @@ export default async function TenantDetailPage({
)}
</div>
{/* Bug 31: prominent banner when the subscription is cancelled.
Sits between header and content so it's the first thing the
owner sees. Says clearly what state means, and that data is
preserved. The Resume action lives in the SubscriptionToggle
at the bottom — duplicating it here would clutter the banner
for the much-more-common active case. */}
{isSuspended && (
<div className="mb-8 animate-in animate-in-delay-1 bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
<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("suspendedTitle")}
</div>
<div className="text-xs text-text-secondary mt-1">
{t("suspendedDescription")}
</div>
</div>
</div>
</div>
)}
{/* Usage */}
<section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
@@ -155,6 +196,25 @@ export default async function TenantDetailPage({
<AssignedUsersPanel tenantName={name} canEdit={canEdit} />
</section>
)}
{/* Bug 31: subscription cancel/resume — owners + platform staff
only. Lives at the bottom of the page (rather than near the
status badge) to add deliberate friction; mis-clicking
"Cancel subscription" from the top would be too easy. The
control itself opens a confirmation modal before sending. */}
{canEdit && (
<section className="mt-12 pt-8 border-t border-border animate-in animate-in-delay-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("subscriptionTitle")}
</h2>
<p className="text-sm text-text-secondary mb-4">
{isSuspended
? t("subscriptionDescriptionSuspended")
: t("subscriptionDescriptionActive")}
</p>
<SubscriptionToggle tenantName={name} suspended={isSuspended} />
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { safeError } from "@/lib/errors";
const patchSchema = z.object({
suspend: z.boolean(),
});
/**
* PATCH /api/tenants/[name]/suspend
*
* Customer-side "Cancel subscription" / "Resume" toggle (Bug 31).
*
* Sets `spec.suspend` on the PiecedTenant CR. The operator interprets
* this flag as "stop reconciling this tenant" — workloads, packages,
* and channel-user changes are no longer applied. Existing data is
* preserved (namespace, ConfigMaps, OpenBao secrets, CNPG database,
* billing records). Resuming sets the flag back to false and the
* operator picks up reconciliation on the next loop.
*
* Authorization
* -------------
* - Customer-side: only an `owner` of the tenant's org may call this.
* `canMutate` is the right gate (mirrors the rest of the customer
* API surface). User-role members cannot cancel a subscription.
* - Platform staff: allowed via `canMutate`'s isPlatform branch, but
* in practice they should use admin tooling for this — the action
* is exposed here for the customer's benefit.
*
* Visibility check is via `canUserSeeTenant` — same notFound() trick
* as the detail page, so we don't leak existence of tenants the
* caller can't see.
*
* Note on workload teardown
* -------------------------
* As of this writing, the operator's `suspend` handling is "skip
* reconciliation and set status.phase to Suspended". The underlying
* StatefulSet keeps running until next reconciliation, which won't
* happen while suspended. Group D will add scale-to-zero so cancelled
* subscriptions actually stop incurring compute. Until then, an
* operator following up with a `kubectl scale` is the workaround.
* Customer data is preserved either way.
*/
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 });
}
// Identical pattern to the detail page — don't leak existence.
if (!(await canUserSeeTenant(user, 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 { suspend } = parsed.data;
// No-op early exit. Avoids a needless K8s patch + status churn when
// the user double-clicks the button or the UI is briefly out of sync.
if (Boolean(tenant.spec.suspend) === suspend) {
return NextResponse.json(
{ message: "No change.", suspend },
{ status: 200 }
);
}
try {
await patchTenantSpec(name, { suspend });
return NextResponse.json(
{
message: suspend
? "Subscription cancelled. Your data is preserved."
: "Subscription resumed.",
suspend,
},
{ status: 200 }
);
} catch (e: any) {
console.error("Suspend toggle failed:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update subscription") },
{ status: e.statusCode || 500 }
);
}
}

View File

@@ -199,7 +199,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
throw new Error(data.error || "Delete failed");
}
setDeleteModal(null);
await fetchTenants();
// Bug 32: K8s deletion is asynchronous — the resource enters a
// Terminating phase with a deletionTimestamp set, finalizers run,
// then the resource is fully removed. fetchTenants() right
// after the API call would race the K8s store and often still
// include the just-deleted row. Two complementary fixes:
// 1. Optimistically drop the row from local state so the UI
// reflects the user's intent immediately.
// 2. Schedule a delayed refetch (1.5s) to pick up any side
// effects (cascaded request rows, freshly-released names).
// The immediate fetchTenants() is kept as a "best chance" — if
// K8s does report the deletion synchronously (rare), we get the
// freshest data. If it doesn't, the optimistic update has us
// covered until the delayed refetch lands.
setTenants((prev) => prev.filter((t) => t.metadata.name !== name));
fetchTenants();
setTimeout(() => fetchTenants(), 1500);
} catch (e: any) {
setError(e.message);
} finally {

View File

@@ -13,8 +13,13 @@ function NavBar() {
const pathname = usePathname();
const user = (session as any)?.platformUser;
const isLogin = pathname === "/login";
if (isLogin) return null;
// Hide the nav entirely on auth-only routes. These pages have no
// session yet — showing "Dashboard" / "Sign Out" is misleading at
// best (the buttons would 401 or redirect-loop). Keep this list
// narrow and route-exact: anything else we add to the auth flow
// (e.g. password reset) needs to be added here too.
const isAuthRoute = pathname === "/login" || pathname === "/register";
if (isAuthRoute) return null;
return (
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">

View File

@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
interface Props {
tenantName: string;
/**
* Current suspend state — server-derived. The control toggles this
* via PATCH /api/tenants/[name]/suspend, then refreshes the route
* so server-component-side data (status badge, suspended notice)
* re-renders.
*/
suspended: boolean;
}
/**
* SubscriptionToggle — owner-side cancel/resume control (Bug 31).
*
* Renders a single button that toggles between "Cancel subscription"
* (when active) and "Resume subscription" (when suspended). Cancellation
* is gated behind a confirmation modal because it's destructive
* looking from the user's POV — even though no data is lost, the
* AI assistant becomes unavailable until they resume. Resume has no
* modal; it's a strict subset of cancellation in terms of risk.
*
* The control intentionally lives at the bottom of the tenant detail
* page rather than next to the status badge — putting it near the
* top would invite mis-clicks. Customers who want to cancel scroll
* past the running configuration, billing-relevant info, and assigned
* users first; that's the right friction level.
*
* Suspended tenants render a top-of-page banner separately (see the
* detail page); this component focuses on the action itself.
*/
export function SubscriptionToggle({ tenantName, suspended }: Props) {
const t = useTranslations("tenantDetail");
const tCommon = useTranslations("common");
const router = useRouter();
const [confirmOpen, setConfirmOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const toggleSuspend = async (next: boolean) => {
setSubmitting(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${encodeURIComponent(tenantName)}/suspend`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suspend: next }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("subscriptionUpdateFailed"));
}
setConfirmOpen(false);
// The status badge + suspended banner are server-rendered, so
// a route refresh is the simplest way to reflect the new state.
// Optimistic local toggle would diverge from the actual CR if
// the operator hasn't observed the patch yet.
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
if (suspended) {
return (
<div>
<button
type="button"
onClick={() => toggleSuspend(false)}
disabled={submitting}
className="text-sm font-medium px-4 py-2 rounded-lg border border-success/30 text-success hover:bg-success/10 transition-colors disabled:opacity-50"
>
{submitting ? tCommon("loading") : t("resumeSubscription")}
</button>
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
</div>
);
}
return (
<div>
<button
type="button"
onClick={() => setConfirmOpen(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("cancelSubscription")}
</button>
{error && !confirmOpen && (
<p className="text-xs text-red-400 mt-2">{error}</p>
)}
{confirmOpen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmOpen(false);
}}
>
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmTitle")}
</h3>
<p className="text-sm text-text-secondary mb-3">
{t("cancelConfirmDescription")}
</p>
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
<li>{t("cancelConfirmBullet1")}</li>
<li>{t("cancelConfirmBullet2")}</li>
<li>{t("cancelConfirmBullet3")}</li>
</ul>
{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 justify-end gap-2">
<button
type="button"
onClick={() => setConfirmOpen(false)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={() => toggleSuspend(true)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("cancelSubscriptionConfirm")}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,18 +1,39 @@
"use client";
import { useTranslations } from "next-intl";
/**
* Visual treatment per phase. Each entry is a Tailwind class string
* applied to the badge. The `Pending` style is also used as a fallback
* for unknown phases — it's the most neutral colour.
*
* Slice 7 / Bug 31 added `Suspended`. It uses an amber-on-muted scheme
* to read as "intentionally paused" — distinct from `Error` (red) and
* `Deleting` (mute grey).
*/
const phaseStyles: Record<string, string> = {
Running:
"bg-success/10 text-success border-success/20",
Provisioning:
"bg-warning/10 text-warning border-warning/20",
Pending:
"bg-text-muted/10 text-text-secondary border-border",
Error:
"bg-error/10 text-error border-error/20",
Deleting:
"bg-text-muted/10 text-text-muted border-border",
Running: "bg-success/10 text-success border-success/20",
Ready: "bg-success/10 text-success border-success/20",
Provisioning: "bg-warning/10 text-warning border-warning/20",
Pending: "bg-text-muted/10 text-text-secondary border-border",
Suspended: "bg-amber-500/10 text-amber-400 border-amber-500/30",
Error: "bg-error/10 text-error border-error/20",
Deleting: "bg-text-muted/10 text-text-muted border-border",
};
export function StatusBadge({ phase }: { phase: string }) {
const t = useTranslations("phase");
const style = phaseStyles[phase] ?? phaseStyles.Pending;
// Translation lookup with fallback to the raw phase. Keeps things
// working if a new operator-side phase ships before the portal has
// a label for it.
const label = (() => {
try {
return t(phase);
} catch {
return phase;
}
})();
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${style}`}
@@ -23,7 +44,7 @@ export function StatusBadge({ phase }: { phase: string }) {
{phase === "Provisioning" && (
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
)}
{phase}
{label}
</span>
);
}

View File

@@ -30,23 +30,73 @@ export const SUPPORTED_COUNTRIES = ["CH", "DE", "AT", "FR", "IT", "LI"] as const
export type SupportedCountry = (typeof SUPPORTED_COUNTRIES)[number];
/**
* Billing address — every field required at minimum non-empty length.
* Postal code rules vary too much across DACH+ to enforce a single
* regex usefully; we settle for "non-empty, ≤ 12 chars". Country is a
* fixed enum to prevent free-text typos that break invoicing.
* Country-specific postal-code patterns. Bug 33: previously a postal
* code could be anything (e.g. "abc"), which broke invoicing.
*
* Patterns are deliberately conservative — they reject obviously wrong
* input but don't try to be exhaustive valid-range checkers (e.g. CH
* codes are 1000-9999 in practice but \d{4} accepts 0000; the post
* office will reject downstream if it matters). If a future country
* has multi-format codes (e.g. UK postcodes with the inner-outer
* structure), add it as a regex here rather than trying to fit
* every country into the same shape.
*/
export const billingAddressSchema = z.object({
// Company line is structurally optional — personal accounts leave it
// empty by design (Bug 2). Server-side, the wizard's UI hides the
// field for personals; the schema just doesn't require it.
company: z.string().trim().max(100).optional().default(""),
street: z.string().trim().min(1, "required").max(200),
postalCode: z.string().trim().min(1, "required").max(12),
city: z.string().trim().min(1, "required").max(100),
country: z.enum(SUPPORTED_COUNTRIES, {
message: "Please choose a country from the list",
}),
});
const POSTAL_CODE_PATTERNS: Record<SupportedCountry, RegExp> = {
CH: /^\d{4}$/,
DE: /^\d{5}$/,
AT: /^\d{4}$/,
FR: /^\d{5}$/,
IT: /^\d{5}$/,
LI: /^\d{4}$/,
};
/**
* Postal-code expectation in human terms — used in error messages so
* the user gets a useful hint ("expected 4 digits") rather than just
* a regex failure. Keep in sync with POSTAL_CODE_PATTERNS.
*/
const POSTAL_CODE_HINTS: Record<SupportedCountry, string> = {
CH: "4 digits",
DE: "5 digits",
AT: "4 digits",
FR: "5 digits",
IT: "5 digits",
LI: "4 digits",
};
/**
* Billing address — every field required at minimum non-empty length.
* Postal code is validated against the chosen country (Bug 33). Country
* is a fixed enum to prevent free-text typos that break invoicing.
*
* `superRefine` is the right hook here because we need to look at two
* fields (country + postalCode) together. The error path is set on
* `postalCode` so the wizard renders the inline error under the right
* input rather than at the form root.
*/
export const billingAddressSchema = z
.object({
// Company line is structurally optional — personal accounts leave it
// empty by design (Bug 2). Server-side, the wizard's UI hides the
// field for personals; the schema just doesn't require it.
company: z.string().trim().max(100).optional().default(""),
street: z.string().trim().min(1, "required").max(200),
postalCode: z.string().trim().min(1, "required").max(12),
city: z.string().trim().min(1, "required").max(100),
country: z.enum(SUPPORTED_COUNTRIES, {
message: "Please choose a country from the list",
}),
})
.superRefine((data, ctx) => {
const pattern = POSTAL_CODE_PATTERNS[data.country];
if (!pattern.test(data.postalCode)) {
ctx.addIssue({
code: "custom",
path: ["postalCode"],
message: `Invalid postal code (expected ${POSTAL_CODE_HINTS[data.country]})`,
});
}
});
export type BillingAddressInput = z.infer<typeof billingAddressSchema>;

View File

@@ -20,7 +20,7 @@
"button": "Weiter mit ZITADEL",
"footer": "On-Premises gehostet in der Schweiz",
"noAccount": "Noch kein Konto?",
"register": "Firma registrieren"
"register": "Konto erstellen"
},
"register": {
"title": "Konto erstellen",
@@ -41,7 +41,7 @@
"individualHint": "Aktivieren Sie diese Option, wenn Sie sich nicht im Namen eines Unternehmens registrieren. Ihr Konto wird als persönlicher Arbeitsbereich eingerichtet.",
"accountTypeLabel": "Kontotyp",
"personalCardTitle": "Privat",
"personalCardDescription": "Für Sie persönlich, ohne Firma.",
"personalCardDescription": "Für Sie persönlich.",
"companyCardTitle": "Unternehmen",
"companyCardDescription": "Für Ihr Unternehmen oder Team."
},
@@ -129,7 +129,21 @@
"notFound": "Tenant nicht gefunden.",
"usage": "Nutzung & Kosten",
"provisioned": "Bereitgestellt",
"assignedUsers": "Zugewiesene Benutzer"
"assignedUsers": "Zugewiesene Benutzer",
"subscriptionTitle": "Abonnement",
"subscriptionDescriptionActive": "Kündigen Sie Ihr Abonnement, wenn Sie diesen Assistenten nicht mehr benötigen. Ihre Daten bleiben erhalten und Sie können jederzeit wieder aktivieren.",
"subscriptionDescriptionSuspended": "Ihr Abonnement ist gekündigt. Aktivieren Sie es wieder, um den Assistenten online zu bringen.",
"cancelSubscription": "Abonnement kündigen",
"cancelSubscriptionConfirm": "Ja, kündigen",
"resumeSubscription": "Abonnement reaktivieren",
"cancelConfirmTitle": "Dieses Abonnement kündigen?",
"cancelConfirmDescription": "Ihr Assistent wird nicht mehr verfügbar sein. Sie können jederzeit reaktivieren — Ihre Daten bleiben erhalten.",
"cancelConfirmBullet1": "Workspace-Dateien (SOUL.md, AGENTS.md) bleiben erhalten",
"cancelConfirmBullet2": "Paket-Anmeldedaten bleiben gespeichert",
"cancelConfirmBullet3": "Rechnungsdaten bleiben gespeichert",
"subscriptionUpdateFailed": "Abonnement konnte nicht aktualisiert werden.",
"suspendedTitle": "Abonnement gekündigt",
"suspendedDescription": "Ihr Assistent ist pausiert. Konfiguration und Daten bleiben erhalten. Verwenden Sie die Reaktivierungs-Schaltfläche unten auf dieser Seite, um ihn wieder online zu bringen."
},
"usage": {
"inputTokens": "Input-Tokens",
@@ -323,5 +337,14 @@
"FR": "Frankreich",
"IT": "Italien",
"LI": "Liechtenstein"
},
"phase": {
"Pending": "Ausstehend",
"Provisioning": "Wird bereitgestellt",
"Running": "Aktiv",
"Ready": "Bereit",
"Suspended": "Pausiert",
"Error": "Fehler",
"Deleting": "Wird gelöscht"
}
}

View File

@@ -20,7 +20,7 @@
"button": "Continue with ZITADEL",
"footer": "Hosted on-premises in Switzerland",
"noAccount": "No account yet?",
"register": "Register your company"
"register": "Create an account"
},
"register": {
"title": "Create your account",
@@ -41,7 +41,7 @@
"individualHint": "Tick this if you're not registering on behalf of a company. Your account will be set up as a personal workspace.",
"accountTypeLabel": "Account type",
"personalCardTitle": "Personal",
"personalCardDescription": "For yourself, no company.",
"personalCardDescription": "For yourself.",
"companyCardTitle": "Company",
"companyCardDescription": "For your business or team."
},
@@ -129,7 +129,21 @@
"notFound": "Tenant not found.",
"usage": "Usage & Spend",
"provisioned": "Provisioned",
"assignedUsers": "Assigned users"
"assignedUsers": "Assigned users",
"subscriptionTitle": "Subscription",
"subscriptionDescriptionActive": "Cancel your subscription if you no longer need this assistant. Your data will be preserved and you can resume anytime.",
"subscriptionDescriptionSuspended": "Your subscription is cancelled. Resume to bring the assistant back online.",
"cancelSubscription": "Cancel subscription",
"cancelSubscriptionConfirm": "Yes, cancel",
"resumeSubscription": "Resume subscription",
"cancelConfirmTitle": "Cancel this subscription?",
"cancelConfirmDescription": "Your assistant will become unavailable. You can resume anytime — your data is preserved.",
"cancelConfirmBullet1": "Workspace files (SOUL.md, AGENTS.md) are kept",
"cancelConfirmBullet2": "Package credentials remain stored",
"cancelConfirmBullet3": "Billing information is kept on file",
"subscriptionUpdateFailed": "Could not update subscription.",
"suspendedTitle": "Subscription cancelled",
"suspendedDescription": "Your assistant is paused. Configuration and data are preserved. Use the Resume control at the bottom of this page to bring it back online."
},
"usage": {
"inputTokens": "Input Tokens",
@@ -323,5 +337,14 @@
"FR": "France",
"IT": "Italy",
"LI": "Liechtenstein"
},
"phase": {
"Pending": "Pending",
"Provisioning": "Provisioning",
"Running": "Running",
"Ready": "Ready",
"Suspended": "Suspended",
"Error": "Error",
"Deleting": "Deleting"
}
}

View File

@@ -20,7 +20,7 @@
"button": "Continuer avec ZITADEL",
"footer": "Hébergé on-premises en Suisse",
"noAccount": "Pas encore de compte ?",
"register": "Enregistrer votre entreprise"
"register": "Créer un compte"
},
"register": {
"title": "Créer votre compte",
@@ -41,7 +41,7 @@
"individualHint": "Cochez cette case si vous ne vous inscrivez pas au nom d'une entreprise. Votre compte sera configuré comme espace de travail personnel.",
"accountTypeLabel": "Type de compte",
"personalCardTitle": "Particulier",
"personalCardDescription": "Pour vous, sans entreprise.",
"personalCardDescription": "Pour vous.",
"companyCardTitle": "Entreprise",
"companyCardDescription": "Pour votre entreprise ou équipe."
},
@@ -129,7 +129,21 @@
"notFound": "Locataire non trouvé.",
"usage": "Utilisation et coûts",
"provisioned": "Provisionné",
"assignedUsers": "Utilisateurs attribués"
"assignedUsers": "Utilisateurs attribués",
"subscriptionTitle": "Abonnement",
"subscriptionDescriptionActive": "Annulez votre abonnement si vous n'avez plus besoin de cet assistant. Vos données seront conservées et vous pourrez reprendre à tout moment.",
"subscriptionDescriptionSuspended": "Votre abonnement est annulé. Reprenez pour remettre l'assistant en ligne.",
"cancelSubscription": "Annuler l'abonnement",
"cancelSubscriptionConfirm": "Oui, annuler",
"resumeSubscription": "Reprendre l'abonnement",
"cancelConfirmTitle": "Annuler cet abonnement ?",
"cancelConfirmDescription": "Votre assistant sera indisponible. Vous pouvez reprendre à tout moment — vos données sont préservées.",
"cancelConfirmBullet1": "Les fichiers de l'espace de travail (SOUL.md, AGENTS.md) sont conservés",
"cancelConfirmBullet2": "Les identifiants des packages restent stockés",
"cancelConfirmBullet3": "Les informations de facturation sont conservées",
"subscriptionUpdateFailed": "Impossible de mettre à jour l'abonnement.",
"suspendedTitle": "Abonnement annulé",
"suspendedDescription": "Votre assistant est en pause. La configuration et les données sont préservées. Utilisez le contrôle Reprendre en bas de cette page pour le remettre en ligne."
},
"usage": {
"inputTokens": "Tokens d'entrée",
@@ -323,5 +337,14 @@
"FR": "France",
"IT": "Italie",
"LI": "Liechtenstein"
},
"phase": {
"Pending": "En attente",
"Provisioning": "Mise en service",
"Running": "Actif",
"Ready": "Prêt",
"Suspended": "Suspendu",
"Error": "Erreur",
"Deleting": "Suppression"
}
}

View File

@@ -20,7 +20,7 @@
"button": "Continua con ZITADEL",
"footer": "Ospitato on-premises in Svizzera",
"noAccount": "Non hai ancora un account?",
"register": "Registra la tua azienda"
"register": "Crea un account"
},
"register": {
"title": "Crea il tuo account",
@@ -41,7 +41,7 @@
"individualHint": "Seleziona questa opzione se non ti stai registrando per conto di un'azienda. Il tuo account sarà configurato come area di lavoro personale.",
"accountTypeLabel": "Tipo di account",
"personalCardTitle": "Privato",
"personalCardDescription": "Per lei, senza azienda.",
"personalCardDescription": "Per lei.",
"companyCardTitle": "Azienda",
"companyCardDescription": "Per la sua azienda o team."
},
@@ -129,7 +129,21 @@
"notFound": "Tenant non trovato.",
"usage": "Utilizzo e costi",
"provisioned": "Attivato",
"assignedUsers": "Utenti assegnati"
"assignedUsers": "Utenti assegnati",
"subscriptionTitle": "Abbonamento",
"subscriptionDescriptionActive": "Annulli il suo abbonamento se non ha più bisogno di questo assistente. I suoi dati saranno preservati e potrà riprendere in qualsiasi momento.",
"subscriptionDescriptionSuspended": "Il suo abbonamento è annullato. Riprenda per riportare l'assistente online.",
"cancelSubscription": "Annulla abbonamento",
"cancelSubscriptionConfirm": "Sì, annulla",
"resumeSubscription": "Riprendi abbonamento",
"cancelConfirmTitle": "Annullare questo abbonamento?",
"cancelConfirmDescription": "Il suo assistente diventerà non disponibile. Può riprendere in qualsiasi momento — i suoi dati sono preservati.",
"cancelConfirmBullet1": "I file del workspace (SOUL.md, AGENTS.md) sono mantenuti",
"cancelConfirmBullet2": "Le credenziali dei pacchetti rimangono memorizzate",
"cancelConfirmBullet3": "Le informazioni di fatturazione sono mantenute",
"subscriptionUpdateFailed": "Impossibile aggiornare l'abbonamento.",
"suspendedTitle": "Abbonamento annullato",
"suspendedDescription": "Il suo assistente è in pausa. Configurazione e dati sono preservati. Usi il controllo Riprendi in fondo a questa pagina per riportarlo online."
},
"usage": {
"inputTokens": "Token di input",
@@ -323,5 +337,14 @@
"FR": "Francia",
"IT": "Italia",
"LI": "Liechtenstein"
},
"phase": {
"Pending": "In attesa",
"Provisioning": "In provisioning",
"Running": "Attivo",
"Ready": "Pronto",
"Suspended": "Sospeso",
"Error": "Errore",
"Deleting": "Eliminazione"
}
}

View File

@@ -78,7 +78,14 @@ export interface PiecedTenantSpec {
}
export interface PiecedTenantStatus {
phase: "Pending" | "Provisioning" | "Running" | "Ready" | "Error" | "Deleting";
phase:
| "Pending"
| "Provisioning"
| "Running"
| "Ready"
| "Suspended"
| "Error"
| "Deleting";
message?: string;
observedGeneration?: number;
/**