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 && ( +
+

+ {t("subscriptionTitle")} +

+

+ {isSuspended + ? t("subscriptionDescriptionSuspended") + : t("subscriptionDescriptionActive")} +

+ +
+ )} ); } 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 ( +
+ + {error &&

{error}

} +
+ ); + } + + return ( +
+ + {error && !confirmOpen && ( +

{error}

+ )} + + {confirmOpen && ( +
{ + if (e.target === e.currentTarget) setConfirmOpen(false); + }} + > +
+

+ {t("cancelConfirmTitle")} +

+

+ {t("cancelConfirmDescription")} +

+
    +
  • {t("cancelConfirmBullet1")}
  • +
  • {t("cancelConfirmBullet2")}
  • +
  • {t("cancelConfirmBullet3")}
  • +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ )} +
+ ); +} 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; /**