diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx
index ce2224e..1378fc5 100644
--- a/src/app/[locale]/tenants/[name]/page.tsx
+++ b/src/app/[locale]/tenants/[name]/page.tsx
@@ -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({
)}
+ {/* 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 && (
+
+
+
+
+
+ {t("suspendedTitle")}
+
+
+ {t("suspendedDescription")}
+
+
+
+
+ )}
+
{/* Usage */}
@@ -155,6 +196,25 @@ export default async function TenantDetailPage({
)}
+
+ {/* 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 && (
+
+
+
+
+ )}
);
}
diff --git a/src/app/api/tenants/[name]/suspend/route.ts b/src/app/api/tenants/[name]/suspend/route.ts
new file mode 100644
index 0000000..91de2c4
--- /dev/null
+++ b/src/app/api/tenants/[name]/suspend/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/components/admin/admin-panel.tsx b/src/components/admin/admin-panel.tsx
index 75a1e9e..c469851 100644
--- a/src/components/admin/admin-panel.tsx
+++ b/src/components/admin/admin-panel.tsx
@@ -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 {
diff --git a/src/components/layout/nav-shell.tsx b/src/components/layout/nav-shell.tsx
index ba05c3c..d7f9065 100644
--- a/src/components/layout/nav-shell.tsx
+++ b/src/components/layout/nav-shell.tsx
@@ -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 (
diff --git a/src/components/tenants/subscription-toggle.tsx b/src/components/tenants/subscription-toggle.tsx
new file mode 100644
index 0000000..0661115
--- /dev/null
+++ b/src/components/tenants/subscription-toggle.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/ui/status-badge.tsx b/src/components/ui/status-badge.tsx
index 1b8612e..c839414 100644
--- a/src/components/ui/status-badge.tsx
+++ b/src/components/ui/status-badge.tsx
@@ -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 = {
- 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 (
)}
- {phase}
+ {label}
);
}
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index 7916a54..3cc25d0 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -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 = {
+ 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 = {
+ 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;
diff --git a/src/messages/de.json b/src/messages/de.json
index 30f7862..51c97aa 100644
--- a/src/messages/de.json
+++ b/src/messages/de.json
@@ -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"
}
}
diff --git a/src/messages/en.json b/src/messages/en.json
index 47e10f0..52c02c6 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -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"
}
}
diff --git a/src/messages/fr.json b/src/messages/fr.json
index fdb1cbb..f997f7c 100644
--- a/src/messages/fr.json
+++ b/src/messages/fr.json
@@ -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"
}
}
diff --git a/src/messages/it.json b/src/messages/it.json
index 5e39df5..7946b03 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -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"
}
}
diff --git a/src/types/index.ts b/src/types/index.ts
index b071be9..7a463a7 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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;
/**