From 31282da0093b204fbfb49a4186abf356e97422aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 18 Aug 2026 15:08:30 -0600 Subject: [PATCH 1/4] feat[backend](tenants): added purge tenant data endpoints and update after termination operation --- .../modules/tenant/connectors/repository.go | 1 + backend/modules/tenant/connectors/usecase.go | 2 + backend/modules/tenant/domain/errors.go | 1 + backend/modules/tenant/handler/tenant.go | 64 ++++++++++++++++++- .../modules/tenant/repository/tenant_pg.go | 23 +++++++ backend/modules/tenant/routes.go | 2 + backend/modules/tenant/usecase/tenant.go | 35 ++++++++++ 7 files changed, 127 insertions(+), 1 deletion(-) diff --git a/backend/modules/tenant/connectors/repository.go b/backend/modules/tenant/connectors/repository.go index 5226bca61..d90b98d36 100644 --- a/backend/modules/tenant/connectors/repository.go +++ b/backend/modules/tenant/connectors/repository.go @@ -15,4 +15,5 @@ type TenantRepository interface { FindByID(ctx context.Context, id uuid.UUID) (*domain.Tenant, error) FindByDomain(ctx context.Context, domain string) (*domain.Tenant, error) List(ctx context.Context, f dto.Filter) ([]domain.Tenant, int64, error) + PurgeAllTenantData(ctx context.Context, id uuid.UUID) error } diff --git a/backend/modules/tenant/connectors/usecase.go b/backend/modules/tenant/connectors/usecase.go index 50fcc6f4d..f9e267ab3 100644 --- a/backend/modules/tenant/connectors/usecase.go +++ b/backend/modules/tenant/connectors/usecase.go @@ -28,5 +28,7 @@ type TenantUsecase interface { List(ctx context.Context, f dto.Filter) ([]domain.Tenant, int64, error) SetSupportAccess(ctx context.Context, id uuid.UUID, level domain.SupportAccess) (*domain.Tenant, error) Terminate(ctx context.Context, id uuid.UUID) error + Reactivate(ctx context.Context, id uuid.UUID) (*domain.Tenant, error) + PermanentlyDelete(ctx context.Context, id uuid.UUID) error ResolveDomain(ctx context.Context, host string) (*domain.Tenant, error) } diff --git a/backend/modules/tenant/domain/errors.go b/backend/modules/tenant/domain/errors.go index 67d8daa9f..ee4cf5e89 100644 --- a/backend/modules/tenant/domain/errors.go +++ b/backend/modules/tenant/domain/errors.go @@ -16,4 +16,5 @@ var ( ErrLimitInvalid = errors.New("a limit must be a whole number, or null to remove it") ErrLimitExceedsLicense = errors.New("the limits handed out to tenants would exceed what this instance is licensed for") ErrDefaultTenant = errors.New("the default tenant holds the platform plane and cannot be changed this way") + ErrNotTerminated = errors.New("tenant is not terminated") ) diff --git a/backend/modules/tenant/handler/tenant.go b/backend/modules/tenant/handler/tenant.go index dbb1c4953..935315387 100644 --- a/backend/modules/tenant/handler/tenant.go +++ b/backend/modules/tenant/handler/tenant.go @@ -181,6 +181,67 @@ func (h *TenantHandler) Terminate(c *gin.Context) { c.Status(http.StatusNoContent) } +// Reactivate godoc +// +// @Summary Reactivate a terminated tenant +// @Description Flips a TERMINATED tenant back to ACTIVE. Fails if the tenant is not terminated. +// @Tags Tenants +// @Security BearerAuth +// @Produce json +// @Param id path string true "Tenant id" +// @Success 200 {object} domain.Tenant +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /tenants/{id}/reactivate [post] +func (h *TenantHandler) Reactivate(c *gin.Context) { + tid, ok := pathTenantID(c) + if !ok { + return + } + t, err := h.uc.Reactivate(c.Request.Context(), tid) + audit.Record(c, audit_connectors.Event{ + Action: "tenant.reactivate", + ResourceType: "tenant", + ResourceID: c.Param("id"), + }, audit_domain.TENANT_REACTIVATE_ATTEMPT, audit_domain.TENANT_REACTIVATE_SUCCESS, err) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, t) +} + +// PermanentlyDelete godoc +// +// @Summary Permanently delete a terminated tenant +// @Description Hard-deletes the tenant row and all rows scoped by tenant_id across the schema. Only allowed when the tenant is TERMINATED. +// @Tags Tenants +// @Security BearerAuth +// @Param id path string true "Tenant id" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /tenants/{id}/permanent [delete] +func (h *TenantHandler) PermanentlyDelete(c *gin.Context) { + tid, ok := pathTenantID(c) + if !ok { + return + } + err := h.uc.PermanentlyDelete(c.Request.Context(), tid) + audit.Record(c, audit_connectors.Event{ + Action: "tenant.purge", + ResourceType: "tenant", + ResourceID: c.Param("id"), + }, audit_domain.TENANT_PURGE_ATTEMPT, audit_domain.TENANT_PURGE_SUCCESS, err) + if err != nil { + writeError(c, err) + return + } + c.Status(http.StatusNoContent) +} + func writeError(c *gin.Context, err error) { switch { case errors.Is(err, domain.ErrNotFound): @@ -192,7 +253,8 @@ func writeError(c *gin.Context, err error) { case errors.Is(err, domain.ErrNameRequired), errors.Is(err, domain.ErrDomainRequired), errors.Is(err, domain.ErrDomainInvalid), errors.Is(err, domain.ErrStatusInvalid), errors.Is(err, domain.ErrAlreadyTerminated), errors.Is(err, domain.ErrSupportInvalid), - errors.Is(err, domain.ErrLimitNegative), errors.Is(err, domain.ErrLimitInvalid): + errors.Is(err, domain.ErrLimitNegative), errors.Is(err, domain.ErrLimitInvalid), + errors.Is(err, domain.ErrNotTerminated): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/modules/tenant/repository/tenant_pg.go b/backend/modules/tenant/repository/tenant_pg.go index 3b06bd20a..04dec2dce 100644 --- a/backend/modules/tenant/repository/tenant_pg.go +++ b/backend/modules/tenant/repository/tenant_pg.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "fmt" "github.com/google/uuid" "gorm.io/gorm" @@ -50,6 +51,28 @@ func (r *pgTenantRepository) findOne(ctx context.Context, query string, arg any) return &t, nil } +func (r *pgTenantRepository) PurgeAllTenantData(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var tables []string + if err := tx.Raw(` + SELECT table_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND column_name = 'tenant_id' + AND table_name <> 'tenant' + `).Scan(&tables).Error; err != nil { + return err + } + for _, tbl := range tables { + sql := fmt.Sprintf(`DELETE FROM %q WHERE tenant_id::text = ?`, tbl) + if err := tx.Exec(sql, id.String()).Error; err != nil { + return err + } + } + return nil + }) +} + func (r *pgTenantRepository) List(ctx context.Context, f dto.Filter) ([]domain.Tenant, int64, error) { q := r.db.WithContext(ctx).Model(&domain.Tenant{}) diff --git a/backend/modules/tenant/routes.go b/backend/modules/tenant/routes.go index 0c294b391..2c5aead52 100644 --- a/backend/modules/tenant/routes.go +++ b/backend/modules/tenant/routes.go @@ -19,6 +19,8 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth, mssp, platform gi g.POST("", write, h.Create) g.PUT("/:id", write, h.Update) g.DELETE("/:id", write, h.Terminate) + g.POST("/:id/reactivate", write, h.Reactivate) + g.DELETE("/:id/permanent", write, h.PermanentlyDelete) own := api.Group("/tenants", userAuth, mssp) ownTenant := []gin.HandlerFunc{middleware.RequireAdmin(), middleware.RequireOwnTenant("id")} diff --git a/backend/modules/tenant/usecase/tenant.go b/backend/modules/tenant/usecase/tenant.go index ac279a1b8..e46777d55 100644 --- a/backend/modules/tenant/usecase/tenant.go +++ b/backend/modules/tenant/usecase/tenant.go @@ -185,6 +185,41 @@ func (u *tenantUsecase) Terminate(ctx context.Context, id uuid.UUID) error { return u.repo.Update(ctx, t) } +func (u *tenantUsecase) Reactivate(ctx context.Context, id uuid.UUID) (*domain.Tenant, error) { + if id.String() == authz.DefaultTenantID { + return nil, domain.ErrDefaultTenant + } + t, err := u.GetByID(ctx, id) + if err != nil { + return nil, err + } + if t.Status != domain.StatusTerminated { + return nil, domain.ErrNotTerminated + } + t.Status = domain.StatusActive + if err := u.repo.Update(ctx, t); err != nil { + return nil, err + } + return t, nil +} + +func (u *tenantUsecase) PermanentlyDelete(ctx context.Context, id uuid.UUID) error { + if id.String() == authz.DefaultTenantID { + return domain.ErrDefaultTenant + } + t, err := u.GetByID(ctx, id) + if err != nil { + return err + } + if t.Status != domain.StatusTerminated { + return domain.ErrNotTerminated + } + if err := u.repo.PurgeAllTenantData(ctx, id); err != nil { + return err + } + return u.repo.Delete(ctx, id) +} + func (u *tenantUsecase) ResolveDomain(ctx context.Context, host string) (*domain.Tenant, error) { if h, _, err := net.SplitHostPort(host); err == nil { host = h From bb402dd154ddcde49746f8e0f3e887f20469ea2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 18 Aug 2026 15:16:35 -0600 Subject: [PATCH 2/4] feat[backend](tenants): added purge clickhouse data and local filesystem purge on tenants purge request --- backend/modules.go | 30 +++++++++++++++++--- backend/modules/tenant/connectors/usecase.go | 7 +++++ backend/modules/tenant/module.go | 4 +-- backend/modules/tenant/usecase/tenant.go | 14 ++++++--- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/backend/modules.go b/backend/modules.go index de665c105..fccf6f59f 100644 --- a/backend/modules.go +++ b/backend/modules.go @@ -2,13 +2,15 @@ package main import ( "context" + "os" + "path/filepath" + "strings" + "time" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" "github.com/google/uuid" iam_handler "github.com/utmstack/utmstack/backend/modules/iam/handler" "github.com/utmstack/utmstack/backend/pkg/joblease" - "path/filepath" - "strings" - "time" dash_usecase "github.com/utmstack/utmstack/backend/modules/dashboards/usecase" "github.com/utmstack/utmstack/backend/pkg/eventstore" @@ -43,8 +45,10 @@ import ( socai_repository "github.com/utmstack/utmstack/backend/modules/socai/repository" "github.com/utmstack/utmstack/backend/modules/storage" "github.com/utmstack/utmstack/backend/modules/tenant" + tenant_connectors "github.com/utmstack/utmstack/backend/modules/tenant/connectors" tenant_domain "github.com/utmstack/utmstack/backend/modules/tenant/domain" tenant_dto "github.com/utmstack/utmstack/backend/modules/tenant/dto" + ep_repository "github.com/utmstack/utmstack/backend/modules/eventprocessing/repository" "github.com/utmstack/utmstack/backend/modules/threatintel" "github.com/utmstack/utmstack/backend/pkg/agentmanager" "github.com/utmstack/utmstack/backend/pkg/env" @@ -214,7 +218,25 @@ func initModules(db *gorm.DB, cfg *config) *modules { env.Int("NOTIFICATIONS_RETENTION_DAYS", 365, false)) iam_handler.AppBaseURL = env.String("APP_BASE_URL", "", false) - tenantMod = tenant.NewModule(db, userUsecase) + + // Extra purgers: ClickHouse rows and per-tenant filesystem folders. + // Each subsystem contributes one; failures short-circuit before the SQL purge + // so the tenant row survives an outage. + var extraPurgers []tenant_connectors.TenantPurgeFunc + if events != nil { + extraPurgers = append(extraPurgers, func(ctx context.Context, id uuid.UUID) error { + return events.PurgeTenant(ctx, id.String()) + }) + } + rulesUserDir := filepath.Join(env.String(ep_repository.RulesDirEnv, ep_repository.DefaultRulesDir, false), ep_repository.UserSubdir) + pipelinesUserDir := filepath.Join(env.String(ep_repository.PipelinesDirEnv, ep_repository.DefaultPipelinesDir, false), ep_repository.UserSubdir) + extraPurgers = append(extraPurgers, + func(_ context.Context, id uuid.UUID) error { return os.RemoveAll(filepath.Join(rulesUserDir, id.String())) }, + func(_ context.Context, id uuid.UUID) error { + return os.RemoveAll(filepath.Join(pipelinesUserDir, id.String())) + }, + ) + tenantMod = tenant.NewModule(db, userUsecase, extraPurgers...) tenantListerForConfig = tenantLister iamMod := iam.NewModule(authUsecase, userUsecase, roleUsecase, tfaUsecase, apiKeyUsecase, idpUsecase, federationUsecase, cfg.uploadDir, tenantLister) iamMod.SetSessionPurger(iam_usecase.NewSessionPurger(refreshRepo, joblease.New(db))) diff --git a/backend/modules/tenant/connectors/usecase.go b/backend/modules/tenant/connectors/usecase.go index f9e267ab3..a3466c70d 100644 --- a/backend/modules/tenant/connectors/usecase.go +++ b/backend/modules/tenant/connectors/usecase.go @@ -10,6 +10,13 @@ import ( "github.com/utmstack/utmstack/backend/modules/tenant/dto" ) +// TenantPurgeFunc is a purger contributed by a subsystem that owns +// tenant-scoped data outside PostgreSQL (ClickHouse tables, on-disk config +// directories, etc). Called during PermanentlyDelete before the SQL purge so +// that a failure leaves the tenant row intact and the whole operation stays +// retryable. +type TenantPurgeFunc func(ctx context.Context, id uuid.UUID) error + // UserProvisioner is iam's create, nothing more. Tenant owns tenancy, so it is // this module that puts the tenant on the context before calling; iam only makes // the account it is asked for, wherever the caller says it belongs. diff --git a/backend/modules/tenant/module.go b/backend/modules/tenant/module.go index dcfdc4f32..cce7368f1 100644 --- a/backend/modules/tenant/module.go +++ b/backend/modules/tenant/module.go @@ -15,9 +15,9 @@ type Module struct { bootstrapUC connectors.BootstrapUsecase } -func NewModule(db *gorm.DB, admin connectors.UserProvisioner) *Module { +func NewModule(db *gorm.DB, admin connectors.UserProvisioner, extras ...connectors.TenantPurgeFunc) *Module { repo := repository.NewTenantRepository(db) - tenantUC := usecase.NewTenantUsecase(repo, admin) + tenantUC := usecase.NewTenantUsecase(repo, admin, extras) return &Module{ tenantHandler: handler.NewTenantHandler(tenantUC), diff --git a/backend/modules/tenant/usecase/tenant.go b/backend/modules/tenant/usecase/tenant.go index e46777d55..cc53cedcf 100644 --- a/backend/modules/tenant/usecase/tenant.go +++ b/backend/modules/tenant/usecase/tenant.go @@ -18,12 +18,13 @@ import ( const defaultPageSize = 25 type tenantUsecase struct { - repo connectors.TenantRepository - admin connectors.UserProvisioner + repo connectors.TenantRepository + admin connectors.UserProvisioner + extras []connectors.TenantPurgeFunc } -func NewTenantUsecase(repo connectors.TenantRepository, admin connectors.UserProvisioner) connectors.TenantUsecase { - return &tenantUsecase{repo: repo, admin: admin} +func NewTenantUsecase(repo connectors.TenantRepository, admin connectors.UserProvisioner, extras []connectors.TenantPurgeFunc) connectors.TenantUsecase { + return &tenantUsecase{repo: repo, admin: admin, extras: extras} } func (u *tenantUsecase) Create(ctx context.Context, req dto.CreateRequest) (*domain.Tenant, error) { @@ -214,6 +215,11 @@ func (u *tenantUsecase) PermanentlyDelete(ctx context.Context, id uuid.UUID) err if t.Status != domain.StatusTerminated { return domain.ErrNotTerminated } + for _, purge := range u.extras { + if err := purge(ctx, id); err != nil { + return fmt.Errorf("external purge: %w", err) + } + } if err := u.repo.PurgeAllTenantData(ctx, id); err != nil { return err } From 8ed63f9c1bd3e580cfb5ba378323c48afb2da921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 18 Aug 2026 15:18:17 -0600 Subject: [PATCH 3/4] fix[frontend](tenants): added reactivate and purge all on terminated tenants --- .../tenants/components/CreateTenantDialog.tsx | 86 +++ .../tenants/components/EditTenantDialog.tsx | 151 ++++ .../src/features/tenants/components/Field.tsx | 17 + .../src/features/tenants/components/Modal.tsx | 49 ++ .../components/PermanentDeleteDialog.tsx | 70 ++ .../components/ReactivateTenantDialog.tsx | 58 ++ .../tenants/components/TenantCard.tsx | 320 +++++++++ .../tenants/components/TerminateDialog.tsx | 72 ++ .../tenants/components/tenant-error.ts | 12 + .../features/tenants/pages/TenantsPage.tsx | 675 +----------------- .../tenants/services/tenants-http.service.ts | 2 + frontend/src/shared/i18n/locales/de.json | 14 + frontend/src/shared/i18n/locales/en.json | 14 + frontend/src/shared/i18n/locales/es.json | 14 + frontend/src/shared/i18n/locales/fr.json | 14 + frontend/src/shared/i18n/locales/it.json | 14 + frontend/src/shared/i18n/locales/pt.json | 14 + frontend/src/shared/i18n/locales/ru.json | 14 + 18 files changed, 954 insertions(+), 656 deletions(-) create mode 100644 frontend/src/features/tenants/components/CreateTenantDialog.tsx create mode 100644 frontend/src/features/tenants/components/EditTenantDialog.tsx create mode 100644 frontend/src/features/tenants/components/Field.tsx create mode 100644 frontend/src/features/tenants/components/Modal.tsx create mode 100644 frontend/src/features/tenants/components/PermanentDeleteDialog.tsx create mode 100644 frontend/src/features/tenants/components/ReactivateTenantDialog.tsx create mode 100644 frontend/src/features/tenants/components/TenantCard.tsx create mode 100644 frontend/src/features/tenants/components/TerminateDialog.tsx create mode 100644 frontend/src/features/tenants/components/tenant-error.ts diff --git a/frontend/src/features/tenants/components/CreateTenantDialog.tsx b/frontend/src/features/tenants/components/CreateTenantDialog.tsx new file mode 100644 index 000000000..70550fcb8 --- /dev/null +++ b/frontend/src/features/tenants/components/CreateTenantDialog.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Building2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { tenantsHttpService } from '../services/tenants-http.service' +import type { CreateTenantRequest } from '../types/tenant.types' +import { Modal } from './Modal' +import { Field } from './Field' +import { tenantError } from './tenant-error' + +export function CreateTenantDialog({ + onClose, + onCreated, +}: { + onClose: () => void + onCreated: () => void +}) { + const { t } = useTranslation() + const [name, setName] = useState('') + const [domain, setDomain] = useState('') + const [adminEmail, setAdminEmail] = useState('') + const [busy, setBusy] = useState(false) + + const valid = + name.trim().length >= 2 && domain.trim().length >= 3 && /.+@.+\..+/.test(adminEmail.trim()) + + const submit = async () => { + if (!valid || busy) return + setBusy(true) + try { + const body: CreateTenantRequest = { + name: name.trim(), + domain: domain.trim().toLowerCase(), + adminEmail: adminEmail.trim(), + } + await tenantsHttpService.create(body) + toast.success(t('tenants.toast.created')) + onCreated() + } catch (err) { + toast.error(tenantError(err, t)) + } finally { + setBusy(false) + } + } + + return ( + + + + + } + > + + setName(e.target.value)} placeholder="Acme Corp" /> + + + setDomain(e.target.value)} + className="font-mono" + placeholder="acme.utmstack.com" + /> + + + setAdminEmail(e.target.value)} + placeholder="admin@acme.com" + /> + + + ) +} diff --git a/frontend/src/features/tenants/components/EditTenantDialog.tsx b/frontend/src/features/tenants/components/EditTenantDialog.tsx new file mode 100644 index 000000000..10a260613 --- /dev/null +++ b/frontend/src/features/tenants/components/EditTenantDialog.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Check, Pencil } from 'lucide-react' +import { toast } from 'sonner' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { tenantsHttpService } from '../services/tenants-http.service' +import type { Tenant, TenantStatus } from '../types/tenant.types' +import { Modal } from './Modal' +import { Field } from './Field' +import { tenantError } from './tenant-error' +import { ReactivateTenantDialog } from './ReactivateTenantDialog' + +const STATUSES: TenantStatus[] = ['ACTIVE', 'SUSPENDED'] + +export function EditTenantDialog({ + tenant, + onClose, + onSaved, +}: { + tenant: Tenant + onClose: () => void + onSaved: () => void +}) { + if (tenant.status === 'TERMINATED') { + return + } + + return +} + +function EditTenantForm({ + tenant, + onClose, + onSaved, +}: { + tenant: Tenant + onClose: () => void + onSaved: () => void +}) { + const { t } = useTranslation() + const [name, setName] = useState(tenant.name) + const [domain, setDomain] = useState(tenant.domain) + const [status, setStatus] = useState(tenant.status) + const [capped, setCapped] = useState(tenant.limits.maxAIRequests != null) + const [maxAI, setMaxAI] = useState(String(tenant.limits.maxAIRequests ?? '')) + const [busy, setBusy] = useState(false) + + const parsedMax = Number(maxAI) + const limitValid = !capped || (maxAI.trim() !== '' && Number.isInteger(parsedMax) && parsedMax >= 0) + const valid = name.trim().length >= 2 && domain.trim().length >= 3 && limitValid + + const submit = async () => { + if (!valid || busy) return + setBusy(true) + try { + await tenantsHttpService.update(tenant.id, { + name: name.trim(), + domain: domain.trim().toLowerCase(), + status, + // Clearing the cap sends an explicit null: omitting the field would + // mean "leave it as it is", which is the opposite of the intent. + maxAIRequests: capped ? parsedMax : null, + }) + toast.success(t('tenants.toast.updated')) + onSaved() + } catch (err) { + toast.error(tenantError(err, t)) + } finally { + setBusy(false) + } + } + + return ( + + + + + } + > + + setName(e.target.value)} /> + + + setDomain(e.target.value)} className="font-mono" /> + + + +
+ {STATUSES.map((s) => ( + + ))} +
+ {status === 'SUSPENDED' && ( +

+ {t('tenants.fields.suspendedWarning')} +

+ )} +
+ + + + {capped && ( + setMaxAI(e.target.value)} + placeholder="1000" + className="mt-2" + /> + )} + +
+ ) +} diff --git a/frontend/src/features/tenants/components/Field.tsx b/frontend/src/features/tenants/components/Field.tsx new file mode 100644 index 000000000..08a100d8d --- /dev/null +++ b/frontend/src/features/tenants/components/Field.tsx @@ -0,0 +1,17 @@ +export function Field({ + label, + hint, + children, +}: { + label: string + hint?: string + children: React.ReactNode +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} diff --git a/frontend/src/features/tenants/components/Modal.tsx b/frontend/src/features/tenants/components/Modal.tsx new file mode 100644 index 000000000..00e7a487c --- /dev/null +++ b/frontend/src/features/tenants/components/Modal.tsx @@ -0,0 +1,49 @@ +import { X, type LucideIcon } from 'lucide-react' + +export function Modal({ + title, + icon: Icon, + subtitle, + onClose, + children, + footer, +}: { + title: string + icon: LucideIcon + subtitle?: string + onClose: () => void + children: React.ReactNode + footer: React.ReactNode +}) { + return ( +
+
e.stopPropagation()} + > +
+
+

+ + {title} +

+ {subtitle &&

{subtitle}

} +
+ +
+
{children}
+
+ {footer} +
+
+
+ ) +} diff --git a/frontend/src/features/tenants/components/PermanentDeleteDialog.tsx b/frontend/src/features/tenants/components/PermanentDeleteDialog.tsx new file mode 100644 index 000000000..4aa7b24bb --- /dev/null +++ b/frontend/src/features/tenants/components/PermanentDeleteDialog.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { tenantsHttpService } from '../services/tenants-http.service' +import type { Tenant } from '../types/tenant.types' +import { Modal } from './Modal' +import { Field } from './Field' +import { tenantError } from './tenant-error' + +export function PermanentDeleteDialog({ + tenant, + onClose, + onDone, +}: { + tenant: Tenant + onClose: () => void + onDone: () => void +}) { + const { t } = useTranslation() + const [confirmation, setConfirmation] = useState('') + const [busy, setBusy] = useState(false) + + const valid = confirmation.trim() === tenant.name + + const submit = async () => { + if (!valid || busy) return + setBusy(true) + try { + await tenantsHttpService.permanentlyDelete(tenant.id) + toast.success(t('tenants.toast.permanentlyDeleted')) + onDone() + } catch (err) { + toast.error(tenantError(err, t)) + } finally { + setBusy(false) + } + } + + return ( + + + + + } + > +

+ {t('tenants.deletePermanent.body', { name: tenant.name })} +

+ + setConfirmation(e.target.value)} + placeholder={tenant.name} + /> + +
+ ) +} diff --git a/frontend/src/features/tenants/components/ReactivateTenantDialog.tsx b/frontend/src/features/tenants/components/ReactivateTenantDialog.tsx new file mode 100644 index 000000000..4d369302a --- /dev/null +++ b/frontend/src/features/tenants/components/ReactivateTenantDialog.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Power } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/shared/components/ui/button' +import { tenantsHttpService } from '../services/tenants-http.service' +import type { Tenant } from '../types/tenant.types' +import { Modal } from './Modal' +import { tenantError } from './tenant-error' + +export function ReactivateTenantDialog({ + tenant, + onClose, + onSaved, +}: { + tenant: Tenant + onClose: () => void + onSaved: () => void +}) { + const { t } = useTranslation() + const [busy, setBusy] = useState(false) + + const submit = async () => { + if (busy) return + setBusy(true) + try { + await tenantsHttpService.reactivate(tenant.id) + toast.success(t('tenants.toast.reactivated')) + onSaved() + } catch (err) { + toast.error(tenantError(err, t)) + } finally { + setBusy(false) + } + } + + return ( + + + + + } + > +

+ {t('tenants.reactivate.body', { name: tenant.name })} +

+
+ ) +} diff --git a/frontend/src/features/tenants/components/TenantCard.tsx b/frontend/src/features/tenants/components/TenantCard.tsx new file mode 100644 index 000000000..9f35f4c28 --- /dev/null +++ b/frontend/src/features/tenants/components/TenantCard.tsx @@ -0,0 +1,320 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { TFunction } from 'i18next' +import { + Building2, + Globe, + Lock, + Loader2, + LogIn, + Pencil, + Power, + ShieldCheck, + Trash2, +} from 'lucide-react' +import { cn } from '@/shared/lib/utils' +import { setSupportTenant } from '@/shared/lib/current-tenant' +import { fetchTenantStats } from '../services/tenants-http.service' +import type { SupportAccess, Tenant, TenantStats, TenantStatus } from '../types/tenant.types' + +/** + * Enter the tenant, then reload rather than navigate. + * + * Everything already fetched — react-query caches, the branding, the + * notification feed — belongs to the operator's own tenant. A soft navigation + * would leave that on screen next to the customer's data, which is exactly the + * confusion a support session must not create. + */ +function enterTenant(tenant: Tenant): void { + setSupportTenant({ + id: tenant.id, + name: tenant.name, + access: tenant.supportAccess === 'FULL' ? 'FULL' : 'READ', + }) + window.location.assign('/home') +} + +// Deterministic hue per tenant, so each card keeps its own accent across loads. +function hueOf(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360 + return h +} + +const RING_TONE: Record = { + sky: 'stroke-sky-500', + violet: 'stroke-violet-500', + amber: 'stroke-amber-500', + emerald: 'stroke-emerald-500', +} + +/** + * One counter as a dial. + * + * `ratio` fills the arc proportionally and is only passed where a real + * denominator exists. Everywhere else the ring is a frame around the number, + * not a measurement — an arc drawn from an invented maximum would read as a + * percentage of nothing. + */ +function Ring({ + label, + value, + tone, + ratio, + caption, +}: { + label: string + value: number | null | undefined + tone: keyof typeof RING_TONE + ratio?: number + caption?: string +}) { + const missing = value == null + const circumference = 2 * Math.PI * 22 + const filled = ratio == null ? 1 : ratio + + return ( +
+
+ + + {!missing && ( + + )} + + + {/* An unreachable subsystem shows a dash. Zero is a real answer and + has to look different from "we could not ask". */} + {value ?? '—'} + +
+ + {label} + + {caption && ( + + {caption} + + )} +
+ ) +} + +const STATUS_STYLE: Record = { + ACTIVE: 'bg-emerald-500/15 text-emerald-600 ring-emerald-500/30 dark:text-emerald-300', + SUSPENDED: 'bg-amber-500/15 text-amber-600 ring-amber-500/30 dark:text-amber-300', + TERMINATED: 'bg-red-500/15 text-red-600 ring-red-500/30 dark:text-red-300', +} + +function StatusBadge({ status }: { status: TenantStatus }) { + const { t } = useTranslation() + return ( + + + {t(`tenants.status.${status}`, { defaultValue: status })} + + ) +} + +function SupportBadge({ level }: { level: SupportAccess }) { + const { t } = useTranslation() + const denied = level === 'NONE' + return ( + + {denied ? : } + {t(`tenants.support.${level}`, { defaultValue: level })} + + ) +} + +// A limit of 0 or less is the backend saying "no cap of its own": the tenant +// simply spends against whatever the instance licence allows. +function aiHint(stats: TenantStats | null, t: TFunction): string | undefined { + if (!stats?.ai) return undefined + return stats.ai.limit > 0 ? `/ ${stats.ai.limit}` : t('tenants.stats.noLimit') +} + +export function TenantCard({ + tenant, + readable, + onEdit, + onTerminate, + onDelete, +}: { + tenant: Tenant + readable: boolean + onEdit: () => void + onTerminate: () => void + onDelete: () => void +}) { + const { t } = useTranslation() + const [stats, setStats] = useState(null) + const [loadingStats, setLoadingStats] = useState(readable) + + useEffect(() => { + if (!readable) { + setLoadingStats(false) + return + } + let cancelled = false + setLoadingStats(true) + fetchTenantStats(tenant.id) + .then((s) => { + if (!cancelled) setStats(s) + }) + .finally(() => { + if (!cancelled) setLoadingStats(false) + }) + return () => { + cancelled = true + } + }, [tenant.id, readable]) + + const hue = hueOf(tenant.name || tenant.domain) + const initial = (tenant.name || tenant.domain).charAt(0).toUpperCase() + const terminated = tenant.status === 'TERMINATED' + + return ( +
+ + +
+ + {initial || } + + +
+

{tenant.name}

+
+ + {tenant.domain} +
+
+ +
+ + {!terminated ? ( + + ) : ( + + )} +
+
+ +
+ + + {readable && !terminated && ( + + )} +
+ +
+ {!readable ? ( + // Not a display rule of ours: the tenant chose this, and the backend + // answers 403 to any read we attempt until they change it. +
+ + {t('tenants.card.noAccess')} +
+ ) : loadingStats ? ( +
+ + {t('tenants.card.loadingStats')} +
+ ) : ( +
+ + + + 0 + ? Math.min(1, stats.ai.used / stats.ai.limit) + : undefined + } + caption={aiHint(stats, t)} + /> +
+ )} +
+
+ ) +} diff --git a/frontend/src/features/tenants/components/TerminateDialog.tsx b/frontend/src/features/tenants/components/TerminateDialog.tsx new file mode 100644 index 000000000..a7670beaf --- /dev/null +++ b/frontend/src/features/tenants/components/TerminateDialog.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/shared/components/ui/button' +import { Input } from '@/shared/components/ui/input' +import { tenantsHttpService } from '../services/tenants-http.service' +import type { Tenant } from '../types/tenant.types' +import { Modal } from './Modal' +import { Field } from './Field' +import { tenantError } from './tenant-error' + +export function TerminateDialog({ + tenant, + onClose, + onDone, +}: { + tenant: Tenant + onClose: () => void + onDone: () => void +}) { + const { t } = useTranslation() + const [confirmation, setConfirmation] = useState('') + const [busy, setBusy] = useState(false) + + // Terminating takes a whole customer offline, so it asks for the name rather + // than a single click that a mis-aimed cursor could produce. + const valid = confirmation.trim() === tenant.name + + const submit = async () => { + if (!valid || busy) return + setBusy(true) + try { + await tenantsHttpService.terminate(tenant.id) + toast.success(t('tenants.toast.terminated')) + onDone() + } catch (err) { + toast.error(tenantError(err, t)) + } finally { + setBusy(false) + } + } + + return ( + + + + + } + > +

+ {t('tenants.terminate.body', { name: tenant.name })} +

+ + setConfirmation(e.target.value)} + placeholder={tenant.name} + /> + +
+ ) +} diff --git a/frontend/src/features/tenants/components/tenant-error.ts b/frontend/src/features/tenants/components/tenant-error.ts new file mode 100644 index 000000000..3ce157c21 --- /dev/null +++ b/frontend/src/features/tenants/components/tenant-error.ts @@ -0,0 +1,12 @@ +import type { TFunction } from 'i18next' +import { TenantsHttpError } from '../services/tenants-http.service' + +export function tenantError(err: unknown, t: TFunction): string { + if (err instanceof TenantsHttpError) { + if (err.status === 409) return t('tenants.toast.domainInUse') + if (err.status === 403) return t('tenants.toast.noPermission') + if (err.status === 404) return t('tenants.toast.notFound') + if (err.status === 400) return err.message || t('tenants.toast.invalidRequest') + } + return err instanceof Error ? err.message : t('tenants.toast.operationFailed') +} diff --git a/frontend/src/features/tenants/pages/TenantsPage.tsx b/frontend/src/features/tenants/pages/TenantsPage.tsx index 914c8065e..c3541529b 100644 --- a/frontend/src/features/tenants/pages/TenantsPage.tsx +++ b/frontend/src/features/tenants/pages/TenantsPage.tsx @@ -1,42 +1,26 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { TFunction } from 'i18next' import { AlertTriangle, Building2, - Check, - Globe, Loader2, - Lock, - LogIn, - Pencil, Plus, - Power, Search, - ShieldCheck, - Trash2, - X, - type LucideIcon, } from 'lucide-react' -import { toast } from 'sonner' import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { useAuth } from '@/features/auth' -import { setSupportTenant } from '@/shared/lib/current-tenant' import { canReadTenant, - fetchTenantStats, - TenantsHttpError, tenantsHttpService, } from '../services/tenants-http.service' -import type { - CreateTenantRequest, - SupportAccess, - Tenant, - TenantStats, - TenantStatus, -} from '../types/tenant.types' +import type { Tenant } from '../types/tenant.types' +import { TenantCard } from '../components/TenantCard' +import { CreateTenantDialog } from '../components/CreateTenantDialog' +import { EditTenantDialog } from '../components/EditTenantDialog' +import { TerminateDialog } from '../components/TerminateDialog' +import { PermanentDeleteDialog } from '../components/PermanentDeleteDialog' /* ───────────────────────────────────────────────────────────────────────── * Page @@ -51,6 +35,7 @@ export function TenantsPage() { const [creating, setCreating] = useState(false) const [editing, setEditing] = useState(null) const [terminating, setTerminating] = useState(null) + const [deletingPermanently, setDeletingPermanently] = useState(null) const load = useCallback(async () => { setError(false) @@ -144,6 +129,7 @@ export function TenantsPage() { readable={canReadTenant(tenant)} onEdit={() => setEditing(tenant)} onTerminate={() => setTerminating(tenant)} + onDelete={() => setDeletingPermanently(tenant)} /> ))} {/* Sits in the grid rather than in the header: adding a customer is @@ -181,6 +167,16 @@ export function TenantsPage() { }} /> )} + {deletingPermanently && ( + setDeletingPermanently(null)} + onDone={() => { + setDeletingPermanently(null) + void load() + }} + /> + )} ) } @@ -213,642 +209,9 @@ function AddTenantCard({ onClick }: { onClick: () => void }) { function StatChip({ label, value }: { label: string; value: number }) { return ( -
+
{value} {label}
) } - -/* ───────────────────────────────────────────────────────────────────────── - * Card - * ───────────────────────────────────────────────────────────────────────── */ - -/** - * Enter the tenant, then reload rather than navigate. - * - * Everything already fetched — react-query caches, the branding, the - * notification feed — belongs to the operator's own tenant. A soft navigation - * would leave that on screen next to the customer's data, which is exactly the - * confusion a support session must not create. - */ -function enterTenant(tenant: Tenant): void { - setSupportTenant({ - id: tenant.id, - name: tenant.name, - access: tenant.supportAccess === 'FULL' ? 'FULL' : 'READ', - }) - window.location.assign('/home') -} - -// Deterministic hue per tenant, so each card keeps its own accent across loads. -function hueOf(s: string): number { - let h = 0 - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360 - return h -} - -function TenantCard({ - tenant, - readable, - onEdit, - onTerminate, -}: { - tenant: Tenant - readable: boolean - onEdit: () => void - onTerminate: () => void -}) { - const { t } = useTranslation() - const [stats, setStats] = useState(null) - const [loadingStats, setLoadingStats] = useState(readable) - - useEffect(() => { - if (!readable) { - setLoadingStats(false) - return - } - let cancelled = false - setLoadingStats(true) - fetchTenantStats(tenant.id) - .then((s) => { - if (!cancelled) setStats(s) - }) - .finally(() => { - if (!cancelled) setLoadingStats(false) - }) - return () => { - cancelled = true - } - }, [tenant.id, readable]) - - const hue = hueOf(tenant.name || tenant.domain) - const initial = (tenant.name || tenant.domain).charAt(0).toUpperCase() - const terminated = tenant.status === 'TERMINATED' - - return ( -
- - -
- - {initial || } - - -
-

{tenant.name}

-
- - {tenant.domain} -
-
- -
- - {!terminated && ( - - )} -
-
- -
- - - {readable && !terminated && ( - - )} -
- -
- {!readable ? ( - // Not a display rule of ours: the tenant chose this, and the backend - // answers 403 to any read we attempt until they change it. -
- - {t('tenants.card.noAccess')} -
- ) : loadingStats ? ( -
- - {t('tenants.card.loadingStats')} -
- ) : ( -
- - - - 0 - ? Math.min(1, stats.ai.used / stats.ai.limit) - : undefined - } - caption={aiHint(stats, t)} - /> -
- )} -
-
- ) -} - -// A limit of 0 or less is the backend saying "no cap of its own": the tenant -// simply spends against whatever the instance licence allows. -function aiHint(stats: TenantStats | null, t: TFunction): string | undefined { - if (!stats?.ai) return undefined - return stats.ai.limit > 0 ? `/ ${stats.ai.limit}` : t('tenants.stats.noLimit') -} - -const RING_TONE: Record = { - sky: 'stroke-sky-500', - violet: 'stroke-violet-500', - amber: 'stroke-amber-500', - emerald: 'stroke-emerald-500', -} - -/** - * One counter as a dial. - * - * `ratio` fills the arc proportionally and is only passed where a real - * denominator exists. Everywhere else the ring is a frame around the number, - * not a measurement — an arc drawn from an invented maximum would read as a - * percentage of nothing. - */ -function Ring({ - label, - value, - tone, - ratio, - caption, -}: { - label: string - value: number | null | undefined - tone: keyof typeof RING_TONE - ratio?: number - caption?: string -}) { - const missing = value == null - const circumference = 2 * Math.PI * 22 - const filled = ratio == null ? 1 : ratio - - return ( -
-
- - - {!missing && ( - - )} - - - {/* An unreachable subsystem shows a dash. Zero is a real answer and - has to look different from "we could not ask". */} - {value ?? '—'} - -
- - {label} - - {caption && ( - - {caption} - - )} -
- ) -} - -const STATUS_STYLE: Record = { - ACTIVE: 'bg-emerald-500/15 text-emerald-600 ring-emerald-500/30 dark:text-emerald-300', - SUSPENDED: 'bg-amber-500/15 text-amber-600 ring-amber-500/30 dark:text-amber-300', - TERMINATED: 'bg-red-500/15 text-red-600 ring-red-500/30 dark:text-red-300', -} - -function StatusBadge({ status }: { status: TenantStatus }) { - const { t } = useTranslation() - return ( - - - {t(`tenants.status.${status}`, { defaultValue: status })} - - ) -} - -function SupportBadge({ level }: { level: SupportAccess }) { - const { t } = useTranslation() - const denied = level === 'NONE' - return ( - - {denied ? : } - {t(`tenants.support.${level}`, { defaultValue: level })} - - ) -} - -/* ───────────────────────────────────────────────────────────────────────── - * Dialogs - * ───────────────────────────────────────────────────────────────────────── */ - -function Modal({ - title, - icon: Icon, - subtitle, - onClose, - children, - footer, -}: { - title: string - icon: LucideIcon - subtitle?: string - onClose: () => void - children: React.ReactNode - footer: React.ReactNode -}) { - return ( -
-
e.stopPropagation()} - > -
-
-

- - {title} -

- {subtitle &&

{subtitle}

} -
- -
-
{children}
-
- {footer} -
-
-
- ) -} - -function Field({ - label, - hint, - children, -}: { - label: string - hint?: string - children: React.ReactNode -}) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ) -} - -function CreateTenantDialog({ - onClose, - onCreated, -}: { - onClose: () => void - onCreated: () => void -}) { - const { t } = useTranslation() - const [name, setName] = useState('') - const [domain, setDomain] = useState('') - const [adminEmail, setAdminEmail] = useState('') - const [busy, setBusy] = useState(false) - - const valid = - name.trim().length >= 2 && domain.trim().length >= 3 && /.+@.+\..+/.test(adminEmail.trim()) - - const submit = async () => { - if (!valid || busy) return - setBusy(true) - try { - const body: CreateTenantRequest = { - name: name.trim(), - domain: domain.trim().toLowerCase(), - adminEmail: adminEmail.trim(), - } - await tenantsHttpService.create(body) - toast.success(t('tenants.toast.created')) - onCreated() - } catch (err) { - toast.error(tenantError(err, t)) - } finally { - setBusy(false) - } - } - - return ( - - - - - } - > - - setName(e.target.value)} placeholder="Acme Corp" /> - - - setDomain(e.target.value)} - className="font-mono" - placeholder="acme.utmstack.com" - /> - - - setAdminEmail(e.target.value)} - placeholder="admin@acme.com" - /> - - - ) -} - -const STATUSES: TenantStatus[] = ['ACTIVE', 'SUSPENDED'] - -function EditTenantDialog({ - tenant, - onClose, - onSaved, -}: { - tenant: Tenant - onClose: () => void - onSaved: () => void -}) { - const { t } = useTranslation() - const [name, setName] = useState(tenant.name) - const [domain, setDomain] = useState(tenant.domain) - const [status, setStatus] = useState(tenant.status) - const [capped, setCapped] = useState(tenant.limits.maxAIRequests != null) - const [maxAI, setMaxAI] = useState(String(tenant.limits.maxAIRequests ?? '')) - const [busy, setBusy] = useState(false) - - const parsedMax = Number(maxAI) - const limitValid = !capped || (maxAI.trim() !== '' && Number.isInteger(parsedMax) && parsedMax >= 0) - const valid = name.trim().length >= 2 && domain.trim().length >= 3 && limitValid - - const submit = async () => { - if (!valid || busy) return - setBusy(true) - try { - await tenantsHttpService.update(tenant.id, { - name: name.trim(), - domain: domain.trim().toLowerCase(), - status, - // Clearing the cap sends an explicit null: omitting the field would - // mean "leave it as it is", which is the opposite of the intent. - maxAIRequests: capped ? parsedMax : null, - }) - toast.success(t('tenants.toast.updated')) - onSaved() - } catch (err) { - toast.error(tenantError(err, t)) - } finally { - setBusy(false) - } - } - - return ( - - - - - } - > - - setName(e.target.value)} /> - - - setDomain(e.target.value)} className="font-mono" /> - - - -
- {STATUSES.map((s) => ( - - ))} -
- {status === 'SUSPENDED' && ( -

- {t('tenants.fields.suspendedWarning')} -

- )} -
- - - - {capped && ( - setMaxAI(e.target.value)} - placeholder="1000" - className="mt-2" - /> - )} - -
- ) -} - -function TerminateDialog({ - tenant, - onClose, - onDone, -}: { - tenant: Tenant - onClose: () => void - onDone: () => void -}) { - const { t } = useTranslation() - const [confirmation, setConfirmation] = useState('') - const [busy, setBusy] = useState(false) - - // Terminating takes a whole customer offline, so it asks for the name rather - // than a single click that a mis-aimed cursor could produce. - const valid = confirmation.trim() === tenant.name - - const submit = async () => { - if (!valid || busy) return - setBusy(true) - try { - await tenantsHttpService.terminate(tenant.id) - toast.success(t('tenants.toast.terminated')) - onDone() - } catch (err) { - toast.error(tenantError(err, t)) - } finally { - setBusy(false) - } - } - - return ( - - - - - } - > -

- {t('tenants.terminate.body', { name: tenant.name })} -

- - setConfirmation(e.target.value)} - placeholder={tenant.name} - /> - -
- ) -} - -function tenantError(err: unknown, t: TFunction): string { - if (err instanceof TenantsHttpError) { - if (err.status === 409) return t('tenants.toast.domainInUse') - if (err.status === 403) return t('tenants.toast.noPermission') - if (err.status === 404) return t('tenants.toast.notFound') - if (err.status === 400) return err.message || t('tenants.toast.invalidRequest') - } - return err instanceof Error ? err.message : t('tenants.toast.operationFailed') -} diff --git a/frontend/src/features/tenants/services/tenants-http.service.ts b/frontend/src/features/tenants/services/tenants-http.service.ts index 10ac488fc..4dc1e89d0 100644 --- a/frontend/src/features/tenants/services/tenants-http.service.ts +++ b/frontend/src/features/tenants/services/tenants-http.service.ts @@ -29,6 +29,8 @@ export const tenantsHttpService = { create: (input: CreateTenantRequest) => api.post('/tenants', input), update: (id: string, input: UpdateTenantRequest) => api.put(`/tenants/${id}`, input), terminate: (id: string) => api.delete(`/tenants/${id}`), + reactivate: (id: string) => api.post(`/tenants/${id}/reactivate`, {}), + permanentlyDelete: (id: string) => api.delete(`/tenants/${id}/permanent`), } /** diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index 24065e1a6..aed0baab4 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -5429,6 +5429,7 @@ "card": { "edit": "Bearbeiten", "terminate": "Beenden", + "deletePermanent": "Delete permanently", "noAccess": "Dieser Mandant hat Ihnen keinen Zugriff gewährt.", "loadingStats": "Wird gelesen…", "enter": "Betreten" @@ -5468,10 +5469,23 @@ "confirmLabel": "Zum Bestätigen {{name}} eingeben", "submit": "Beenden" }, + "reactivate": { + "title": "Reactivate this tenant?", + "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", + "submit": "Reactivate" + }, + "deletePermanent": { + "title": "Delete this tenant forever?", + "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", + "confirmLabel": "Type {{name}} to confirm", + "submit": "Delete forever" + }, "toast": { "created": "Mandant angelegt", "updated": "Mandant aktualisiert", "terminated": "Mandant beendet", + "reactivated": "Tenant reactivated", + "permanentlyDeleted": "Tenant permanently deleted", "domainInUse": "Diese Domain ist bereits vergeben", "noPermission": "Dazu fehlt Ihnen die Berechtigung", "notFound": "Mandant nicht gefunden", diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index a3b705bad..b23ed55c4 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -5409,6 +5409,7 @@ "card": { "edit": "Edit", "terminate": "Terminate", + "deletePermanent": "Delete permanently", "noAccess": "This tenant has not granted you access.", "loadingStats": "Reading…", "enter": "Enter" @@ -5448,10 +5449,23 @@ "confirmLabel": "Type {{name}} to confirm", "submit": "Terminate" }, + "reactivate": { + "title": "Reactivate this tenant?", + "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", + "submit": "Reactivate" + }, + "deletePermanent": { + "title": "Delete this tenant forever?", + "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", + "confirmLabel": "Type {{name}} to confirm", + "submit": "Delete forever" + }, "toast": { "created": "Tenant created", "updated": "Tenant updated", "terminated": "Tenant terminated", + "reactivated": "Tenant reactivated", + "permanentlyDeleted": "Tenant permanently deleted", "domainInUse": "That domain is already taken", "noPermission": "You do not have permission to do that", "notFound": "Tenant not found", diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index 6d502409b..de99e2fce 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -5391,6 +5391,7 @@ "card": { "edit": "Editar", "terminate": "Terminar", + "deletePermanent": "Delete permanently", "noAccess": "Este tenant no te ha dado acceso.", "loadingStats": "Leyendo…", "enter": "Entrar" @@ -5430,10 +5431,23 @@ "confirmLabel": "Escribe {{name}} para confirmar", "submit": "Terminar" }, + "reactivate": { + "title": "Reactivate this tenant?", + "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", + "submit": "Reactivate" + }, + "deletePermanent": { + "title": "Delete this tenant forever?", + "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", + "confirmLabel": "Type {{name}} to confirm", + "submit": "Delete forever" + }, "toast": { "created": "Tenant creado", "updated": "Tenant actualizado", "terminated": "Tenant terminado", + "reactivated": "Tenant reactivated", + "permanentlyDeleted": "Tenant permanently deleted", "domainInUse": "Ese dominio ya está en uso", "noPermission": "No tienes permiso para hacer eso", "notFound": "Tenant no encontrado", diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index 2d6e8536e..3169c0e0c 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -5429,6 +5429,7 @@ "card": { "edit": "Modifier", "terminate": "Résilier", + "deletePermanent": "Delete permanently", "noAccess": "Ce locataire ne vous a pas donné accès.", "loadingStats": "Lecture…", "enter": "Entrer" @@ -5468,10 +5469,23 @@ "confirmLabel": "Saisissez {{name}} pour confirmer", "submit": "Résilier" }, + "reactivate": { + "title": "Reactivate this tenant?", + "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", + "submit": "Reactivate" + }, + "deletePermanent": { + "title": "Delete this tenant forever?", + "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", + "confirmLabel": "Type {{name}} to confirm", + "submit": "Delete forever" + }, "toast": { "created": "Locataire créé", "updated": "Locataire mis à jour", "terminated": "Locataire résilié", + "reactivated": "Tenant reactivated", + "permanentlyDeleted": "Tenant permanently deleted", "domainInUse": "Ce domaine est déjà pris", "noPermission": "Vous n'avez pas la permission de faire cela", "notFound": "Locataire introuvable", diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index 95f64b70f..422bd7654 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -5429,6 +5429,7 @@ "card": { "edit": "Modifica", "terminate": "Termina", + "deletePermanent": "Delete permanently", "noAccess": "Questo tenant non ti ha dato accesso.", "loadingStats": "Lettura…", "enter": "Entra" @@ -5468,10 +5469,23 @@ "confirmLabel": "Scrivi {{name}} per confermare", "submit": "Termina" }, + "reactivate": { + "title": "Reactivate this tenant?", + "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", + "submit": "Reactivate" + }, + "deletePermanent": { + "title": "Delete this tenant forever?", + "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", + "confirmLabel": "Type {{name}} to confirm", + "submit": "Delete forever" + }, "toast": { "created": "Tenant creato", "updated": "Tenant aggiornato", "terminated": "Tenant terminato", + "reactivated": "Tenant reactivated", + "permanentlyDeleted": "Tenant permanently deleted", "domainInUse": "Questo dominio è già in uso", "noPermission": "Non hai il permesso di farlo", "notFound": "Tenant non trovato", diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index 6f2690f94..cbba6238e 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -5391,6 +5391,7 @@ "card": { "edit": "Editar", "terminate": "Encerrar", + "deletePermanent": "Delete permanently", "noAccess": "Este tenant não concedeu acesso a você.", "loadingStats": "Lendo…", "enter": "Entrar" @@ -5430,10 +5431,23 @@ "confirmLabel": "Digite {{name}} para confirmar", "submit": "Encerrar" }, + "reactivate": { + "title": "Reactivate this tenant?", + "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", + "submit": "Reactivate" + }, + "deletePermanent": { + "title": "Delete this tenant forever?", + "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", + "confirmLabel": "Type {{name}} to confirm", + "submit": "Delete forever" + }, "toast": { "created": "Tenant criado", "updated": "Tenant atualizado", "terminated": "Tenant encerrado", + "reactivated": "Tenant reactivated", + "permanentlyDeleted": "Tenant permanently deleted", "domainInUse": "Esse domínio já está em uso", "noPermission": "Você não tem permissão para isso", "notFound": "Tenant não encontrado", diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index 69fd3d2ee..6bcee0f3d 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -5453,6 +5453,7 @@ "card": { "edit": "Изменить", "terminate": "Закрыть", + "deletePermanent": "Delete permanently", "noAccess": "Этот арендатор не предоставил вам доступ.", "loadingStats": "Чтение…", "enter": "Войти" @@ -5492,10 +5493,23 @@ "confirmLabel": "Введите {{name}} для подтверждения", "submit": "Закрыть" }, + "reactivate": { + "title": "Reactivate this tenant?", + "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", + "submit": "Reactivate" + }, + "deletePermanent": { + "title": "Delete this tenant forever?", + "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", + "confirmLabel": "Type {{name}} to confirm", + "submit": "Delete forever" + }, "toast": { "created": "Арендатор создан", "updated": "Арендатор обновлён", "terminated": "Арендатор закрыт", + "reactivated": "Tenant reactivated", + "permanentlyDeleted": "Tenant permanently deleted", "domainInUse": "Этот домен уже занят", "noPermission": "У вас нет прав на это действие", "notFound": "Арендатор не найден", From 902c18c723695abde76a2d17f0903b5e89fab125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20S=C3=A1nchez?= Date: Tue, 18 Aug 2026 15:27:44 -0600 Subject: [PATCH 4/4] fix[frontend](tenants): added missing translations --- frontend/src/shared/i18n/locales/de.json | 20 ++++++++++---------- frontend/src/shared/i18n/locales/es.json | 20 ++++++++++---------- frontend/src/shared/i18n/locales/fr.json | 20 ++++++++++---------- frontend/src/shared/i18n/locales/it.json | 20 ++++++++++---------- frontend/src/shared/i18n/locales/pt.json | 20 ++++++++++---------- frontend/src/shared/i18n/locales/ru.json | 20 ++++++++++---------- 6 files changed, 60 insertions(+), 60 deletions(-) diff --git a/frontend/src/shared/i18n/locales/de.json b/frontend/src/shared/i18n/locales/de.json index aed0baab4..6d37ca983 100644 --- a/frontend/src/shared/i18n/locales/de.json +++ b/frontend/src/shared/i18n/locales/de.json @@ -5429,7 +5429,7 @@ "card": { "edit": "Bearbeiten", "terminate": "Beenden", - "deletePermanent": "Delete permanently", + "deletePermanent": "Endgültig löschen", "noAccess": "Dieser Mandant hat Ihnen keinen Zugriff gewährt.", "loadingStats": "Wird gelesen…", "enter": "Betreten" @@ -5470,22 +5470,22 @@ "submit": "Beenden" }, "reactivate": { - "title": "Reactivate this tenant?", - "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", - "submit": "Reactivate" + "title": "Diesen Mandanten reaktivieren?", + "body": "{{name}} kann sich wieder anmelden. Die aufbewahrten Daten werden unverändert wiederhergestellt.", + "submit": "Reaktivieren" }, "deletePermanent": { - "title": "Delete this tenant forever?", - "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", - "confirmLabel": "Type {{name}} to confirm", - "submit": "Delete forever" + "title": "Diesen Mandanten für immer löschen?", + "body": "Jede Spur von {{name}} — Benutzer, Datenquellen, Dashboards, Alarme, Integrationen — wird gelöscht. Das lässt sich nicht rückgängig machen.", + "confirmLabel": "Zum Bestätigen {{name}} eingeben", + "submit": "Für immer löschen" }, "toast": { "created": "Mandant angelegt", "updated": "Mandant aktualisiert", "terminated": "Mandant beendet", - "reactivated": "Tenant reactivated", - "permanentlyDeleted": "Tenant permanently deleted", + "reactivated": "Mandant reaktiviert", + "permanentlyDeleted": "Mandant endgültig gelöscht", "domainInUse": "Diese Domain ist bereits vergeben", "noPermission": "Dazu fehlt Ihnen die Berechtigung", "notFound": "Mandant nicht gefunden", diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index de99e2fce..5632d2e52 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -5391,7 +5391,7 @@ "card": { "edit": "Editar", "terminate": "Terminar", - "deletePermanent": "Delete permanently", + "deletePermanent": "Eliminar permanentemente", "noAccess": "Este tenant no te ha dado acceso.", "loadingStats": "Leyendo…", "enter": "Entrar" @@ -5432,22 +5432,22 @@ "submit": "Terminar" }, "reactivate": { - "title": "Reactivate this tenant?", - "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", - "submit": "Reactivate" + "title": "¿Reactivar este tenant?", + "body": "{{name}} podrá iniciar sesión de nuevo. Sus datos conservados se restauran tal cual.", + "submit": "Reactivar" }, "deletePermanent": { - "title": "Delete this tenant forever?", - "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", - "confirmLabel": "Type {{name}} to confirm", - "submit": "Delete forever" + "title": "¿Eliminar este tenant para siempre?", + "body": "Todo rastro de {{name}} — usuarios, fuentes de datos, dashboards, alertas, integraciones — será borrado. Esto no se puede deshacer.", + "confirmLabel": "Escribe {{name}} para confirmar", + "submit": "Eliminar para siempre" }, "toast": { "created": "Tenant creado", "updated": "Tenant actualizado", "terminated": "Tenant terminado", - "reactivated": "Tenant reactivated", - "permanentlyDeleted": "Tenant permanently deleted", + "reactivated": "Tenant reactivado", + "permanentlyDeleted": "Tenant eliminado permanentemente", "domainInUse": "Ese dominio ya está en uso", "noPermission": "No tienes permiso para hacer eso", "notFound": "Tenant no encontrado", diff --git a/frontend/src/shared/i18n/locales/fr.json b/frontend/src/shared/i18n/locales/fr.json index 3169c0e0c..4eadf0521 100644 --- a/frontend/src/shared/i18n/locales/fr.json +++ b/frontend/src/shared/i18n/locales/fr.json @@ -5429,7 +5429,7 @@ "card": { "edit": "Modifier", "terminate": "Résilier", - "deletePermanent": "Delete permanently", + "deletePermanent": "Supprimer définitivement", "noAccess": "Ce locataire ne vous a pas donné accès.", "loadingStats": "Lecture…", "enter": "Entrer" @@ -5470,22 +5470,22 @@ "submit": "Résilier" }, "reactivate": { - "title": "Reactivate this tenant?", - "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", - "submit": "Reactivate" + "title": "Réactiver ce locataire ?", + "body": "{{name}} pourra se reconnecter. Les données conservées sont restaurées telles quelles.", + "submit": "Réactiver" }, "deletePermanent": { - "title": "Delete this tenant forever?", - "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", - "confirmLabel": "Type {{name}} to confirm", - "submit": "Delete forever" + "title": "Supprimer ce locataire pour toujours ?", + "body": "Toute trace de {{name}} — utilisateurs, sources de données, tableaux de bord, alertes, intégrations — sera effacée. C'est irréversible.", + "confirmLabel": "Saisissez {{name}} pour confirmer", + "submit": "Supprimer définitivement" }, "toast": { "created": "Locataire créé", "updated": "Locataire mis à jour", "terminated": "Locataire résilié", - "reactivated": "Tenant reactivated", - "permanentlyDeleted": "Tenant permanently deleted", + "reactivated": "Locataire réactivé", + "permanentlyDeleted": "Locataire supprimé définitivement", "domainInUse": "Ce domaine est déjà pris", "noPermission": "Vous n'avez pas la permission de faire cela", "notFound": "Locataire introuvable", diff --git a/frontend/src/shared/i18n/locales/it.json b/frontend/src/shared/i18n/locales/it.json index 422bd7654..f774f2693 100644 --- a/frontend/src/shared/i18n/locales/it.json +++ b/frontend/src/shared/i18n/locales/it.json @@ -5429,7 +5429,7 @@ "card": { "edit": "Modifica", "terminate": "Termina", - "deletePermanent": "Delete permanently", + "deletePermanent": "Elimina definitivamente", "noAccess": "Questo tenant non ti ha dato accesso.", "loadingStats": "Lettura…", "enter": "Entra" @@ -5470,22 +5470,22 @@ "submit": "Termina" }, "reactivate": { - "title": "Reactivate this tenant?", - "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", - "submit": "Reactivate" + "title": "Riattivare questo tenant?", + "body": "{{name}} potrà accedere di nuovo. I dati conservati vengono ripristinati così come sono.", + "submit": "Riattiva" }, "deletePermanent": { - "title": "Delete this tenant forever?", - "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", - "confirmLabel": "Type {{name}} to confirm", - "submit": "Delete forever" + "title": "Eliminare questo tenant per sempre?", + "body": "Ogni traccia di {{name}} — utenti, sorgenti dati, dashboard, avvisi, integrazioni — sarà cancellata. Non si può annullare.", + "confirmLabel": "Scrivi {{name}} per confermare", + "submit": "Elimina per sempre" }, "toast": { "created": "Tenant creato", "updated": "Tenant aggiornato", "terminated": "Tenant terminato", - "reactivated": "Tenant reactivated", - "permanentlyDeleted": "Tenant permanently deleted", + "reactivated": "Tenant riattivato", + "permanentlyDeleted": "Tenant eliminato definitivamente", "domainInUse": "Questo dominio è già in uso", "noPermission": "Non hai il permesso di farlo", "notFound": "Tenant non trovato", diff --git a/frontend/src/shared/i18n/locales/pt.json b/frontend/src/shared/i18n/locales/pt.json index cbba6238e..84a2ad6ba 100644 --- a/frontend/src/shared/i18n/locales/pt.json +++ b/frontend/src/shared/i18n/locales/pt.json @@ -5391,7 +5391,7 @@ "card": { "edit": "Editar", "terminate": "Encerrar", - "deletePermanent": "Delete permanently", + "deletePermanent": "Excluir permanentemente", "noAccess": "Este tenant não concedeu acesso a você.", "loadingStats": "Lendo…", "enter": "Entrar" @@ -5432,22 +5432,22 @@ "submit": "Encerrar" }, "reactivate": { - "title": "Reactivate this tenant?", - "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", - "submit": "Reactivate" + "title": "Reativar este tenant?", + "body": "{{name}} poderá entrar novamente. Os dados preservados são restaurados como estavam.", + "submit": "Reativar" }, "deletePermanent": { - "title": "Delete this tenant forever?", - "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", - "confirmLabel": "Type {{name}} to confirm", - "submit": "Delete forever" + "title": "Excluir este tenant para sempre?", + "body": "Todo vestígio de {{name}} — usuários, fontes de dados, dashboards, alertas, integrações — será apagado. Isso não pode ser desfeito.", + "confirmLabel": "Digite {{name}} para confirmar", + "submit": "Excluir para sempre" }, "toast": { "created": "Tenant criado", "updated": "Tenant atualizado", "terminated": "Tenant encerrado", - "reactivated": "Tenant reactivated", - "permanentlyDeleted": "Tenant permanently deleted", + "reactivated": "Tenant reativado", + "permanentlyDeleted": "Tenant excluído permanentemente", "domainInUse": "Esse domínio já está em uso", "noPermission": "Você não tem permissão para isso", "notFound": "Tenant não encontrado", diff --git a/frontend/src/shared/i18n/locales/ru.json b/frontend/src/shared/i18n/locales/ru.json index 6bcee0f3d..7eb5c3251 100644 --- a/frontend/src/shared/i18n/locales/ru.json +++ b/frontend/src/shared/i18n/locales/ru.json @@ -5453,7 +5453,7 @@ "card": { "edit": "Изменить", "terminate": "Закрыть", - "deletePermanent": "Delete permanently", + "deletePermanent": "Удалить безвозвратно", "noAccess": "Этот арендатор не предоставил вам доступ.", "loadingStats": "Чтение…", "enter": "Войти" @@ -5494,22 +5494,22 @@ "submit": "Закрыть" }, "reactivate": { - "title": "Reactivate this tenant?", - "body": "{{name}} will be able to sign in again. Their preserved data is restored as-is.", - "submit": "Reactivate" + "title": "Восстановить этого арендатора?", + "body": "{{name}} снова сможет войти. Сохранённые данные восстанавливаются как есть.", + "submit": "Восстановить" }, "deletePermanent": { - "title": "Delete this tenant forever?", - "body": "Every trace of {{name}} — users, data sources, dashboards, alerts, integrations — will be erased. This cannot be undone.", - "confirmLabel": "Type {{name}} to confirm", - "submit": "Delete forever" + "title": "Удалить этого арендатора навсегда?", + "body": "Все следы {{name}} — пользователи, источники данных, дашборды, оповещения, интеграции — будут стёрты. Это нельзя отменить.", + "confirmLabel": "Введите {{name}} для подтверждения", + "submit": "Удалить навсегда" }, "toast": { "created": "Арендатор создан", "updated": "Арендатор обновлён", "terminated": "Арендатор закрыт", - "reactivated": "Tenant reactivated", - "permanentlyDeleted": "Tenant permanently deleted", + "reactivated": "Арендатор восстановлен", + "permanentlyDeleted": "Арендатор удалён безвозвратно", "domainInUse": "Этот домен уже занят", "noPermission": "У вас нет прав на это действие", "notFound": "Арендатор не найден",