feat(clients): selective bulk attach + new bulk detach

Inbounds page:
- AttachClientsModal now shows a per-client selection table (email,
  comment, enabled tag) with search and a live "selected of total"
  counter; all clients are pre-selected so the old "attach all"
  workflow stays a single OK click.
- New DetachClientsModal on the inbound row menu lets you pick which
  clients to remove from that inbound (records are kept so they can be
  re-attached later; for full removal use Delete).

Clients page:
- New "Attach (N)" bulk-action button + BulkAttachInboundsModal that
  attaches selected clients to one or more multi-user inbounds.
- New "Detach (N)" bulk-action button + BulkDetachInboundsModal that
  removes selected clients from chosen inbounds; (email, inbound) pairs
  where the client isn't attached are silently skipped.

Backend adds POST /panel/api/clients/bulkDetach, wrapping the existing
Detach service for each email and reporting per-email
detached/skipped/errors. ClientRecord rows are kept on detach to match
the single-client endpoint; bulkDel remains the path for full removal.
This commit is contained in:
MHSanaei
2026-05-28 11:08:52 +02:00
parent a07b68894c
commit 72b68cce22
15 changed files with 809 additions and 13 deletions
+69
View File
@@ -884,6 +884,75 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string,
return result, needRestart, nil
}
// BulkDetachResult reports the outcome of a bulk detach across target inbounds.
type BulkDetachResult struct {
Detached []string `json:"detached"`
Skipped []string `json:"skipped"`
Errors []string `json:"errors"`
}
// BulkDetach detaches the given existing clients (by email) from each target inbound.
// (email, inbound) pairs where the client is not currently attached are silently skipped
// at the inbound level; emails that aren't attached to any of the requested inbounds
// are reported under skipped. ClientRecord rows are kept even when they become orphaned
// (matches single-client detach semantics); callers should use bulkDelete for full removal.
func (s *ClientService) BulkDetach(inboundSvc *InboundService, emails []string, inboundIds []int) (*BulkDetachResult, bool, error) {
result := &BulkDetachResult{}
if len(emails) == 0 || len(inboundIds) == 0 {
return result, false, nil
}
requested := make(map[int]struct{}, len(inboundIds))
for _, id := range inboundIds {
requested[id] = struct{}{}
}
needRestart := false
seenEmail := make(map[string]struct{}, len(emails))
for _, email := range emails {
if email == "" {
continue
}
key := strings.ToLower(email)
if _, ok := seenEmail[key]; ok {
continue
}
seenEmail[key] = struct{}{}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", email, err))
continue
}
currentIds, err := s.GetInboundIdsForRecord(rec.Id)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", email, err))
continue
}
intersection := make([]int, 0, len(currentIds))
for _, id := range currentIds {
if _, ok := requested[id]; ok {
intersection = append(intersection, id)
}
}
if len(intersection) == 0 {
result.Skipped = append(result.Skipped, rec.Email)
continue
}
nr, err := s.Detach(inboundSvc, rec.Id, intersection)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", rec.Email, err))
continue
}
if nr {
needRestart = true
}
result.Detached = append(result.Detached, rec.Email)
}
return result, needRestart, nil
}
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")