Explorar o código

fix: restore admin resource transfer confirmations

Use the Ant Design app modal instance for admin resource transfer confirmations so domain, DNS credential, and API token transfers can proceed after form submission. Add UI coverage for domain and DNS credential transfer flows.

AI-Co-Authored-By: Codex
chendeben hai 1 mes
pai
achega
ffdf8e6be7

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Account-JLSLLCWd.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Admin-BUJvz7RQ.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Admin-C0WP3L38.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/ApiTokens-qGT6EEU9.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
public/assets/Dashboard-98LvZX6c.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
public/assets/DashboardCharts-XTeFGkxN.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/DnsApi-CjNU-Ppc.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/DomainDetail-x6tu_kb_.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Domains-C0x30adR.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Inbox-DVO6wVkZ.js


+ 1 - 1
public/assets/MailboxPermissionTags-BAjhCwxd.js → public/assets/MailboxPermissionTags-ORMgZe2y.js

@@ -1 +1 @@
-import{o as e}from"./theme-JEAJA3Ef.js";import{i as t,t as n}from"./jsx-runtime-dm82oUI8.js";import{_ as r}from"./index-BFO2uHVj.js";var i=n();function a({permissions:n}){let{t:a}=e(),o=[n.view?[`view`,a(`mailboxPermission.view`)]:null,n.receive?[`receive`,a(`mailboxPermission.receive`)]:null,n.send?[`send`,a(`mailboxPermission.send`)]:null].filter(Boolean);return o.length?(0,i.jsx)(t,{size:[4,4],wrap:!0,children:o.map(([e,t])=>(0,i.jsx)(r,{children:t},e))}):(0,i.jsx)(r,{children:a(`mailboxPermission.none`)})}export{a as t};
+import{o as e}from"./theme-JEAJA3Ef.js";import{i as t,t as n}from"./jsx-runtime-dm82oUI8.js";import{_ as r}from"./index-avDTLp8M.js";var i=n();function a({permissions:n}){let{t:a}=e(),o=[n.view?[`view`,a(`mailboxPermission.view`)]:null,n.receive?[`receive`,a(`mailboxPermission.receive`)]:null,n.send?[`send`,a(`mailboxPermission.send`)]:null].filter(Boolean);return o.length?(0,i.jsx)(t,{size:[4,4],wrap:!0,children:o.map(([e,t])=>(0,i.jsx)(r,{children:t},e))}):(0,i.jsx)(r,{children:a(`mailboxPermission.none`)})}export{a as t};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/SectionCard-DDGEHf10.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/SendingLogs-EyGyZdpp.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Settings-DfSJsq0-.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/SmtpCredentials-ufx_CaIB.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Webhooks-8HAWQsN7.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
public/assets/index-avDTLp8M.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/list-B0gNP0uN.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/popconfirm-eKz0FSVA.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/table-DZMCIB4H.js


+ 1 - 1
public/index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-BFO2uHVj.js"></script>
+    <script type="module" crossorigin src="/assets/index-avDTLp8M.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-dom-stZKkA6x.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-dm82oUI8.js">

+ 32 - 7
src/pages/Admin/index.tsx

@@ -609,7 +609,8 @@ function AdminResources({
   onTransferDnsCredential: (credentialId: number, values: { targetUserId: number }) => Promise<void>;
   onTransferApiTokens: (values: { tokenIds: number[]; targetUserId: number }) => Promise<void>;
 }) {
-  const { locale } = useI18n();
+  const { locale, t } = useI18n();
+  const { modal } = AntApp.useApp();
   const [domainForm] = Form.useForm<{ domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }>();
   const [dnsForm] = Form.useForm<{ credentialId: number; targetUserId: number }>();
   const [tokenForm] = Form.useForm<{ tokenIds: number[]; targetUserId: number }>();
@@ -649,8 +650,10 @@ function AdminResources({
   ];
 
   async function submitDomainTransfer(values: { domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }) {
-    Modal.confirm({
+    modal.confirm({
       title: tr(locale, '确认迁移该域名?', 'Transfer this domain?'),
+      okText: t('common.confirm'),
+      cancelText: t('common.cancel'),
       onOk: async () => {
         await onTransferDomain(values.domainId, {
           targetUserId: values.targetUserId,
@@ -662,8 +665,10 @@ function AdminResources({
   }
 
   async function submitDnsTransfer(values: { credentialId: number; targetUserId: number }) {
-    Modal.confirm({
+    modal.confirm({
       title: tr(locale, '确认迁移该 DNS 凭据?', 'Transfer this DNS credential?'),
+      okText: t('common.confirm'),
+      cancelText: t('common.cancel'),
       onOk: async () => {
         await onTransferDnsCredential(values.credentialId, { targetUserId: values.targetUserId });
         dnsForm.resetFields();
@@ -672,8 +677,10 @@ function AdminResources({
   }
 
   async function submitTokenTransfer(values: { tokenIds: number[]; targetUserId: number }) {
-    Modal.confirm({
+    modal.confirm({
       title: tr(locale, `确认迁移 ${values.tokenIds.length} 个 API 密钥?`, `Transfer ${values.tokenIds.length} API keys?`),
+      okText: t('common.confirm'),
+      cancelText: t('common.cancel'),
       onOk: async () => {
         await onTransferApiTokens({ tokenIds: values.tokenIds, targetUserId: values.targetUserId });
         tokenForm.resetFields();
@@ -692,7 +699,13 @@ function AdminResources({
       ) : null}
       <div className="form-grid three">
         <SectionCard title={tr(locale, '迁移域名', 'Transfer domain')}>
-          <Form form={domainForm} layout="vertical" onFinish={submitDomainTransfer} disabled={loading || actionKeys.has(`transfer-domain:${selectedDomainId}`)}>
+          <Form
+            form={domainForm}
+            name="admin-domain-transfer"
+            layout="vertical"
+            onFinish={submitDomainTransfer}
+            disabled={loading || actionKeys.has(`transfer-domain:${selectedDomainId}`)}
+          >
             <Form.Item name="domainId" label={tr(locale, '域名', 'Domain')} rules={[{ required: true }]}>
               <Select
                 showSearch
@@ -719,7 +732,13 @@ function AdminResources({
           </Form>
         </SectionCard>
         <SectionCard title={tr(locale, '迁移 DNS 凭据', 'Transfer DNS credential')}>
-          <Form form={dnsForm} layout="vertical" onFinish={submitDnsTransfer} disabled={loading || actionKeys.has(`transfer-dns:${selectedCredentialId}`)}>
+          <Form
+            form={dnsForm}
+            name="admin-dns-credential-transfer"
+            layout="vertical"
+            onFinish={submitDnsTransfer}
+            disabled={loading || actionKeys.has(`transfer-dns:${selectedCredentialId}`)}
+          >
             <Form.Item name="credentialId" label={tr(locale, 'DNS 凭据', 'DNS credential')} rules={[{ required: true }]}>
               <Select
                 showSearch
@@ -737,7 +756,13 @@ function AdminResources({
           </Form>
         </SectionCard>
         <SectionCard title={tr(locale, '迁移 API 密钥', 'Transfer API keys')}>
-          <Form form={tokenForm} layout="vertical" onFinish={submitTokenTransfer} disabled={loading || actionKeys.has('transfer-token')}>
+          <Form
+            form={tokenForm}
+            name="admin-api-token-transfer"
+            layout="vertical"
+            onFinish={submitTokenTransfer}
+            disabled={loading || actionKeys.has('transfer-token')}
+          >
             <Form.Item name="tokenIds" label={tr(locale, 'API 密钥', 'API key')} rules={[{ required: true }]}>
               <Select
                 mode="multiple"

+ 175 - 0
test/ui/admin-resources.test.tsx

@@ -0,0 +1,175 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createMemoryRouter, RouterProvider } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { api } from '../../src/frontend/services/api';
+import { mailhubTheme } from '../../src/frontend/theme';
+import AdminPage from '../../src/pages/Admin';
+import type { AdminResourceInventory, AdminUser, Domain, RuntimeConfig } from '../../src/frontend/types';
+
+describe('Admin resource migration', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  it('submits a domain transfer after selecting the domain and target user', async () => {
+    const user = userEvent.setup();
+    const transferDomain = vi.spyOn(api, 'transferAdminDomain').mockResolvedValue({ domain });
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users });
+    vi.spyOn(api, 'adminResources').mockResolvedValue({ inventory });
+
+    renderAdminResourcesRoute();
+
+    const transferCard = (await screen.findByText('迁移域名')).closest('.ant-card');
+    expect(transferCard).not.toBeNull();
+    await selectOption(user, within(transferCard!).getByLabelText('域名'), 'taobiba.com · chendeben');
+    await selectOption(user, within(transferCard!).getByLabelText('目标用户'), 'chan (#7)');
+    await user.click(within(transferCard!).getByRole('button', { name: '执行迁移' }));
+
+    const dialog = await screen.findByRole('dialog');
+    expect(dialog.textContent).toContain('确认迁移该域名?');
+    await user.click(within(dialog).getByRole('button', { name: /确\s*认/ }));
+
+    await waitFor(() => expect(transferDomain).toHaveBeenCalledWith(12, {
+      targetUserId: 7,
+      dnsCredentialMode: 'domain_only'
+    }));
+  });
+
+  it('submits a DNS credential transfer after confirming the action', async () => {
+    const user = userEvent.setup();
+    const transferCredential = vi.spyOn(api, 'transferAdminDnsCredential').mockResolvedValue({
+      credential: inventory.users[0].dnsCredentials[0]
+    });
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users });
+    vi.spyOn(api, 'adminResources').mockResolvedValue({ inventory });
+
+    renderAdminResourcesRoute();
+
+    const transferCard = (await screen.findByText('迁移 DNS 凭据')).closest('.ant-card');
+    expect(transferCard).not.toBeNull();
+    await selectOption(user, within(transferCard!).getByLabelText('DNS 凭据'), 'DNSPod · taobiba.com · chendeben');
+    await selectOption(user, within(transferCard!).getByLabelText('目标用户'), 'chan (#7)');
+    await user.click(within(transferCard!).getByRole('button', { name: '执行迁移' }));
+
+    const dialog = await screen.findByRole('dialog');
+    expect(dialog.textContent).toContain('确认迁移该 DNS 凭据?');
+    await user.click(within(dialog).getByRole('button', { name: /确\s*认/ }));
+
+    await waitFor(() => expect(transferCredential).toHaveBeenCalledWith(8, { targetUserId: 7 }));
+  });
+});
+
+async function selectOption(user: ReturnType<typeof userEvent.setup>, input: HTMLElement, option: string) {
+  await user.click(input);
+  await user.click(await screen.findByText(option, { selector: '.ant-select-item-option-content' }));
+}
+
+function renderAdminResourcesRoute() {
+  const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
+    initialEntries: ['/admin/resources']
+  });
+
+  return render(
+    <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
+      <AntApp>
+        <I18nProvider>
+          <AppContext.Provider value={appContext}>
+            <RouterProvider router={router} />
+          </AppContext.Provider>
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+}
+
+const resourceCounts = {
+  domains: 1,
+  dnsCredentials: 1,
+  apiTokens: 0,
+  inboundMailboxes: 0,
+  inboundMessages: 0,
+  sendEvents: 0,
+  smtpCredential: 0
+};
+
+const users: AdminUser[] = [
+  { id: 1, username: 'chendeben', email: 'owner@example.test', role: 'admin', status: 'active', resourceCounts },
+  { id: 7, username: 'chan', email: 'chan@example.test', role: 'user', status: 'active', resourceCounts }
+];
+
+const domain: Domain = {
+  id: 12,
+  userId: 1,
+  dnsCredentialId: 8,
+  smtpRelayId: null,
+  domain: 'taobiba.com',
+  selector: 'mailhub',
+  verificationToken: 'verify-token',
+  dkimPublic: 'public-key',
+  senderHost: 'mail.taobiba.com',
+  sendingIp: '192.0.2.10',
+  spfExtra: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  catchAllAddress: '',
+  mailboxSignupEnabled: false,
+  status: {},
+  createdAt: '2026-07-19T00:00:00.000Z',
+  updatedAt: '2026-07-19T00:00:00.000Z'
+};
+
+const inventory: AdminResourceInventory = {
+  users: [
+    {
+      user: users[0],
+      domains: [domain],
+      dnsCredentials: [{
+        id: 8,
+        userId: 1,
+        name: 'DNSPod',
+        provider: 'dnspod',
+        zoneName: 'taobiba.com',
+        defaultTtl: 600,
+        credentialSet: true,
+        createdAt: '2026-07-19T00:00:00.000Z',
+        updatedAt: '2026-07-19T00:00:00.000Z'
+      }],
+      smtpCredential: null,
+      apiTokens: [],
+      inboundMailboxes: [],
+      inboundMessageCount: 0,
+      sendEventCount: 0
+    }
+  ],
+  warnings: []
+};
+
+const runtimeConfig: RuntimeConfig = {
+  appBaseUrl: 'https://mail.example.test',
+  mailHostname: 'mail.example.test',
+  sendingIp: '192.0.2.10',
+  defaultSpfMechanisms: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  registrationRequiresApproval: false,
+  sendRequiresVerified: true,
+  engagementTrackingEnabled: true,
+  listUnsubscribeMailto: '',
+  listUnsubscribeUrl: '',
+  listUnsubscribePostEnabled: false,
+  feedbackIdEnabled: false,
+  reportAbuseTo: '',
+  csaComplaintsTo: '',
+  bounceAddress: '',
+  bounceEnvelopeEnabled: false
+};
+
+const appContext: AppContextValue = {
+  user: users[0],
+  config: runtimeConfig,
+  refreshBootstrap: vi.fn(async () => undefined),
+  logout: vi.fn(async () => undefined)
+};

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio