Merge pull request #4317 from yan9651688/feat/account-one-click-copy

feat(accounts): add safe one-click account duplication
This commit is contained in:
Wesley Liddick
2026-07-15 14:23:35 +08:00
committed by GitHub
22 changed files with 1402 additions and 49 deletions
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn()
}))
vi.mock('@/api/client', () => ({
apiClient: { post }
}))
import { duplicate } from '@/api/admin/accounts'
describe('admin account duplicate API', () => {
beforeEach(() => {
sessionStorage.clear()
post.mockReset()
post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } })
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
})
it('sends a stable idempotency key with the duplicate request', async () => {
const account = await duplicate(42)
expect(post).toHaveBeenCalledWith('/admin/accounts/42/duplicate', undefined, {
headers: {
'Idempotency-Key': 'account-duplicate-42-11111111-1111-4111-8111-111111111111'
}
})
expect(account).toEqual({ id: 43, name: 'primary (Copy)' })
})
it('reuses the operation key after an ambiguous failed request', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(99)).rejects.toThrow('network timeout')
post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } })
await duplicate(99)
expect(post).toHaveBeenCalledTimes(2)
const firstHeaders = post.mock.calls[0][2].headers
const secondHeaders = post.mock.calls[1][2].headers
expect(secondHeaders).toEqual(firstHeaders)
})
it('reuses the operation key after a page reload', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(77)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.resetModules()
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
const { duplicate: duplicateAfterReload } = await import('@/api/admin/accounts')
await duplicateAfterReload(77)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
})
+45
View File
@@ -138,6 +138,50 @@ export async function create(accountData: CreateAccountRequest): Promise<Account
return data
}
/**
* Duplicate an account while keeping credentials on the server.
* @param id - Source account ID
* @returns Newly created account
*/
const duplicateOperationKeys = new Map<number, string>()
function duplicateOperationStorageKey(id: number): string {
return `sub2api:admin:account-duplicate:${id}`
}
function getStoredDuplicateOperationKey(id: number): string | null {
try {
return globalThis.sessionStorage?.getItem(duplicateOperationStorageKey(id)) ?? null
} catch {
return null
}
}
function storeDuplicateOperationKey(id: number, key: string | null): void {
try {
if (key) globalThis.sessionStorage?.setItem(duplicateOperationStorageKey(id), key)
else globalThis.sessionStorage?.removeItem(duplicateOperationStorageKey(id))
} catch {
// In-memory retry protection still works when browser storage is unavailable.
}
}
export async function duplicate(id: number): Promise<Account> {
let idempotencyKey = duplicateOperationKeys.get(id) ?? getStoredDuplicateOperationKey(id)
if (!idempotencyKey) {
const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
idempotencyKey = `account-duplicate-${id}-${requestID}`
}
duplicateOperationKeys.set(id, idempotencyKey)
storeDuplicateOperationKey(id, idempotencyKey)
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/duplicate`, undefined, {
headers: { 'Idempotency-Key': idempotencyKey }
})
duplicateOperationKeys.delete(id)
storeDuplicateOperationKey(id, null)
return data
}
/**
* Update account
* @param id - Account ID
@@ -809,6 +853,7 @@ export const accountsAPI = {
listWithEtag,
getById,
create,
duplicate,
update,
checkMixedChannelRisk,
delete: deleteAccount,