Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRs.Nest <css81933@gmail.com>2026-04-23 16:19:07 +0300
committerGitHub <noreply@github.com>2026-04-23 16:19:07 +0300
commit6bcaf61c44f7d51664dc12a942877b7d7c8d5464 (patch)
treeb46a21be6558cb141f478dd4b1f74f1338ffc086
parentff250726901b2eced76c142a35111d872414bbe9 (diff)
Feature: Copy clients between inbounds (#4087)
* feat: copy clients between inbounds * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * fix: copy clients modal not opening * revert: undo install.sh/deploy.sh changes; i18n: add copy-clients translations for all languages --------- Co-authored-by: Нестеров Руслан <r.nesterov@comagic.dev>
-rw-r--r--web/controller/inbound.go37
-rw-r--r--web/html/inbounds.html219
-rw-r--r--web/service/inbound.go203
-rw-r--r--web/translation/translate.ar_EG.toml14
-rw-r--r--web/translation/translate.en_US.toml14
-rw-r--r--web/translation/translate.es_ES.toml14
-rw-r--r--web/translation/translate.fa_IR.toml14
-rw-r--r--web/translation/translate.id_ID.toml14
-rw-r--r--web/translation/translate.ja_JP.toml14
-rw-r--r--web/translation/translate.pt_BR.toml14
-rw-r--r--web/translation/translate.ru_RU.toml14
-rw-r--r--web/translation/translate.tr_TR.toml14
-rw-r--r--web/translation/translate.uk_UA.toml14
-rw-r--r--web/translation/translate.vi_VN.toml14
-rw-r--r--web/translation/translate.zh_CN.toml14
-rw-r--r--web/translation/translate.zh_TW.toml14
16 files changed, 641 insertions, 0 deletions
diff --git a/web/controller/inbound.go b/web/controller/inbound.go
index b012ec95..ee024cc6 100644
--- a/web/controller/inbound.go
+++ b/web/controller/inbound.go
@@ -41,6 +41,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/clientIps/:email", a.getClientIps)
g.POST("/clearClientIps/:email", a.clearClientIps)
g.POST("/addClient", a.addInboundClient)
+ g.POST("/:id/copyClients", a.copyInboundClients)
g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient)
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
@@ -54,6 +55,12 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
}
+type CopyInboundClientsRequest struct {
+ SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"`
+ ClientEmails []string `form:"clientEmails" json:"clientEmails"`
+ Flow string `form:"flow" json:"flow"`
+}
+
// getInbounds retrieves the list of inbounds for the logged-in user.
func (a *InboundController) getInbounds(c *gin.Context) {
user := session.GetLoginUser(c)
@@ -260,6 +267,36 @@ func (a *InboundController) addInboundClient(c *gin.Context) {
}
}
+// copyInboundClients copies clients from source inbound to target inbound.
+func (a *InboundController) copyInboundClients(c *gin.Context) {
+ targetID, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+ return
+ }
+
+ req := &CopyInboundClientsRequest{}
+ err = c.ShouldBind(req)
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+ return
+ }
+ if req.SourceInboundID <= 0 {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
+ return
+ }
+
+ result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+ return
+ }
+ jsonObj(c, result, nil)
+ if needRestart {
+ a.xrayService.SetToNeedRestart()
+ }
+}
+
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
func (a *InboundController) delInboundClient(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
diff --git a/web/html/inbounds.html b/web/html/inbounds.html
index 60de0750..b8485702 100644
--- a/web/html/inbounds.html
+++ b/web/html/inbounds.html
@@ -262,6 +262,10 @@
<a-icon type="usergroup-add"></a-icon>
{{ i18n "pages.client.bulk"}}
</a-menu-item>
+ <a-menu-item key="copyClients">
+ <a-icon type="copy"></a-icon>
+ {{ i18n "pages.client.copyFromInbound"}}
+ </a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n
@@ -777,6 +781,218 @@
{{template "modals/inboundInfoModal"}}
{{template "modals/clientsModal"}}
{{template "modals/clientsBulkModal"}}
+<a-modal id="copy-clients-modal"
+ :title="copyClientsModal.title"
+ :visible="copyClientsModal.visible"
+ :confirm-loading="copyClientsModal.confirmLoading"
+ ok-text='{{ i18n "pages.client.copySelected" }}'
+ cancel-text='{{ i18n "close" }}'
+ :class="themeSwitcher.currentTheme"
+ :closable="true"
+ :mask-closable="false"
+ @ok="() => copyClientsModal.ok()"
+ @cancel="() => copyClientsModal.close()"
+ width="900px">
+ <a-space direction="vertical" style="width: 100%;">
+ <div>
+ <div style="margin-bottom: 6px;">{{ i18n "pages.client.copySource" }}</div>
+ <a-select v-model="copyClientsModal.sourceInboundId"
+ style="width: 100%;"
+ :dropdown-class-name="themeSwitcher.currentTheme"
+ @change="id => copyClientsModal.onSourceChange(id)">
+ <a-select-option v-for="item in copyClientsModal.sources"
+ :key="item.id"
+ :value="item.id">
+ [[ item.label ]]
+ </a-select-option>
+ </a-select>
+ </div>
+ <div v-if="copyClientsModal.sourceInboundId">
+ <a-space style="margin-bottom: 10px;">
+ <a-button size="small" @click="() => copyClientsModal.selectAll()">{{ i18n "pages.client.selectAll" }}</a-button>
+ <a-button size="small" @click="() => copyClientsModal.clearAll()">{{ i18n "pages.client.clearAll" }}</a-button>
+ </a-space>
+ <a-table :columns="copyClientsColumns"
+ :data-source="copyClientsModal.sourceClients"
+ :pagination="false"
+ size="small"
+ :row-key="item => item.email"
+ :scroll="{ y: 280 }">
+ <template slot="emailCheckbox" slot-scope="text, record">
+ <a-checkbox :checked="copyClientsModal.selectedEmails.includes(record.email)"
+ @change="event => copyClientsModal.toggleEmail(record.email, event.target.checked)">
+ [[ record.email ]]
+ </a-checkbox>
+ </template>
+ </a-table>
+ </div>
+ <div v-if="copyClientsModal.showFlow">
+ <div style="margin-bottom: 6px;">{{ i18n "pages.client.copyFlowLabel" }}</div>
+ <a-select v-model="copyClientsModal.flow"
+ style="width: 100%;"
+ :dropdown-class-name="themeSwitcher.currentTheme"
+ allow-clear>
+ <a-select-option value="">{{ i18n "none" }}</a-select-option>
+ <a-select-option value="xtls-rprx-vision">xtls-rprx-vision</a-select-option>
+ <a-select-option value="xtls-rprx-vision-udp443">xtls-rprx-vision-udp443</a-select-option>
+ </a-select>
+ <div style="margin-top: 4px; font-size: 12px; opacity: 0.7;">
+ {{ i18n "pages.client.copyFlowHint" }}
+ </div>
+ </div>
+ <div v-if="copyClientsModal.selectedEmails.length > 0">
+ <div style="margin-bottom: 4px;">{{ i18n "pages.client.copyEmailPreview" }}</div>
+ <div style="max-height: 120px; overflow-y: auto;">
+ <a-tag v-for="preview in previewEmails" :key="preview" style="margin-bottom: 4px;">
+ [[ preview ]]
+ </a-tag>
+ </div>
+ </div>
+ </a-space>
+</a-modal>
+<script>
+ const copyClientsColumns = [
+ { title: '{{ i18n "pages.inbounds.email" }}', width: 300, scopedSlots: { customRender: 'emailCheckbox' } },
+ { title: '{{ i18n "pages.inbounds.traffic" }}', width: 160, dataIndex: 'trafficLabel' },
+ { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, dataIndex: 'expiryLabel' },
+ ];
+
+ const copyClientsModal = {
+ visible: false,
+ confirmLoading: false,
+ title: '',
+ targetInboundId: 0,
+ targetInboundRemark: '',
+ targetProtocol: '',
+ showFlow: false,
+ flow: '',
+ sourceInboundId: undefined,
+ sources: [],
+ sourceClients: [],
+ selectedEmails: [],
+ show(targetDbInbound) {
+ if (!targetDbInbound) return;
+ const sources = app.dbInbounds
+ .filter(row => row.id !== targetDbInbound.id && typeof row.isMultiUser === 'function' && row.isMultiUser())
+ .map(row => {
+ const clients = app.getInboundClients(row) || [];
+ return { id: row.id, label: `${row.remark} (${row.protocol}, ${clients.length})` };
+ });
+ let showFlow = false;
+ try {
+ const targetInbound = targetDbInbound.toInbound();
+ showFlow = !!(targetInbound && typeof targetInbound.canEnableTlsFlow === 'function' && targetInbound.canEnableTlsFlow());
+ } catch (e) {
+ showFlow = false;
+ }
+ copyClientsModal.targetInboundId = targetDbInbound.id;
+ copyClientsModal.targetInboundRemark = targetDbInbound.remark;
+ copyClientsModal.targetProtocol = targetDbInbound.protocol;
+ copyClientsModal.showFlow = showFlow;
+ copyClientsModal.flow = '';
+ copyClientsModal.title = `{{ i18n "pages.client.copyToInbound" }} ${targetDbInbound.remark}`;
+ copyClientsModal.sources = sources;
+ copyClientsModal.sourceInboundId = undefined;
+ copyClientsModal.sourceClients = [];
+ copyClientsModal.selectedEmails = [];
+ copyClientsModal.confirmLoading = false;
+ copyClientsModal.visible = true;
+ },
+ close() {
+ copyClientsModal.visible = false;
+ copyClientsModal.confirmLoading = false;
+ },
+ onSourceChange(sourceInboundId) {
+ copyClientsModal.selectedEmails = [];
+ const sourceInbound = app.dbInbounds.find(row => row.id === Number(sourceInboundId));
+ if (!sourceInbound) {
+ copyClientsModal.sourceClients = [];
+ return;
+ }
+ const sourceClients = app.getInboundClients(sourceInbound) || [];
+ copyClientsModal.sourceClients = sourceClients.map(client => {
+ const stats = app.getClientStats(sourceInbound, client.email);
+ const used = stats ? ((stats.up || 0) + (stats.down || 0)) : 0;
+ let expiryLabel = '{{ i18n "unlimited" }}';
+ if (client.expiryTime > 0) {
+ expiryLabel = IntlUtil.formatDate(client.expiryTime);
+ } else if (client.expiryTime < 0) {
+ expiryLabel = `${-client.expiryTime / 86400000}d`;
+ }
+ return {
+ email: client.email,
+ trafficLabel: SizeFormatter.sizeFormat(used),
+ expiryLabel,
+ };
+ });
+ },
+ toggleEmail(email, checked) {
+ const selected = copyClientsModal.selectedEmails.slice();
+ if (checked) {
+ if (!selected.includes(email)) selected.push(email);
+ } else {
+ const idx = selected.indexOf(email);
+ if (idx >= 0) selected.splice(idx, 1);
+ }
+ copyClientsModal.selectedEmails = selected;
+ },
+ selectAll() {
+ copyClientsModal.selectedEmails = copyClientsModal.sourceClients.map(item => item.email);
+ },
+ clearAll() {
+ copyClientsModal.selectedEmails = [];
+ },
+ async ok() {
+ if (!copyClientsModal.sourceInboundId) {
+ app.$message.error('{{ i18n "pages.client.copySelectSourceFirst" }}');
+ return;
+ }
+ copyClientsModal.confirmLoading = true;
+ const payload = {
+ sourceInboundId: copyClientsModal.sourceInboundId,
+ clientEmails: copyClientsModal.selectedEmails,
+ };
+ if (copyClientsModal.showFlow && copyClientsModal.flow) {
+ payload.flow = copyClientsModal.flow;
+ }
+ try {
+ const msg = await HttpUtil.post(`/panel/api/inbounds/${copyClientsModal.targetInboundId}/copyClients`, payload);
+ if (!msg || !msg.success) return;
+ const obj = msg.obj || {};
+ const addedCount = (obj.added || []).length;
+ const errorList = obj.errors || [];
+ if (addedCount > 0) {
+ app.$message.success(`{{ i18n "pages.client.copyResultSuccess" }}: ${addedCount}`);
+ } else {
+ app.$message.warning('{{ i18n "pages.client.copyResultNone" }}');
+ }
+ if (errorList.length > 0) {
+ app.$message.error(`{{ i18n "pages.client.copyResultErrors" }}: ${errorList.join('; ')}`);
+ }
+ copyClientsModal.close();
+ await app.getDBInbounds();
+ } finally {
+ copyClientsModal.confirmLoading = false;
+ }
+ },
+ };
+
+ const copyClientsModalApp = new Vue({
+ delimiters: ['[[', ']]'],
+ el: '#copy-clients-modal',
+ data: {
+ copyClientsModal,
+ copyClientsColumns,
+ themeSwitcher,
+ },
+ computed: {
+ previewEmails() {
+ if (!this.copyClientsModal.targetInboundId) return [];
+ return this.copyClientsModal.selectedEmails.map(email => `${email}_${this.copyClientsModal.targetInboundId}`);
+ },
+ },
+ });
+</script>
<script>
const columns = [{
title: "ID",
@@ -1135,6 +1351,9 @@
case "addBulkClient":
this.openAddBulkClient(dbInbound.id)
break;
+ case "copyClients":
+ copyClientsModal.show(dbInbound);
+ break;
case "export":
this.inboundLinks(dbInbound.id);
break;
diff --git a/web/service/inbound.go b/web/service/inbound.go
index 19c3d80c..7d5d8932 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -10,6 +10,7 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
@@ -26,6 +27,12 @@ type InboundService struct {
xrayApi xray.XrayAPI
}
+type CopyClientsResult struct {
+ Added []string `json:"added"`
+ Skipped []string `json:"skipped"`
+ Errors []string `json:"errors"`
+}
+
// GetInbounds retrieves all inbounds for a specific user.
// Returns a slice of inbound models with their associated client statistics.
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
@@ -750,6 +757,202 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
return needRestart, tx.Save(oldInbound).Error
}
+func (s *InboundService) getClientPrimaryKey(protocol model.Protocol, client model.Client) string {
+ switch protocol {
+ case model.Trojan:
+ return client.Password
+ case model.Shadowsocks:
+ return client.Email
+ case model.Hysteria:
+ return client.Auth
+ default:
+ return client.ID
+ }
+}
+
+func (s *InboundService) writeBackClientSubID(sourceInboundID int, sourceProtocol model.Protocol, client model.Client, subID string) (bool, error) {
+ client.SubID = subID
+ client.UpdatedAt = time.Now().UnixMilli()
+ clientID := s.getClientPrimaryKey(sourceProtocol, client)
+ if clientID == "" {
+ return false, common.NewError("empty client ID")
+ }
+
+ settingsBytes, err := json.Marshal(map[string][]model.Client{
+ "clients": []model.Client{client},
+ })
+ if err != nil {
+ return false, err
+ }
+
+ updatePayload := &model.Inbound{
+ Id: sourceInboundID,
+ Settings: string(settingsBytes),
+ }
+ return s.UpdateInboundClient(updatePayload, clientID)
+}
+
+func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) string {
+ switch targetProtocol {
+ case model.VMESS, model.VLESS:
+ return uuid.NewString()
+ default:
+ return strings.ReplaceAll(uuid.NewString(), "-", "")
+ }
+}
+
+func (s *InboundService) buildTargetClientFromSource(source model.Client, targetProtocol model.Protocol, email string, flow string) (model.Client, error) {
+ nowTs := time.Now().UnixMilli()
+ target := source
+ target.Email = email
+ target.CreatedAt = nowTs
+ target.UpdatedAt = nowTs
+
+ target.ID = ""
+ target.Password = ""
+ target.Auth = ""
+ target.Flow = ""
+
+ switch targetProtocol {
+ case model.VMESS:
+ target.ID = s.generateRandomCredential(targetProtocol)
+ case model.VLESS:
+ target.ID = s.generateRandomCredential(targetProtocol)
+ if flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443" {
+ target.Flow = flow
+ }
+ case model.Trojan, model.Shadowsocks:
+ target.Password = s.generateRandomCredential(targetProtocol)
+ case model.Hysteria:
+ target.Auth = s.generateRandomCredential(targetProtocol)
+ default:
+ target.ID = s.generateRandomCredential(targetProtocol)
+ }
+
+ return target, nil
+}
+
+func (s *InboundService) nextAvailableCopiedEmail(originalEmail string, targetID int, occupied map[string]struct{}) string {
+ base := fmt.Sprintf("%s_%d", originalEmail, targetID)
+ candidate := base
+ suffix := 0
+ for {
+ if _, exists := occupied[strings.ToLower(candidate)]; !exists {
+ occupied[strings.ToLower(candidate)] = struct{}{}
+ return candidate
+ }
+ suffix++
+ candidate = fmt.Sprintf("%s_%d", base, suffix)
+ }
+}
+
+func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID int, clientEmails []string, flow string) (*CopyClientsResult, bool, error) {
+ result := &CopyClientsResult{
+ Added: []string{},
+ Skipped: []string{},
+ Errors: []string{},
+ }
+ if targetInboundID == sourceInboundID {
+ return result, false, common.NewError("source and target inbounds must be different")
+ }
+
+ targetInbound, err := s.GetInbound(targetInboundID)
+ if err != nil {
+ return result, false, err
+ }
+ sourceInbound, err := s.GetInbound(sourceInboundID)
+ if err != nil {
+ return result, false, err
+ }
+
+ sourceClients, err := s.GetClients(sourceInbound)
+ if err != nil {
+ return result, false, err
+ }
+ if len(sourceClients) == 0 {
+ return result, false, nil
+ }
+
+ allowedEmails := map[string]struct{}{}
+ if len(clientEmails) > 0 {
+ for _, email := range clientEmails {
+ allowedEmails[strings.ToLower(strings.TrimSpace(email))] = struct{}{}
+ }
+ }
+
+ occupiedEmails := map[string]struct{}{}
+ allEmails, err := s.getAllEmails()
+ if err != nil {
+ return result, false, err
+ }
+ for _, email := range allEmails {
+ clean := strings.Trim(email, "\"")
+ if clean != "" {
+ occupiedEmails[strings.ToLower(clean)] = struct{}{}
+ }
+ }
+
+ newClients := make([]model.Client, 0)
+ needRestart := false
+ for _, sourceClient := range sourceClients {
+ originalEmail := strings.TrimSpace(sourceClient.Email)
+ if originalEmail == "" {
+ continue
+ }
+ if len(allowedEmails) > 0 {
+ if _, ok := allowedEmails[strings.ToLower(originalEmail)]; !ok {
+ continue
+ }
+ }
+
+ if sourceClient.SubID == "" {
+ newSubID := uuid.NewString()
+ subNeedRestart, subErr := s.writeBackClientSubID(sourceInbound.Id, sourceInbound.Protocol, sourceClient, newSubID)
+ if subErr != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to write source subId: %v", originalEmail, subErr))
+ continue
+ }
+ if subNeedRestart {
+ needRestart = true
+ }
+ sourceClient.SubID = newSubID
+ }
+
+ targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails)
+ targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound.Protocol, targetEmail, flow)
+ if buildErr != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
+ continue
+ }
+ newClients = append(newClients, targetClient)
+ result.Added = append(result.Added, targetEmail)
+ }
+
+ if len(newClients) == 0 {
+ return result, needRestart, nil
+ }
+
+ settingsPayload, err := json.Marshal(map[string][]model.Client{
+ "clients": newClients,
+ })
+ if err != nil {
+ return result, needRestart, err
+ }
+
+ addNeedRestart, err := s.AddInboundClient(&model.Inbound{
+ Id: targetInboundID,
+ Settings: string(settingsPayload),
+ })
+ if err != nil {
+ return result, needRestart, err
+ }
+ if addNeedRestart {
+ needRestart = true
+ }
+
+ return result, needRestart, nil
+}
+
func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, error) {
oldInbound, err := s.GetInbound(inboundId)
if err != nil {
diff --git a/web/translation/translate.ar_EG.toml b/web/translation/translate.ar_EG.toml
index f9216621..ca7076c8 100644
--- a/web/translation/translate.ar_EG.toml
+++ b/web/translation/translate.ar_EG.toml
@@ -298,6 +298,20 @@
"submitEdit" = "احفظ التعديلات"
"clientCount" = "عدد العملاء"
"bulk" = "إضافة بالجملة"
+"copyFromInbound" = "نسخ العملاء من الـ Inbound"
+"copyToInbound" = "نسخ العملاء إلى"
+"copySelected" = "نسخ المحدد"
+"copySource" = "المصدر"
+"copyEmailPreview" = "معاينة البريد الإلكتروني الناتج"
+"copySelectSourceFirst" = "الرجاء اختيار الـ Inbound المصدر أولاً."
+"copyResult" = "نتيجة النسخ"
+"copyResultSuccess" = "تم النسخ بنجاح"
+"copyResultNone" = "لا يوجد شيء للنسخ: لم يتم اختيار أي عميل أو أن المصدر فارغ"
+"copyResultErrors" = "أخطاء النسخ"
+"copyFlowLabel" = "Flow للعملاء الجدد (VLESS)"
+"copyFlowHint" = "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغاً لتخطيه."
+"selectAll" = "تحديد الكل"
+"clearAll" = "مسح الكل"
"method" = "طريقة"
"first" = "أول واحد"
"last" = "آخر واحد"
diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml
index 91580a29..45186187 100644
--- a/web/translation/translate.en_US.toml
+++ b/web/translation/translate.en_US.toml
@@ -298,6 +298,20 @@
"submitEdit" = "Save Changes"
"clientCount" = "Number of Clients"
"bulk" = "Add Bulk"
+"copyFromInbound" = "Copy Clients from Inbound"
+"copyToInbound" = "Copy clients to"
+"copySelected" = "Copy Selected"
+"copySource" = "Source"
+"copyEmailPreview" = "Resulting email preview"
+"copySelectSourceFirst" = "Please select a source inbound first."
+"copyResult" = "Copy result"
+"copyResultSuccess" = "Copied successfully"
+"copyResultNone" = "Nothing to copy: no clients selected or source is empty"
+"copyResultErrors" = "Copy errors"
+"copyFlowLabel" = "Flow for new clients (VLESS)"
+"copyFlowHint" = "Applied to all copied clients. Leave empty to skip."
+"selectAll" = "Select all"
+"clearAll" = "Clear all"
"method" = "Method"
"first" = "First"
"last" = "Last"
diff --git a/web/translation/translate.es_ES.toml b/web/translation/translate.es_ES.toml
index 3dfe3ade..d8f94461 100644
--- a/web/translation/translate.es_ES.toml
+++ b/web/translation/translate.es_ES.toml
@@ -298,6 +298,20 @@
"submitEdit" = "Guardar Cambios"
"clientCount" = "Número de Clientes"
"bulk" = "Agregar en Lote"
+"copyFromInbound" = "Copiar clientes desde entrada"
+"copyToInbound" = "Copiar clientes a"
+"copySelected" = "Copiar seleccionados"
+"copySource" = "Origen"
+"copyEmailPreview" = "Vista previa del email resultante"
+"copySelectSourceFirst" = "Seleccione primero una entrada de origen."
+"copyResult" = "Resultado de la copia"
+"copyResultSuccess" = "Copiado correctamente"
+"copyResultNone" = "Nada que copiar: ningún cliente seleccionado o el origen está vacío"
+"copyResultErrors" = "Errores al copiar"
+"copyFlowLabel" = "Flow para nuevos clientes (VLESS)"
+"copyFlowHint" = "Se aplica a todos los clientes copiados. Déjelo vacío para omitir."
+"selectAll" = "Seleccionar todo"
+"clearAll" = "Limpiar todo"
"method" = "Método"
"first" = "Primero"
"last" = "Último"
diff --git a/web/translation/translate.fa_IR.toml b/web/translation/translate.fa_IR.toml
index 5569239f..aaec75f6 100644
--- a/web/translation/translate.fa_IR.toml
+++ b/web/translation/translate.fa_IR.toml
@@ -298,6 +298,20 @@
"submitEdit" = "ذخیره تغییرات"
"clientCount" = "تعداد کاربران"
"bulk" = "انبوه‌سازی"
+"copyFromInbound" = "کپی کاربران از اینباند"
+"copyToInbound" = "کپی کاربران به"
+"copySelected" = "کپی انتخاب‌شده‌ها"
+"copySource" = "منبع"
+"copyEmailPreview" = "پیش‌نمایش ایمیل نهایی"
+"copySelectSourceFirst" = "ابتدا یک اینباند منبع انتخاب کنید."
+"copyResult" = "نتیجه کپی"
+"copyResultSuccess" = "با موفقیت کپی شد"
+"copyResultNone" = "چیزی برای کپی نیست: هیچ کاربری انتخاب نشده یا منبع خالی است"
+"copyResultErrors" = "خطاهای کپی"
+"copyFlowLabel" = "Flow برای کاربران جدید (VLESS)"
+"copyFlowHint" = "برای همه کاربران کپی‌شده اعمال می‌شود. برای نادیده گرفتن، خالی بگذارید."
+"selectAll" = "انتخاب همه"
+"clearAll" = "پاک کردن همه"
"method" = "روش"
"first" = "از"
"last" = "تا"
diff --git a/web/translation/translate.id_ID.toml b/web/translation/translate.id_ID.toml
index c53b6a9c..e115aaa8 100644
--- a/web/translation/translate.id_ID.toml
+++ b/web/translation/translate.id_ID.toml
@@ -298,6 +298,20 @@
"submitEdit" = "Simpan Perubahan"
"clientCount" = "Jumlah Klien"
"bulk" = "Tambahkan Massal"
+"copyFromInbound" = "Salin klien dari inbound"
+"copyToInbound" = "Salin klien ke"
+"copySelected" = "Salin yang dipilih"
+"copySource" = "Sumber"
+"copyEmailPreview" = "Pratinjau email hasil"
+"copySelectSourceFirst" = "Silakan pilih inbound sumber terlebih dahulu."
+"copyResult" = "Hasil penyalinan"
+"copyResultSuccess" = "Berhasil disalin"
+"copyResultNone" = "Tidak ada yang disalin: tidak ada klien yang dipilih atau sumber kosong"
+"copyResultErrors" = "Kesalahan penyalinan"
+"copyFlowLabel" = "Flow untuk klien baru (VLESS)"
+"copyFlowHint" = "Diterapkan ke semua klien yang disalin. Biarkan kosong untuk melewati."
+"selectAll" = "Pilih semua"
+"clearAll" = "Hapus semua"
"method" = "Metode"
"first" = "Pertama"
"last" = "Terakhir"
diff --git a/web/translation/translate.ja_JP.toml b/web/translation/translate.ja_JP.toml
index 2b7cdb63..ffa9168b 100644
--- a/web/translation/translate.ja_JP.toml
+++ b/web/translation/translate.ja_JP.toml
@@ -298,6 +298,20 @@
"submitEdit" = "変更を保存"
"clientCount" = "クライアント数"
"bulk" = "一括作成"
+"copyFromInbound" = "インバウンドからクライアントをコピー"
+"copyToInbound" = "クライアントのコピー先"
+"copySelected" = "選択項目をコピー"
+"copySource" = "ソース"
+"copyEmailPreview" = "結果メールのプレビュー"
+"copySelectSourceFirst" = "先にソースインバウンドを選択してください。"
+"copyResult" = "コピー結果"
+"copyResultSuccess" = "正常にコピーされました"
+"copyResultNone" = "コピーする項目がありません: クライアントが選択されていないかソースが空です"
+"copyResultErrors" = "コピーエラー"
+"copyFlowLabel" = "新規クライアントの Flow (VLESS)"
+"copyFlowHint" = "すべてのコピー対象クライアントに適用されます。空のままにするとスキップします。"
+"selectAll" = "すべて選択"
+"clearAll" = "すべて解除"
"method" = "方法"
"first" = "最初"
"last" = "最後"
diff --git a/web/translation/translate.pt_BR.toml b/web/translation/translate.pt_BR.toml
index 876e7c57..18db4d62 100644
--- a/web/translation/translate.pt_BR.toml
+++ b/web/translation/translate.pt_BR.toml
@@ -298,6 +298,20 @@
"submitEdit" = "Salvar Alterações"
"clientCount" = "Número de Clientes"
"bulk" = "Adicionar Vários"
+"copyFromInbound" = "Copiar clientes da entrada"
+"copyToInbound" = "Copiar clientes para"
+"copySelected" = "Copiar selecionados"
+"copySource" = "Origem"
+"copyEmailPreview" = "Prévia do email resultante"
+"copySelectSourceFirst" = "Selecione primeiro uma entrada de origem."
+"copyResult" = "Resultado da cópia"
+"copyResultSuccess" = "Copiado com sucesso"
+"copyResultNone" = "Nada a copiar: nenhum cliente selecionado ou origem vazia"
+"copyResultErrors" = "Erros ao copiar"
+"copyFlowLabel" = "Flow para novos clientes (VLESS)"
+"copyFlowHint" = "Aplicado a todos os clientes copiados. Deixe em branco para ignorar."
+"selectAll" = "Selecionar tudo"
+"clearAll" = "Limpar tudo"
"method" = "Método"
"first" = "Primeiro"
"last" = "Último"
diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml
index 0c144ff5..5bf89dfd 100644
--- a/web/translation/translate.ru_RU.toml
+++ b/web/translation/translate.ru_RU.toml
@@ -298,6 +298,20 @@
"submitEdit" = "Сохранить изменения"
"clientCount" = "Количество клиентов"
"bulk" = "Добавить несколько"
+"copyFromInbound" = "Скопировать клиентов из инбаунда"
+"copyToInbound" = "Скопировать клиентов в"
+"copySelected" = "Скопировать выбранных"
+"copySource" = "Источник"
+"copyEmailPreview" = "Предпросмотр итоговых email"
+"copySelectSourceFirst" = "Сначала выберите источник."
+"copyResult" = "Результат копирования"
+"copyResultSuccess" = "Успешно скопировано"
+"copyResultNone" = "Нечего копировать: ни одного клиента не выбрано или список источника пуст"
+"copyResultErrors" = "Ошибки при копировании"
+"copyFlowLabel" = "Flow для новых клиентов (VLESS)"
+"copyFlowHint" = "Применится ко всем копируемым клиентам. Оставьте пустым, чтобы не задавать."
+"selectAll" = "Выбрать всех"
+"clearAll" = "Снять всё"
"method" = "Метод"
"first" = "Первый"
"last" = "Последний"
diff --git a/web/translation/translate.tr_TR.toml b/web/translation/translate.tr_TR.toml
index cc1159a2..0393a7dc 100644
--- a/web/translation/translate.tr_TR.toml
+++ b/web/translation/translate.tr_TR.toml
@@ -298,6 +298,20 @@
"submitEdit" = "Değişiklikleri Kaydet"
"clientCount" = "Müşteri Sayısı"
"bulk" = "Toplu Ekle"
+"copyFromInbound" = "Gelen bağlantıdan istemcileri kopyala"
+"copyToInbound" = "İstemcileri şuraya kopyala"
+"copySelected" = "Seçilenleri kopyala"
+"copySource" = "Kaynak"
+"copyEmailPreview" = "Sonuç e-posta önizlemesi"
+"copySelectSourceFirst" = "Önce bir kaynak gelen bağlantı seçin."
+"copyResult" = "Kopyalama sonucu"
+"copyResultSuccess" = "Başarıyla kopyalandı"
+"copyResultNone" = "Kopyalanacak bir şey yok: istemci seçilmedi veya kaynak boş"
+"copyResultErrors" = "Kopyalama hataları"
+"copyFlowLabel" = "Yeni istemciler için Flow (VLESS)"
+"copyFlowHint" = "Kopyalanan tüm istemcilere uygulanır. Boş bırakırsanız atlanır."
+"selectAll" = "Tümünü seç"
+"clearAll" = "Tümünü temizle"
"method" = "Yöntem"
"first" = "İlk"
"last" = "Son"
diff --git a/web/translation/translate.uk_UA.toml b/web/translation/translate.uk_UA.toml
index fa1bee62..40bcaa76 100644
--- a/web/translation/translate.uk_UA.toml
+++ b/web/translation/translate.uk_UA.toml
@@ -298,6 +298,20 @@
"submitEdit" = "Зберегти зміни"
"clientCount" = "Кількість клієнтів"
"bulk" = "Додати групу"
+"copyFromInbound" = "Скопіювати клієнтів з інбаунда"
+"copyToInbound" = "Скопіювати клієнтів у"
+"copySelected" = "Скопіювати вибраних"
+"copySource" = "Джерело"
+"copyEmailPreview" = "Попередній перегляд підсумкових email"
+"copySelectSourceFirst" = "Спочатку виберіть джерело."
+"copyResult" = "Результат копіювання"
+"copyResultSuccess" = "Успішно скопійовано"
+"copyResultNone" = "Нічого копіювати: жодного клієнта не вибрано або список джерела порожній"
+"copyResultErrors" = "Помилки під час копіювання"
+"copyFlowLabel" = "Flow для нових клієнтів (VLESS)"
+"copyFlowHint" = "Застосується до всіх скопійованих клієнтів. Залиште порожнім, щоб не задавати."
+"selectAll" = "Вибрати всіх"
+"clearAll" = "Зняти все"
"method" = "Метод"
"first" = "Перший"
"last" = "Останній"
diff --git a/web/translation/translate.vi_VN.toml b/web/translation/translate.vi_VN.toml
index 7f48f9a1..43afe89b 100644
--- a/