diff --git a/README.md b/README.md index 6d21e26..4dde2d8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine - Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual - Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel - Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray -- Contas de revendedor (reseller) com cota de usuários e escopo próprio +- Revendedores hierárquicos com sub-revendas, planos por validade/slots ou créditos, auditoria e escopo próprio - Gerenciamento multi-servidor (master/slave) direto pelo painel - API HTTP completa para bots/automações (ver **HTTP API Reference**) - API pública `/check` para consultar usuário ou UUID @@ -66,6 +66,23 @@ Para configurações XHTTP antigas, carregue a configuração visual e clique em A confirmação dessa migração é exibida dentro do próprio painel. Se a gravação falhar, o inbound SSH temporário é removido do rascunho e o inbound antigo permanece intacto, permitindo tentar novamente após corrigir o erro exibido. +### Revendedores compatíveis com o painel PHP antigo + +A área **Revendedores** mantém o fluxo mais importante do DraconCore PHP, com autorização refeita no servidor: + +- cada revendedor gerencia apenas suas contas e seus sub-revendedores diretos; +- o superadmin enxerga toda a hierarquia; +- plano **Validade / slots** compartilha a cota entre a capacidade `max_connections` das contas SSH/Xray e os slots reservados aos sub-revendedores; +- plano **Créditos** debita no cadastro e na renovação; o custo acompanha `max_connections`, com mínimo de uma conexão, e esse limite fica congelado após a criação; +- contas de plano por crédito recebem 31 dias por cadastro ou renovação; +- criação, edição, renovação, suspensão, reativação e exclusão de revendedores ficam registradas em auditoria; +- suspensão bloqueia a árvore e remove os clientes Xray do runtime sem apagar seus metadados; a reativação restaura os clientes ainda válidos; +- exclusão remove a árvore, suas contas SSH/Xray e devolve ao pai os créditos ainda não usados; +- a cota considera todos os nós gerenciados, mesmo temporariamente desativados, para impedir liberação artificial de limite; +- o estado de acesso é sincronizado com nós gerenciados sem copiar senha ou hash de login. + +Revendedores existentes são migrados automaticamente como contas principais no modo **Validade / slots**. Não é necessário recriá-los. Por segurança, as funções antigas de revelar senha em texto puro e de alterar a sessão para “entrar como revendedor” não foram copiadas. + ### Requisitos - Servidor Linux com `systemd` @@ -73,6 +90,8 @@ A confirmação dessa migração é exibida dentro do próprio painel. Se a grav - Gerenciador de pacotes `apt`, `yum` ou `dnf` - Portas liberadas no firewall/security group conforme a configuração usada +O instalador usa Go 1.25.12 e baixa as dependências fixadas no `go.mod`. As bibliotecas oficiais `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text` e `x/time` estão fixadas nas versões de segurança revisadas em 13/07/2026. + Distribuições alvo: - Ubuntu / Debian / Linux Mint @@ -92,6 +111,7 @@ sudo bash install.sh Durante a instalação, o script instala/configura: - Go +- verificação SHA-256 dos arquivos oficiais de Go e Xray antes da extração - PostgreSQL - Xray-core - Binário do DragonCoreSSH V40 @@ -545,7 +565,8 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post - Compact infrastructure workspace with Servers, Status, Monitoring, and Traffic in one visual switcher - Live status cards across SSH, Xray, and Infrastructure, with panel-native confirmations - Bot-style section navigation throughout the panel: SSH/SlowDNS and Resellers separate lists from creation; Xray separates Users, Create User, Configuration, and Logs; Settings separates Network/SSH, SlowDNS, UDP, TLS, and Xray -- Reseller accounts with a user quota and self-scoped access +- Full reseller workflow compatible with the useful parts of the legacy PHP panel: direct-child hierarchy, validity/slot and credit plans, weighted SSH/Xray connection quotas, renew, suspend/reactivate, delete, and audit history +- Existing reseller-owned SSH/Xray accounts with a legacy zero connection limit are migrated to one slot automatically; they do not need to be recreated - Multi-server (master/slave) management directly from the panel - Full HTTP API for bots/automations (see **HTTP API Reference**) - Public `/check` API for checking username or UUID @@ -602,6 +623,8 @@ The migration confirmation is rendered inside the panel. If saving fails, the te - `apt`, `yum`, or `dnf` package manager - Required ports opened in the firewall/security group +The installer uses Go 1.25.12 and downloads the versions pinned in `go.mod`. The official `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text`, and `x/time` modules are pinned to the security-reviewed versions current on 2026-07-13. + Target distributions: - Ubuntu / Debian / Linux Mint @@ -621,6 +644,7 @@ sudo bash install.sh During installation, the script installs/configures: - Go +- SHA-256 verification of the official Go and Xray archives before extraction - PostgreSQL - Xray-core - DragonCoreSSH V40 binary @@ -1112,7 +1136,7 @@ curl -s "http://SERVER_IP:9090/api/users" -H "X-Session-Token: $TOKEN" - No body. Deletes the session for the supplied `X-Session-Token`. Returns `200` (empty). #### `GET /api/auth/me` — session -- `200`: `{ "username": string, "role": string }`. If the role is `reseller`, it also includes `max_users` (int), `used_users` (int, combined SSH+Xray), `used_ssh_users` (int), `used_xray_users` (int), `expires_at` (string RFC3339 or null), `is_active` (bool). +- `200`: `{ "username": string, "role": string }`. Reseller responses also include `max_users`, weighted `used_users`, SSH/Xray account counts across managed nodes, `parent_username`, `quota_mode`, `credit_balance`, child allocation/count, expiry, and direct/effective active status. --- @@ -1180,19 +1204,35 @@ Creates or updates (upsert) an SSH user. --- -### Resellers (superadmin only) +### Resellers (authenticated; hierarchy scoped) -#### `GET /api/resellers` — superadmin -- `200`: array of `{ "id": int, "username": string, "role": string, "max_users": int, "used_users": int, "used_ssh_users": int, "used_xray_users": int, "expires_at": string/null, "is_active": bool, "created_at": string }`. +Superadmins manage every reseller. A reseller sees and manages only its direct children; it cannot skip a hierarchy level. Child plans inherit the parent's `slots` or `credits` mode. -#### `POST /api/resellers/create` — superadmin -Creates or updates a reseller (upsert by username). -- Body: `username` (string, required); `password` (string, optional — required only when creating; if given on an existing account it is changed); `max_users` (int); `expires_at` (string, optional RFC3339; empty clears expiry); `is_active` (bool). -- `201 Created` (empty). Errors: `400 username required`, `400 password required for new account`, `400 invalid expires_at (RFC3339 required)`; `500 db error`. +#### `GET /api/resellers` +- `200`: direct-child array with hierarchy, plan, weighted quota, account-count breakdown, contact, price, expiry, and effective status fields: `{ "id", "username", "parent_username", "quota_mode", "max_users", "credit_balance", "used_users", "used_ssh_users", "used_xray_users", "child_allocation", "child_count", "available", "usage_incomplete", "whatsapp", "monthly_price_cents", "expires_at", "is_active", "effective_active", "created_at" }`. +- `used_users` is weighted by each SSH/Xray account's connection limit and includes every configured managed node. If a node cannot be verified, `usage_incomplete` is true and provisioning remains fail-closed. -#### `DELETE /api/resellers/delete` — superadmin -- Query: `username` (string, required). Also disconnects/removes the reseller's owned SSH users and Xray clients. -- `204 No Content`. Errors: `400 username required`; `500 db error`. +#### `POST /api/resellers/create` +Creates or edits a reseller. +- Body: `username` (required); `password` (required only on create); `parent_username`; `quota_mode` (`slots` or `credits`); `max_users`; `credits`; `expires_at` (RFC3339 for slot plans); `is_active`; `whatsapp`; `monthly_price_cents`. +- For reseller callers, `parent_username` and `quota_mode` are forced to the signed-in parent. Parent and plan mode are immutable after creation. +- Credit transfers are atomic and audited. Slot limits cannot be reduced below direct account use plus reserved child allocation. +- `201`: `{ "username": string, "created": bool }`. + +#### `POST /api/resellers/action` +- Body: `username`, `action` (`renew`, `suspend`, `reactivate`, or superadmin-only `pull`), and optional `days`. +- `renew` extends a validity reseller from the later of now/current expiry. `suspend` and `reactivate` apply to the full descendant tree and owned SSH/Xray access on managed nodes. `pull` safely attaches a nested reseller directly to the main panel without duplicating transferred credits. +- `200`: `{ "ok": true, "runtime_warning": string }`. + +#### `DELETE /api/resellers/delete` +- Query: `username` (required). Suspends the subtree first, then removes all descendant reseller records and their owned SSH/Xray accounts locally and from every configured managed node. Unused descendant credit balances are returned once to the direct credit parent. +- `204 No Content`. + +#### `GET /api/resellers/audit` +- Returns the latest 200 lifecycle/credit events. Resellers receive only their own and direct-child activity. + +#### `POST /api/resellers/runtime` — superadmin/internal node synchronization +- Password-free master-to-node hierarchy/status synchronization used for managed-server suspension, expiry, reactivation, and cleanup. Login passwords and password hashes are never replicated. --- diff --git a/account_renewal.go b/account_renewal.go new file mode 100644 index 0000000..ae398fa --- /dev/null +++ b/account_renewal.go @@ -0,0 +1,340 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +type accountRenewPayload struct { + Username string `json:"username,omitempty"` + UUID string `json:"uuid,omitempty"` + Days int `json:"days,omitempty"` + ServerID string `json:"server_id,omitempty"` +} + +func renewalDays(owner string, requested int) int { + if u, ok := adminUsers.get(owner); ok && normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit { + return 31 + } + if requested == 0 { + return 30 + } + return requested +} + +func renewalExpiry(existing *time.Time, days int) time.Time { + base := time.Now() + if existing != nil && existing.After(base) { + base = *existing + } + return base.AddDate(0, 0, days) +} + +func jsonInt(value interface{}) int { + switch value := value.(type) { + case int: + return value + case int64: + return int(value) + case float64: + return int(value) + case json.Number: + result, _ := strconv.Atoi(value.String()) + return result + default: + result, _ := strconv.ParseFloat(fmt.Sprint(value), 64) + return int(result) + } +} + +func handleRenewSSHUser(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var p accountRenewPayload + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + p.Username = strings.TrimSpace(p.Username) + if err := validateAccountUsername(p.Username); err != nil { + http.Error(w, "invalid username", http.StatusBadRequest) + return + } + if p.Days < 0 || p.Days > 3650 { + http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest) + return + } + ctx := r.Context() + sess := sessionFromCtx(ctx) + if sess != nil && sess.Role == RoleReseller { + quotaUnlock := lockResellerQuota(sess.Username) + defer quotaUnlock() + } + if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil { + writeManagedServerSelectionError(w, err) + return + } else if remote { + row, exists, infoErr := remoteSSHUserInfo(ctx, ms, p.Username) + if infoErr != nil { + http.Error(w, "could not verify remote account", http.StatusBadGateway) + return + } + if !exists { + http.Error(w, "SSH account not found", http.StatusNotFound) + return + } + owner := strings.TrimSpace(fmt.Sprint(row["owner_username"])) + charged, cost := false, 0 + if sess != nil && sess.Role == RoleReseller { + if owner != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + cost = resellerProvisionCost(jsonInt(row["max_connections"])) + charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username) + if infoErr != nil { + writeResellerProvisionError(w, infoErr) + return + } + p.Days = renewalDays(owner, p.Days) + } + if sess != nil && sess.Role == RoleReseller { + if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil { + if charged { + refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username) + } + http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway) + return + } + } + p.ServerID = "" + body, _ := json.Marshal(p) + status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/renew", body, "application/json") + if proxyErr != nil || status < 200 || status >= 300 { + if charged { + refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username) + } + if proxyErr != nil { + writeBadGatewayError(w, "renew SSH account on managed server", proxyErr) + return + } + } + writeProxyResponse(w, status, data, contentType) + return + } + + state, ok := userMgr.Get(p.Username) + if !ok { + http.Error(w, "SSH account not found", http.StatusNotFound) + return + } + state.mu.Lock() + cfg := state.Cfg + existingExpiry := state.ExpiresAt + state.mu.Unlock() + charged, cost := false, 0 + if sess != nil && sess.Role == RoleReseller { + if cfg.OwnerUsername != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + cost = resellerProvisionCost(cfg.MaxConnections) + var creditErr error + charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-ssh:"+p.Username) + if creditErr != nil { + writeResellerProvisionError(w, creditErr) + return + } + p.Days = renewalDays(sess.Username, p.Days) + } + if p.Days == 0 { + p.Days = 30 + } + next := renewalExpiry(existingExpiry, p.Days) + cfg.ExpiresAt = next.UTC().Format(time.RFC3339) + if err := store.UpsertUser(ctx, cfg); err != nil { + if charged { + refundResellerProvisionCredits(ctx, store, cfg.OwnerUsername, cost, "renew-ssh:"+p.Username) + } + http.Error(w, "database error", http.StatusInternalServerError) + return + } + userMgr.DisconnectUser(p.Username) + reloadUsersFromDB(ctx, store) + if sess != nil { + _ = store.appendResellerAudit(ctx, sess.Username, cfg.OwnerUsername, "renewed SSH account", + fmt.Sprintf("account=%s days=%d", p.Username, p.Days)) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next}) + } +} + +func handleRenewXrayClient(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var p accountRenewPayload + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + p.UUID = strings.TrimSpace(p.UUID) + if _, err := parseUUID(p.UUID); err != nil { + http.Error(w, "invalid UUID", http.StatusBadRequest) + return + } + if p.Days < 0 || p.Days > 3650 { + http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest) + return + } + ctx := r.Context() + sess := sessionFromCtx(ctx) + if sess != nil && sess.Role == RoleReseller { + quotaUnlock := lockResellerQuota(sess.Username) + defer quotaUnlock() + } + if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil { + writeManagedServerSelectionError(w, err) + return + } else if remote { + row, exists, infoErr := remoteXrayClientInfo(ctx, ms, p.UUID) + if infoErr != nil { + http.Error(w, "could not verify remote account", http.StatusBadGateway) + return + } + if !exists { + http.Error(w, "Xray account not found", http.StatusNotFound) + return + } + owner := strings.TrimSpace(fmt.Sprint(row["owner_username"])) + charged, cost := false, 0 + if sess != nil && sess.Role == RoleReseller { + if owner != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + cost = resellerProvisionCost(jsonInt(row["max_conns"])) + charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID) + if infoErr != nil { + writeResellerProvisionError(w, infoErr) + return + } + p.Days = renewalDays(owner, p.Days) + } + if sess != nil && sess.Role == RoleReseller { + if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil { + if charged { + refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID) + } + http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway) + return + } + } + p.ServerID = "" + body, _ := json.Marshal(p) + status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/renew", body, "application/json") + if proxyErr != nil || status < 200 || status >= 300 { + if charged { + refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID) + } + if proxyErr != nil { + writeBadGatewayError(w, "renew Xray account on managed server", proxyErr) + return + } + } + writeProxyResponse(w, status, data, contentType) + return + } + + meta, err := store.GetXrayClientMeta(ctx, p.UUID) + if err != nil { + http.Error(w, "Xray account not found", http.StatusNotFound) + return + } + charged, cost := false, 0 + if sess != nil && sess.Role == RoleReseller { + if meta.OwnerUsername != sess.Username { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + cost = resellerProvisionCost(meta.MaxConns) + var creditErr error + charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-xray:"+p.UUID) + if creditErr != nil { + writeResellerProvisionError(w, creditErr) + return + } + p.Days = renewalDays(sess.Username, p.Days) + } + if p.Days == 0 { + p.Days = 30 + } + next := renewalExpiry(meta.ExpiresAt, p.Days) + meta.ExpiresAt = &next + if err := store.UpsertXrayClientMeta(ctx, *meta); err != nil { + if charged { + refundResellerProvisionCredits(ctx, store, meta.OwnerUsername, cost, "renew-xray:"+p.UUID) + } + http.Error(w, "database error", http.StatusInternalServerError) + return + } + runtimeWarning := "" + if meta.OwnerUsername != "" { + if runtimeErr := restoreOwnerXrayClients(ctx, store, meta.OwnerUsername); runtimeErr != nil { + log.Printf("restore renewed Xray account %s: %v", p.UUID, runtimeErr) + runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service." + } + } else if err := ensureXrayClientPresent(*meta); err != nil { + log.Printf("restore renewed Xray account %s: %v", p.UUID, err) + runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service." + } + if sess != nil { + _ = store.appendResellerAudit(ctx, sess.Username, meta.OwnerUsername, "renewed Xray account", + fmt.Sprintf("uuid=%s days=%d", p.UUID, p.Days)) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next, "runtime_warning": runtimeWarning}) + } +} + +func ensureXrayClientPresent(meta XrayClientMeta) error { + inbounds, err := xrayMgr.ListInbounds() + if err != nil { + return err + } + for _, inbound := range inbounds { + if inbound.Tag != meta.InboundTag { + continue + } + for _, client := range inbound.Clients { + if client.UUID == meta.UUID { + return nil + } + } + email := strings.TrimSpace(meta.Email) + if email == "" { + email = meta.UUID + } + if err := xrayMgr.AddXrayClient(meta.InboundTag, meta.UUID, email); err != nil { + return err + } + xrayMgr.restartIfExternalRunning() + return nil + } + return fmt.Errorf("inbound %s no longer exists", meta.InboundTag) +} diff --git a/account_validation.go b/account_validation.go new file mode 100644 index 0000000..924608f --- /dev/null +++ b/account_validation.go @@ -0,0 +1,110 @@ +package main + +import ( + "encoding/base32" + "fmt" + "regexp" + "strings" + "time" +) + +var accountUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$`) + +func validateAccountUsername(username string) error { + if !accountUsernamePattern.MatchString(username) { + return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, @, or hyphen") + } + return nil +} + +func hasAccountControlCharacters(value string) bool { + return strings.IndexFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 +} + +func validateOptionalAccountExpiry(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} { + if _, err := time.Parse(layout, raw); err == nil { + return nil + } + } + return fmt.Errorf("invalid expiration date") +} + +func validateSSHUserPayload(p *UserPayload) error { + p.Username = strings.TrimSpace(p.Username) + p.OwnerUsername = strings.TrimSpace(p.OwnerUsername) + p.ServerID = strings.TrimSpace(p.ServerID) + p.TOTPSecret = normalizeBase32Secret(p.TOTPSecret) + if err := validateAccountUsername(p.Username); err != nil { + return err + } + if p.Password != nil && len(*p.Password) > 4096 { + return fmt.Errorf("password is too long") + } + if p.MaxConnections < 0 || p.MaxConnections > 1000 { + return fmt.Errorf("max_connections must be between 0 and 1000") + } + if p.LimitUpMbps < 0 || p.LimitUpMbps > 100000 || p.LimitDownMbps < 0 || p.LimitDownMbps > 100000 { + return fmt.Errorf("speed limits must be between 0 and 100000 Mbps") + } + if err := validateOptionalAccountExpiry(p.ExpiresAt); err != nil { + return err + } + if p.OwnerUsername != "" { + if err := validateAdminUsername(p.OwnerUsername); err != nil { + return fmt.Errorf("invalid owner username") + } + } + if len(p.ServerID) > 32 || hasAccountControlCharacters(p.ServerID) { + return fmt.Errorf("invalid server id") + } + if p.TOTPSecret != "" { + if len(p.TOTPSecret) > 256 { + return fmt.Errorf("TOTP secret is too long") + } + if _, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(p.TOTPSecret); err != nil { + return fmt.Errorf("invalid TOTP secret") + } + } + if p.TOTPPeriod != 0 && (p.TOTPPeriod < 15 || p.TOTPPeriod > 300) { + return fmt.Errorf("TOTP period must be between 15 and 300 seconds") + } + if p.TOTPWindow < 0 || p.TOTPWindow > 10 { + return fmt.Errorf("TOTP window must be between 0 and 10") + } + if p.TOTPDigits != 0 && (p.TOTPDigits < 6 || p.TOTPDigits > 8) { + return fmt.Errorf("TOTP digits must be between 6 and 8") + } + return nil +} + +func validateXrayClientFields(uuid, inboundTag, email, name, expiresAt string, maxConnections int, requireInbound bool) error { + uuid = strings.TrimSpace(uuid) + if _, err := parseUUID(uuid); err != nil { + return fmt.Errorf("invalid UUID") + } + if requireInbound && strings.TrimSpace(inboundTag) == "" { + return fmt.Errorf("inbound_tag required") + } + for field, value := range map[string]string{ + "inbound_tag": inboundTag, + "email": email, + "name": name, + } { + limit := 256 + if field == "inbound_tag" { + limit = 128 + } + if len(value) > limit || hasAccountControlCharacters(value) { + return fmt.Errorf("invalid %s", field) + } + } + if maxConnections < 0 || maxConnections > 1000 { + return fmt.Errorf("max_connections must be between 0 and 1000") + } + return validateOptionalAccountExpiry(expiresAt) +} diff --git a/account_validation_test.go b/account_validation_test.go new file mode 100644 index 0000000..fce4424 --- /dev/null +++ b/account_validation_test.go @@ -0,0 +1,57 @@ +package main + +import "testing" + +func TestValidateSSHUserPayloadBounds(t *testing.T) { + valid := &UserPayload{ + Username: "client-01", + MaxConnections: 2, + TOTPPeriod: 60, + TOTPWindow: 1, + TOTPDigits: 6, + } + if err := validateSSHUserPayload(valid); err != nil { + t.Fatalf("valid SSH payload rejected: %v", err) + } + + invalid := *valid + invalid.MaxConnections = -1 + if err := validateSSHUserPayload(&invalid); err == nil { + t.Fatal("negative max_connections was accepted") + } + + invalid = *valid + invalid.TOTPSecret = "not base32!" + if err := validateSSHUserPayload(&invalid); err == nil { + t.Fatal("invalid TOTP secret was accepted") + } +} + +func TestValidateXrayClientFields(t *testing.T) { + const id = "d9428888-122b-11e1-b85c-61cd3cbb3210" + if err := validateXrayClientFields(id, "vless-in", "client@example.test", "Client", "", 2, true); err != nil { + t.Fatalf("valid Xray client rejected: %v", err) + } + if err := validateXrayClientFields("not-a-uuid", "vless-in", "", "", "", 1, true); err == nil { + t.Fatal("invalid Xray UUID was accepted") + } + if err := validateXrayClientFields(id, "vless-in", "", "", "", 1001, true); err == nil { + t.Fatal("excessive Xray connection limit was accepted") + } +} + +func TestCreditAccountConnectionLimitIsImmutable(t *testing.T) { + username := "credit-limit-test" + adminUsers.set(&AdminUser{Username: username, Role: RoleReseller, QuotaMode: QuotaModeCredit, IsActive: true}) + defer adminUsers.delete(username) + + if err := authorizeResellerQuotaChange(nil, nil, username, 2, 3); err != errCreditLimitImmutable { + t.Fatalf("credit limit change error = %v, want %v", err, errCreditLimitImmutable) + } + if err := authorizeResellerQuotaChange(nil, nil, username, 2, 2); err != nil { + t.Fatalf("unchanged credit limit rejected: %v", err) + } + if _, _, err := authorizeResellerProvision(nil, nil, username, "ssh:test", 0); err != errResellerConnLimit { + t.Fatalf("zero-connection credit account error = %v, want %v", err, errResellerConnLimit) + } +} diff --git a/admin/assets/app.css b/admin/assets/app.css index 8f1eefd..85dfeb6 100644 --- a/admin/assets/app.css +++ b/admin/assets/app.css @@ -743,6 +743,7 @@ select:disabled { .bot-input-suffix{display:flex;align-items:center;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--input-bg),#06090f);overflow:hidden;}.bot-input-suffix input{border:0!important;border-radius:0!important;background:transparent!important;box-shadow:none!important;}.bot-input-suffix span{padding:0 11px;color:var(--muted);font-size:.72rem;font-weight:850;}.bot-note{margin-top:13px;padding:11px 12px;border-left:2px solid #7c5cff;border-radius:0 12px 12px 0;background:rgba(124,92,255,.07);color:var(--muted);font-size:.73rem;line-height:1.45;} .bot-master-detail{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(360px,.65fr);gap:16px;align-items:start;}.bot-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;}.bot-list-heading>div{min-width:0;}.bot-list-heading strong{font-size:.91rem;}.bot-editor-card{position:sticky;top:168px;}.bot-span-2{grid-column:1/-1;}.bot-check-field{display:flex;align-items:center;gap:9px;min-height:44px;margin-top:19px;padding:0 12px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025);color:var(--text-2);font-size:.76rem;font-weight:800;cursor:pointer;}.bot-check-field input{width:16px;height:16px;}.bot-table{min-width:720px;}.bot-table td:last-child{text-align:right;white-space:nowrap;}.bot-table .bot-primary-cell{display:flex;flex-direction:column;gap:3px;}.bot-table .bot-primary-cell strong{color:var(--text);font-size:.82rem;}.bot-table .bot-primary-cell small{color:var(--muted);font-size:.69rem;}.bot-empty-row td{text-align:center!important;padding:34px!important;color:var(--muted);}.bot-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;}.bot-row-actions .btn+.btn{margin-left:0;}.bot-status{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;font-size:.68rem;font-weight:850;text-transform:capitalize;}.bot-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;}.bot-status.active,.bot-status.approved{color:#79e9aa;background:rgba(49,214,123,.09);}.bot-status.pending{color:#ffd36d;background:rgba(255,200,87,.09);}.bot-status.blocked,.bot-status.refunded,.bot-status.error{color:#ff929d;background:rgba(255,91,105,.09);}.bot-status.inactive,.bot-status.expired,.bot-status.customer{color:#9eabbd;background:rgba(148,163,184,.09);}.bot-status.reseller{color:#b5a4ff;background:rgba(139,92,246,.11);} +.reseller-row-actions{min-width:265px;flex-wrap:wrap}.reseller-audit-table{min-width:820px}.reseller-audit-table td:nth-child(1){white-space:nowrap}.reseller-audit-table td:nth-child(4){color:#ffd36d;font-weight:800}.reseller-audit-table td:last-child{max-width:360px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.68rem;white-space:normal} .bot-message-editor{padding:22px;}.bot-message-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-message-grid textarea{min-height:128px;}.bot-save-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid var(--bot-line);} .bot-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;}.bot-modal.hidden{display:none!important;}.bot-modal-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(7px);}.bot-modal-card{position:relative;width:min(100%,480px);padding:20px;border:1px solid rgba(139,92,246,.25);border-radius:24px;background:linear-gradient(180deg,#111723,#080c13);box-shadow:0 34px 100px rgba(0,0,0,.65);}.bot-modal-open{overflow:hidden;} diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js index a0f172b..9e2ad03 100644 --- a/admin/assets/js/01-core.js +++ b/admin/assets/js/01-core.js @@ -5,6 +5,8 @@ if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken); localStorage.removeItem("SESSION_TOKEN"); let currentRole = ""; let currentUser = ""; +let currentQuotaMode = "slots"; +let currentCreditBalance = 0; let statsTimer = null, usersTimer = null, xrayTimer = null; let tlsForwardersState = []; let managedTlsForwardersState = []; @@ -369,8 +371,13 @@ const resellerFormTitle = document.getElementById("resellerFormTitle"); const resellerForm = document.getElementById("resellerForm"); const rUsername = document.getElementById("rUsername"); const rPassword = document.getElementById("rPassword"); +const rParent = document.getElementById("rParent"); +const rQuotaMode = document.getElementById("rQuotaMode"); const rMaxUsers = document.getElementById("rMaxUsers"); +const rCredits = document.getElementById("rCredits"); const rExpires = document.getElementById("rExpires"); +const rWhatsApp = document.getElementById("rWhatsApp"); +const rMonthlyPrice = document.getElementById("rMonthlyPrice"); const rActive = document.getElementById("rActive"); // Managed servers @@ -391,7 +398,6 @@ const serverFormStatus = document.getElementById("serverFormStatus"); const serversListView = document.getElementById("serversListView"); const serverConfigSubpage = document.getElementById("serverConfigSubpage"); const cfgServerName = document.getElementById("cfgServerName"); -const managedConfigEditor = document.getElementById("managedConfigEditor"); const managedConfigStatus = document.getElementById("managedConfigStatus"); const serversStatusGrid = document.getElementById("serversStatusGrid"); const serversStatusPageStatus = document.getElementById("serversStatusPageStatus"); @@ -534,7 +540,9 @@ async function api(path, opts = {}) { "X-Session-Token": sessionToken, }); const res = await fetch(path, o); - if (res.status === 401 || res.status === 403) throw new Error("auth"); + // A 403 is an in-session permission or quota error; only 401 means the + // session is no longer valid and should return to the login screen. + if (res.status === 401) throw new Error("auth"); return res; } function withServerParam(path, serverID) { diff --git a/admin/assets/js/02-shell.js b/admin/assets/js/02-shell.js index 63a3051..2749605 100644 --- a/admin/assets/js/02-shell.js +++ b/admin/assets/js/02-shell.js @@ -153,7 +153,7 @@ function selectTab(tab) { if (tab === "stats" && currentRole === "superadmin") loadStats(); if (tab === "vnstat" && currentRole === "superadmin") loadVnstat(); if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus(); - if (tab === "resellers" && currentRole === "superadmin") loadResellers(); + if (tab === "resellers") loadResellers(); if (tab === "servers" && currentRole === "superadmin") loadServers(); if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab(); } @@ -218,6 +218,13 @@ function clearTimers() { } function initAfterLogin() { + if (currentRole === "superadmin") { + currentQuotaMode = "slots"; + currentCreditBalance = 0; + [fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => { + if (input) { input.disabled = false; input.title = ""; } + }); + } meUsername.textContent = currentUser; mainApp.classList.remove("role-superadmin", "role-reseller"); mainApp.classList.add(currentRole === "superadmin" ? "role-superadmin" : "role-reseller"); @@ -276,13 +283,28 @@ async function loadMe() { const res = await api("/api/auth/me"); const d = await res.json(); dashboardCache.me = d; + currentQuotaMode = d.quota_mode || "slots"; + currentCreditBalance = d.credit_balance || 0; + const creditPlan = currentQuotaMode === "credits"; + [fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => { + if (!input) return; + input.disabled = creditPlan; + input.title = creditPlan ? "Planos por crédito usam 31 dias e são renovados pelo botão +30d." : ""; + }); const used = d.used_users ?? 0; const max = d.max_users || 0; - rUsedMax.textContent = used + " / " + (max || "∞"); + rUsedMax.textContent = currentQuotaMode === "credits" + ? `${currentCreditBalance} créditos` + : `${used + (d.child_allocation || 0)} / ${max || "∞"}`; rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : t("No expiration"); - rStatus.textContent = d.is_active ? t("Active") : t("Suspended"); - rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)"; - updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0); + const effectiveActive = d.effective_active ?? d.is_active; + rStatus.textContent = effectiveActive ? t("Active") : t("Suspended"); + rStatus.style.color = effectiveActive ? "var(--success)" : "var(--danger)"; + if (currentQuotaMode === "credits") { + updateCreditQuotaCard(currentCreditBalance, d.used_ssh_users || 0, d.used_xray_users || 0, d.child_count || 0); + } else { + updateQuotaCard(used + (d.child_allocation || 0), max, d.used_ssh_users || 0, d.used_xray_users || 0); + } renderDashboardCounters(); } catch {} } @@ -333,6 +355,24 @@ function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) { if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = t("SSH {ssh} · Xray {xray}", {ssh: sshUsed, xray: xrayUsed}); } +function updateCreditQuotaCard(balance, sshUsed = 0, xrayUsed = 0, childCount = 0) { + if (!dashQuotaText) return; + dashQuotaChip.textContent = `${balance} Cr`; + dashQuotaChip.className = `chip ${balance <= 0 ? "red" : balance <= 5 ? "warn" : "green"}`; + dashQuotaText.textContent = `${balance} créditos disponíveis`; + dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed} · ${childCount} sub-revendas`; + dashQuotaBar.style.width = "100%"; + if (dashQuotaRemaining) { + dashQuotaRemaining.textContent = String(balance); + setQuotaTone(dashQuotaRemaining, balance <= 0 ? "quota-danger" : balance <= 5 ? "quota-warn" : "quota-good"); + } + if (dashQuotaSummaryText) dashQuotaSummaryText.textContent = `${balance} créditos no saldo`; + if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = balance > 0 ? "100%" : "0%"; + if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${balance} Cr`; + if (xrayResellerQuotaRemaining) xrayResellerQuotaRemaining.textContent = String(balance); + if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`; +} + function flattenXrayClients(inbounds = []) { return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c))); } @@ -391,7 +431,11 @@ function renderDashboardCounters() { const me = dashboardCache.me; if (currentRole === "reseller" && me) { - updateQuotaCard(me.used_users ?? total, me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length); + if ((me.quota_mode || "slots") === "credits") { + updateCreditQuotaCard(me.credit_balance || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length, me.child_count || 0); + } else { + updateQuotaCard((me.used_users ?? total) + (me.child_allocation || 0), me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length); + } } } diff --git a/admin/assets/js/03-ssh-users.js b/admin/assets/js/03-ssh-users.js index 2c46719..f819771 100644 --- a/admin/assets/js/03-ssh-users.js +++ b/admin/assets/js/03-ssh-users.js @@ -1,4 +1,5 @@ // ─── SSH Users ──────────────────────────────────────────────────────────────── +let editingSSHUser = ""; document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers); document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers); newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create")); @@ -7,8 +8,13 @@ cancelUserBtn.addEventListener("click", () => { setWorkspaceSection("ssh", "users"); }); function prepareNewSSHUser() { + editingSSHUser = ""; userForm.reset(); fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6; + fMaxConn.disabled = false; + fMaxConn.min = currentRole === "reseller" ? "1" : "0"; + fMaxConn.value = currentRole === "reseller" ? "1" : "0"; + fMaxConn.title = ""; const heading = document.getElementById("userFormHeading"); const title = document.getElementById("userFormTitle"); if (heading) heading.textContent = t("Create user"); @@ -87,6 +93,10 @@ function renderUsers(users) { tr.appendChild(td); }); const tdA = document.createElement("td"); + const renewBtn = Object.assign(document.createElement("button"), { + className:"btn btn-ghost btn-sm", textContent:"+30d", + onclick: () => renewSSHUser(u), + }); const editBtn = Object.assign(document.createElement("button"), { className:"btn btn-ghost btn-sm", textContent:t("Edit"), onclick: () => fillUserForm(u), @@ -96,7 +106,8 @@ function renderUsers(users) { style: "margin-left:4px;", onclick: () => deleteUser(u.username), }); - tdA.append(editBtn, delBtn); + tdA.className = "bot-row-actions"; + tdA.append(renewBtn, editBtn, delBtn); tr.appendChild(tdA); usersBody.appendChild(tr); }); @@ -113,6 +124,7 @@ function renderUsers(users) { } function fillUserForm(u) { + editingSSHUser = u.username || ""; setWorkspaceSection("ssh", "create"); fUsername.value = u.username || ""; fPassword.value = ""; @@ -122,6 +134,10 @@ function fillUserForm(u) { fTotpDigits.value = u.totp_digits || 6; fAllowStatic.checked = !!u.allow_static_password; fMaxConn.value = u.max_connections || ""; + const creditLocked = currentRole === "reseller" && currentQuotaMode === "credits"; + fMaxConn.disabled = creditLocked; + fMaxConn.min = currentRole === "reseller" ? "1" : "0"; + fMaxConn.title = creditLocked ? "Em planos por crédito, altere o limite criando uma nova conta." : ""; fUp.value = u.limit_mbps_up || ""; fDown.value = u.limit_mbps_down || ""; fExpires.value = u.expires_at ? localFromISO(u.expires_at) : ""; @@ -145,7 +161,9 @@ userForm.addEventListener("submit", async e => { totp_digits: parseInt(fTotpDigits.value||"6",10), allow_static_password: !!fAllowStatic.checked, max_connections: parseInt(fMaxConn.value||"0",10), - expires_at: isoFromLocal(fExpires.value), + expires_at: currentRole === "reseller" && currentQuotaMode === "credits" && editingSSHUser + ? "" + : isoFromLocal(fExpires.value), limit_mbps_up: parseInt(fUp.value||"0",10), limit_mbps_down: parseInt(fDown.value||"0",10), server_id: selectedSSHServer(), @@ -187,3 +205,29 @@ async function deleteUser(username) { else userStatus.textContent = t("Error deleting."); } } + +async function renewSSHUser(user) { + const creditCost = Math.max(1, Number(user.max_connections || 0)); + const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits" + ? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.` + : "A validade será estendida em 30 dias a partir da data atual ou da validade existente."; + const accepted = await panelConfirm({ + icon:"+30", title:"Renovar SSH", message:`Renovar “${user.username}”?`, + detail:creditDetail, confirmLabel:"Renovar conta", + }); + if (!accepted) return; + userStatus.textContent = `Renovando ${user.username}…`; + try { + const res = await api("/api/users/renew", { + method:"POST", + body:JSON.stringify({ username:user.username, days:30, server_id:selectedSSHServer() }), + }); + if (!res.ok) throw new Error((await res.text()).trim()); + showPanelToast(`${user.username} renovado.`, "success", "SSH / SlowDNS"); + await loadUsers(); + if (currentRole === "reseller") loadMe(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else showPanelToast(e.message, "error", "Renovar SSH"); + } +} diff --git a/admin/assets/js/04-xray.js b/admin/assets/js/04-xray.js index 80c4262..be0ee8a 100644 --- a/admin/assets/js/04-xray.js +++ b/admin/assets/js/04-xray.js @@ -244,12 +244,17 @@ function renderInbounds(inbounds, options = {}) { editBtn.style.marginLeft = "4px"; editBtn.textContent = t("Edit"); editBtn.onclick = () => openEditXrayClient(ib.tag, c); + const renewBtn = document.createElement("button"); + renewBtn.className = "btn btn-ghost btn-sm"; + renewBtn.style.marginLeft = "4px"; + renewBtn.textContent = "+30d"; + renewBtn.onclick = () => renewXrayClient(c); const delBtn = document.createElement("button"); delBtn.className = "btn btn-danger btn-sm"; delBtn.style.marginLeft = "4px"; delBtn.textContent = t("Del"); delBtn.onclick = () => removeClient(ib.tag, c.id); - actTd.append(copyBtn, editBtn, delBtn); + actTd.append(copyBtn, renewBtn, editBtn, delBtn); tr.appendChild(actTd); tbody.appendChild(tr); }); @@ -324,7 +329,10 @@ function prepareXrayClientCreator(preferredTag = "") { const uuid = document.getElementById("xCreateUUID"); if (uuid) uuid.value = genUUID(); const maxConns = document.getElementById("xCreateMaxConns"); - if (maxConns) maxConns.value = "0"; + if (maxConns) { + maxConns.min = currentRole === "reseller" ? "1" : "0"; + maxConns.value = currentRole === "reseller" ? "1" : "0"; + } const status = document.getElementById("xCreateClientStatus"); if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound."); updateXrayCreatorInboundLabel(); @@ -405,6 +413,34 @@ async function removeClient(tag, uuid) { } } +async function renewXrayClient(client) { + const creditCost = Math.max(1, Number(client.max_conns || 0)); + const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits" + ? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.` + : "A validade será estendida em 30 dias a partir da data atual ou da validade existente."; + const accepted = await panelConfirm({ + icon:"+30", title:"Renovar Xray", message:`Renovar “${client.name || client.email || client.id.slice(0, 8)}”?`, + detail:creditDetail, confirmLabel:"Renovar conta", + }); + if (!accepted) return; + xStatus.textContent = "Renovando cliente Xray…"; + try { + const res = await api("/api/xray/clients/renew", { + method:"POST", + body:JSON.stringify({ uuid:client.id, days:30, server_id:selectedXrayServer() }), + }); + if (!res.ok) throw new Error((await res.text()).trim()); + const data = await res.json(); + if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", "Renovar Xray"); + else showPanelToast("Cliente Xray renovado.", "success", "Xray"); + await loadInbounds({ force:true }); + if (currentRole === "reseller") loadMe(); + } catch (e) { + if (e.message === "auth") doAuthError(); + else showPanelToast(e.message, "error", "Renovar Xray"); + } +} + async function loadXrayCfg() { if (!xCfgEditor) return; const target = selectedXrayServerLabel(); diff --git a/admin/assets/js/05-resellers.js b/admin/assets/js/05-resellers.js index d3d601b..4f6ba1a 100644 --- a/admin/assets/js/05-resellers.js +++ b/admin/assets/js/05-resellers.js @@ -1,127 +1,297 @@ -// ─── Resellers ──────────────────────────────────────────────────────────────── -document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers); -document.getElementById("newResellerBtn").addEventListener("click", () => navigateWorkspaceSection("resellers", "create")); -document.getElementById("cancelResellerBtn").addEventListener("click", () => { +// ─── Hierarchical resellers ─────────────────────────────────────────────────── +let resellersCache = []; +let editingReseller = ""; + +document.getElementById("reloadResellersBtn")?.addEventListener("click", loadResellers); +document.getElementById("resellerHeroReloadBtn")?.addEventListener("click", loadResellers); +document.getElementById("newResellerBtn")?.addEventListener("click", () => { + prepareNewReseller(); + navigateWorkspaceSection("resellers", "create"); +}); +document.getElementById("cancelResellerBtn")?.addEventListener("click", () => { prepareNewReseller(); setWorkspaceSection("resellers", "users"); }); +document.getElementById("reloadResellerAuditBtn")?.addEventListener("click", loadResellerAudit); +document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers); +document.querySelectorAll("[data-workspace='resellers'][data-workspace-section='audit']").forEach(el => { + el.addEventListener("click", loadResellerAudit); +}); +document.querySelector("[data-workspace-select='resellers']")?.addEventListener("change", e => { + if (e.target.value === "audit") loadResellerAudit(); +}); +rQuotaMode?.addEventListener("change", toggleResellerPlanFields); + +function toggleResellerPlanFields() { + const credit = rQuotaMode.value === "credits"; + document.getElementById("rSlotsField")?.classList.toggle("hidden", credit); + document.getElementById("rCreditsField")?.classList.toggle("hidden", !credit); + document.getElementById("rExpiresField")?.classList.toggle("hidden", credit); + if (credit) rExpires.value = ""; +} function prepareNewReseller() { - resellerFormTitle.textContent = "Create Reseller"; + editingReseller = ""; + resellerFormTitle.textContent = t("Create Reseller"); const heading = document.getElementById("resellerFormHeading"); if (heading) heading.textContent = t("Create reseller"); resellerForm.reset(); + rUsername.disabled = false; + rParent.disabled = false; + rQuotaMode.disabled = currentRole === "reseller"; + rQuotaMode.value = currentRole === "reseller" ? currentQuotaMode : "slots"; + rMaxUsers.min = currentRole === "reseller" ? "1" : "0"; + rMaxUsers.value = currentRole === "reseller" ? "1" : "30"; + rCredits.value = "1"; rActive.checked = true; - resellerStatus.textContent = "New reseller."; + populateResellerParents(); + toggleResellerPlanFields(); + resellerStatus.textContent = t("New reseller."); requestAnimationFrame(() => rUsername.focus()); } -document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers); - async function loadResellers() { - resellerStatus.textContent = "Loading…"; + resellerStatus.textContent = t("Loading…"); + setResellerLiveStatus("Carregando revendedores…", "is-loading"); try { - const res = await api("/api/resellers"); - const data = await res.json(); - renderResellers(data || []); - resellerStatus.textContent = "Loaded."; + const res = await api("/api/resellers"); + if (!res.ok) throw new Error(await res.text()); + resellersCache = await res.json() || []; + renderResellers(resellersCache); + populateResellerParents(); + resellerStatus.textContent = t("Loaded."); + setResellerLiveStatus(`Atualizado às ${new Date().toLocaleTimeString()}`, "is-ok"); } catch (e) { - if (e.message==="auth") doAuthError(); - else resellerStatus.textContent = "Error loading."; + if (e.message === "auth") doAuthError(); + else { + resellerStatus.textContent = `${t("Error loading.")} ${e.message || ""}`.trim(); + setResellerLiveStatus("Falha ao carregar revendedores", "is-error"); + } } } +function setResellerLiveStatus(message, tone) { + const el = document.getElementById("resellerLiveStatus"); + if (!el) return; + el.textContent = message; + el.className = `workspace-live-status ${tone || ""}`.trim(); +} + +function renderResellerMetrics(list) { + const active = list.filter(r => r.effective_active).length; + const allocated = list.reduce((sum, r) => sum + (r.quota_mode === "slots" ? Number(r.max_users || 0) : 0), 0); + const credits = list.reduce((sum, r) => sum + (r.quota_mode === "credits" ? Number(r.credit_balance || 0) : 0), 0); + document.getElementById("resellerMetricTotal").textContent = String(list.length); + document.getElementById("resellerMetricActive").textContent = String(active); + document.getElementById("resellerMetricAllocated").textContent = String(allocated); + document.getElementById("resellerMetricCredits").textContent = String(credits); +} + function renderResellers(list) { resellerCountChip.textContent = list.length; + renderResellerMetrics(list); resellersBody.innerHTML = ""; + if (!list.length) { + resellersBody.innerHTML = `
Controle cotas, validade e acesso dos parceiros em um só lugar.
Controle hierarquia, créditos, cotas, validade e acesso dos parceiros em um só lugar.
| Username | Users (used/max) | Expires | Status | Actions | +Conta | Plano e uso | Validade | Status | Ações |
|---|
Veja quem criou, alterou, renovou, suspendeu ou removeu cada conta.
| Quando | Responsável | Revendedor | Ação | Detalhes |
|---|