feat: separate storage code revoke and delete

* feat(quota-store): separate revoke and delete semantics for redemption codes

- PATCH /api/admin/quota-store/storage-codes/:code accepts { revokedAt } to
  revoke a code (keeps it visible, prevents future redemptions)
- DELETE /api/admin/quota-store/storage-codes/:code removes eligible codes
- Added revokeStorageCodeSchema (shared) with zValidator on PATCH route
- Fixed revokeStorageRedemptionCode API fn to use PATCH with revokedAt payload
- Added deleteStorageRedemptionCode API fn using DELETE
- UI: separate Revoke and Delete buttons per code row with distinct disabled rules
  - Revoke disabled when already revoked or fully redeemed
  - Delete disabled when any redemptions exist (usesCount > 0)
- Updated confirmation copy to describe product outcome, not technical action
- i18n: added delete keys in en.json and zh.json; updated revokeConfirm copy
- Updated integration, unit, and component tests

Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: align storage code revoke contract

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jasper Van
2026-05-06 17:08:41 -04:00
committed by GitHub
parent df2e2a8cd3
commit bf0bf99ef4
11 changed files with 175 additions and 29 deletions
@@ -849,21 +849,21 @@ describe('Quota Store API', () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ code: 'ZS-GEN-1', state: 'revoked' }),
json: async () => ({}),
} as Response)
const res = await app.request('/api/admin/quota-store/storage-codes/ZS-GEN-1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ state: 'revoked' }),
body: JSON.stringify({ revoked: true }),
})
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ code: 'ZS-GEN-1', state: 'revoked' })
await expect(res.json()).resolves.toEqual({ code: 'ZS-GEN-1', revoked: true })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit]
expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}/api/store/storage-codes/ZS-GEN-1`)
expect(init.method).toBe('PATCH')
expect(JSON.parse(init.body as string)).toEqual({ state: 'revoked' })
expect(JSON.parse(init.body as string)).toEqual({ revoked: true })
})
it('rejects non-admin storage code management', async () => {
+6 -4
View File
@@ -7,6 +7,7 @@ import {
quotaStorePackagePatchSchema,
quotaStoreSettingsSchema,
redemptionInputSchema,
revokeStorageCodeSchema,
} from '@shared/schemas'
import { Hono } from 'hono'
import { z } from 'zod'
@@ -122,16 +123,17 @@ const adminQuotaStore = new Hono<Env>()
if ('error' in result) return c.json(result, 502)
return c.json({ items: result, total: result.length }, 201)
})
.patch('/storage-codes/:code', async (c) => {
.patch('/storage-codes/:code', zValidator('json', revokeStorageCodeSchema), async (c) => {
const code = c.req.param('code')
const result = await requestCloudWithBinding(
c,
`${storageCodesPath()}/${encodeURIComponent(c.req.param('code'))}`,
`${storageCodesPath()}/${encodeURIComponent(code)}`,
'PATCH',
z.object({}).passthrough(),
await c.req.json(),
c.req.valid('json'),
)
if ('error' in result) return c.json(result, 502)
return c.json(result)
return c.json({ code, revoked: true })
})
.delete('/storage-codes/:code', async (c) => {
const result = await deleteCloud(c, `${storageCodesPath()}/${encodeURIComponent(c.req.param('code'))}`)
+2
View File
@@ -24,6 +24,7 @@ export type {
QuotaStorePackagePatchInput,
QuotaStoreSettingsInput,
RedemptionInput,
RevokeStorageCodeInput,
StorageCodeStatus,
} from './quota-store'
export {
@@ -34,6 +35,7 @@ export {
quotaStorePackagePatchSchema,
quotaStoreSettingsSchema,
redemptionInputSchema,
revokeStorageCodeSchema,
storageCodeStatusSchema,
} from './quota-store'
export type { CreateShareInput, CreateShareRequest, ShareKind } from './share'
+5
View File
@@ -44,6 +44,10 @@ export const generateStorageCodesInputSchema = z.object({
count: z.number().int().min(1).max(100),
})
export const revokeStorageCodeSchema = z.object({
revoked: z.literal(true),
})
export const cloudDeliveryEventSchema = z
.object({
eventId: z.string().min(1),
@@ -87,4 +91,5 @@ export type CheckoutInput = z.infer<typeof checkoutInputSchema>
export type RedemptionInput = z.infer<typeof redemptionInputSchema>
export type StorageCodeStatus = z.infer<typeof storageCodeStatusSchema>
export type GenerateStorageCodesInput = z.input<typeof generateStorageCodesInputSchema>
export type RevokeStorageCodeInput = z.infer<typeof revokeStorageCodeSchema>
export type CloudDeliveryEvent = z.infer<typeof cloudDeliveryEventSchema>
@@ -20,6 +20,7 @@ import {
import {
createQuotaStorePackage,
deleteQuotaStorePackage,
deleteStorageRedemptionCode,
generateStorageRedemptionCodes,
revokeStorageRedemptionCode,
updateQuotaStorePackage,
@@ -74,9 +75,11 @@ export function usePackageEditor() {
export function useCodeActions() {
const [form, setForm] = useState(emptyCodeForm)
const [revokingCode, setRevokingCode] = useState<string | null>(null)
const [deletingCode, setDeletingCode] = useState<string | null>(null)
const generate = useGenerateCodesMutation(form, () => setForm(emptyCodeForm))
const revoke = useRevokeCodeMutation(setRevokingCode)
return { form, setForm, revokingCode, generate, revoke }
const deleteCode = useDeleteCodeMutation(setDeletingCode)
return { form, setForm, revokingCode, deletingCode, generate, revoke, deleteCode }
}
export function PackagesTab({
@@ -198,10 +201,12 @@ export function CodesTab({
available={available}
pending={actions.generate.isPending}
revokingCode={actions.revokingCode}
deletingCode={actions.deletingCode}
onStatusChange={onStatusChange}
onFormChange={actions.setForm}
onGenerate={() => actions.generate.mutate()}
onRevoke={(code) => actions.revoke.mutate(code)}
onDelete={(code) => actions.deleteCode.mutate(code)}
/>
)
}
@@ -276,6 +281,21 @@ function useRevokeCodeMutation(setRevokingCode: (code: string | null) => void) {
})
}
function useDeleteCodeMutation(setDeletingCode: (code: string | null) => void) {
const { t } = useTranslation()
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteStorageRedemptionCode,
onMutate: (code) => setDeletingCode(code),
onSettled: () => setDeletingCode(null),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'storage-plans', 'storage-codes'] })
toast.success(t('admin.storagePlans.codes.deleted'))
},
onError: (err) => toast.error(err.message),
})
}
function editPackage(
pkg: QuotaStorePackage,
setEditing: (pkg: QuotaStorePackage) => void,
@@ -1,6 +1,6 @@
import type { GenerateStorageCodesInput, StorageCodeStatus } from '@shared/schemas'
import type { StorageRedemptionCode } from '@shared/types'
import { Ban, Plus } from 'lucide-react'
import { Ban, Plus, Trash2 } from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
@@ -233,8 +233,10 @@ type CodeListProps = {
status: StorageCodeStatus | 'all'
available: boolean
revokingCode: string | null
deletingCode: string | null
onStatusChange: (status: StorageCodeStatus | 'all') => void
onRevoke: (code: string) => void
onDelete: (code: string) => void
}
function CodeStatusSelect({ status, onStatusChange }: Pick<CodeListProps, 'status' | 'onStatusChange'>) {
@@ -278,10 +280,21 @@ function CodeTable(props: CodeListProps) {
)
}
function CodeRow({ code, available, revokingCode, onRevoke }: CodeListProps & { code: StorageRedemptionCode }) {
function CodeRow({
code,
available,
revokingCode,
deletingCode,
onRevoke,
onDelete,
}: CodeListProps & { code: StorageRedemptionCode }) {
const { t } = useTranslation()
const [confirmOpen, setConfirmOpen] = useState(false)
const [revokeConfirmOpen, setRevokeConfirmOpen] = useState(false)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const revoked = Boolean(code.revokedAt)
const redeemed = code.usesCount >= code.maxUses
const canRevoke = !revoked && !redeemed
const canDelete = code.usesCount === 0
return (
<TableRow>
<TableCell className="font-mono text-xs">{code.code}</TableCell>
@@ -294,23 +307,38 @@ function CodeRow({ code, available, revokingCode, onRevoke }: CodeListProps & {
<CodeStatusBadge code={code} />
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
disabled={!available || revoked || revokingCode === code.code}
onClick={() => setConfirmOpen(true)}
>
<Ban className="mr-2 h-4 w-4" />
{t('admin.storagePlans.codes.revoke')}
</Button>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={!available || !canRevoke || revokingCode === code.code}
onClick={() => setRevokeConfirmOpen(true)}
>
<Ban className="mr-2 h-4 w-4" />
{t('admin.storagePlans.codes.revoke')}
</Button>
<Button
variant="outline"
size="sm"
disabled={!available || !canDelete || deletingCode === code.code}
onClick={() => setDeleteConfirmOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('admin.storagePlans.codes.delete')}
</Button>
</div>
<Dialog open={revokeConfirmOpen} onOpenChange={setRevokeConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('admin.storagePlans.codes.revokeTitle')}</DialogTitle>
<DialogDescription>{t('admin.storagePlans.codes.revokeConfirm', { code: code.code })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" disabled={revokingCode === code.code} onClick={() => setConfirmOpen(false)}>
<Button
variant="outline"
disabled={revokingCode === code.code}
onClick={() => setRevokeConfirmOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
@@ -318,7 +346,7 @@ function CodeRow({ code, available, revokingCode, onRevoke }: CodeListProps & {
disabled={revokingCode === code.code}
onClick={() => {
onRevoke(code.code)
setConfirmOpen(false)
setRevokeConfirmOpen(false)
}}
>
{t('admin.storagePlans.codes.revoke')}
@@ -326,6 +354,33 @@ function CodeRow({ code, available, revokingCode, onRevoke }: CodeListProps & {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('admin.storagePlans.codes.deleteTitle')}</DialogTitle>
<DialogDescription>{t('admin.storagePlans.codes.deleteConfirm', { code: code.code })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
disabled={deletingCode === code.code}
onClick={() => setDeleteConfirmOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
disabled={deletingCode === code.code}
onClick={() => {
onDelete(code.code)
setDeleteConfirmOpen(false)
}}
>
{t('admin.storagePlans.codes.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
)
+5 -1
View File
@@ -1087,7 +1087,11 @@
"admin.storagePlans.codes.revoked": "Code revoked",
"admin.storagePlans.codes.revoke": "Revoke",
"admin.storagePlans.codes.revokeTitle": "Revoke code",
"admin.storagePlans.codes.revokeConfirm": "Revoke {{code}}? Users will no longer be able to redeem this code. Storage already granted by earlier redemptions will not be changed.",
"admin.storagePlans.codes.revokeConfirm": "Revoke {{code}}? This code will stop working for future redemptions. Existing redeemed storage or traffic will not be changed.",
"admin.storagePlans.codes.deleted": "Code deleted",
"admin.storagePlans.codes.delete": "Delete",
"admin.storagePlans.codes.deleteTitle": "Delete code",
"admin.storagePlans.codes.deleteConfirm": "Delete {{code}}? This code will be removed from the list. Use this only for codes that should no longer appear in management.",
"admin.storagePlans.codes.code": "Code",
"admin.storagePlans.codes.storage": "Storage",
"admin.storagePlans.codes.uses": "Uses",
+5 -1
View File
@@ -1087,7 +1087,11 @@
"admin.storagePlans.codes.revoked": "兑换码已撤销",
"admin.storagePlans.codes.revoke": "撤销",
"admin.storagePlans.codes.revokeTitle": "撤销兑换码",
"admin.storagePlans.codes.revokeConfirm": "确定撤销 {{code}} 吗?撤销后用户将无法再使用这个兑换码,之前已兑换成功的存储空间不会受到影响。",
"admin.storagePlans.codes.revokeConfirm": "确定撤销 {{code}} 吗?此兑换码将无法再用于新的兑换。之前已兑换的存储或流量不会受到影响。",
"admin.storagePlans.codes.deleted": "兑换码已删除",
"admin.storagePlans.codes.delete": "删除",
"admin.storagePlans.codes.deleteTitle": "删除兑换码",
"admin.storagePlans.codes.deleteConfirm": "确定删除 {{code}} 吗?此兑换码将从列表中移除。仅对不再需要出现在管理界面中的兑换码使用此操作。",
"admin.storagePlans.codes.code": "兑换码",
"admin.storagePlans.codes.storage": "容量",
"admin.storagePlans.codes.uses": "使用",
+9 -2
View File
@@ -30,6 +30,7 @@ import {
deleteQuotaStorePackage,
deleteShare,
deleteStorage,
deleteStorageRedemptionCode,
deleteTeamLogo,
deleteUser,
disconnectCloud,
@@ -370,12 +371,14 @@ describe('api', () => {
vi.mocked(fetch)
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
.mockResolvedValueOnce(makeResponse({ items: [{ code: 'ZS123' }], total: 1 }))
.mockResolvedValueOnce(makeResponse({ code: 'ZS123', revoked: true }))
.mockResolvedValueOnce(makeResponse({ code: 'ZS123', deleted: true }))
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
await listStorageRedemptionCodes('active')
await generateStorageRedemptionCodes({ resourceType: 'traffic', resourceBytes: 1024, maxUses: 2, count: 3 })
await revokeStorageRedemptionCode('ZS123')
await deleteStorageRedemptionCode('ZS123')
await listAdminQuotaDeliveryRecords()
const calls = vi.mocked(fetch).mock.calls as Array<[string, RequestInit]>
@@ -390,8 +393,11 @@ describe('api', () => {
count: 3,
})
expect(calls[2][0]).toBe('/api/admin/quota-store/storage-codes/ZS123')
expect(calls[2][1].method).toBe('DELETE')
expect(calls[3][0]).toBe('/api/admin/quota-store/delivery-records')
expect(calls[2][1].method).toBe('PATCH')
expect(JSON.parse(calls[2][1].body as string)).toEqual({ revoked: true })
expect(calls[3][0]).toBe('/api/admin/quota-store/storage-codes/ZS123')
expect(calls[3][1].method).toBe('DELETE')
expect(calls[4][0]).toBe('/api/admin/quota-store/delivery-records')
})
it('calls user store endpoints', async () => {
@@ -459,6 +465,7 @@ describe('api', () => {
() => generateStorageRedemptionCodes({ resourceType: 'storage', resourceBytes: 1024, count: 1 }),
],
['revokeStorageRedemptionCode', () => revokeStorageRedemptionCode('ZS123')],
['deleteStorageRedemptionCode', () => deleteStorageRedemptionCode('ZS123')],
['listAdminQuotaDeliveryRecords', () => listAdminQuotaDeliveryRecords()],
['listPurchasableQuotaPackages', () => listPurchasableQuotaPackages()],
['listQuotaStoreTargets', () => listQuotaStoreTargets()],
+9
View File
@@ -343,6 +343,15 @@ export function generateStorageRedemptionCodes(data: GenerateStorageCodesInput)
}
export function revokeStorageRedemptionCode(code: string) {
return unwrap<{ code: string; revoked: true }>(
adminQuotaStoreApi['storage-codes'][':code'].$patch({
param: { code },
json: { revoked: true },
}),
)
}
export function deleteStorageRedemptionCode(code: string) {
return unwrap<{ code: string; deleted: boolean }>(
adminQuotaStoreApi['storage-codes'][':code'].$delete({ param: { code } }),
)
@@ -8,6 +8,7 @@ import {
ApiError,
createQuotaStorePackage,
deleteQuotaStorePackage,
deleteStorageRedemptionCode,
generateStorageRedemptionCodes,
getQuotaStoreSettings,
listAdminQuotaDeliveryRecords,
@@ -57,6 +58,7 @@ vi.mock('@/lib/api', () => {
ApiError: MockApiError,
createQuotaStorePackage: vi.fn(),
deleteQuotaStorePackage: vi.fn(),
deleteStorageRedemptionCode: vi.fn(),
generateStorageRedemptionCodes: vi.fn(),
getQuotaStoreSettings: vi.fn(),
listAdminQuotaDeliveryRecords: vi.fn(),
@@ -385,7 +387,7 @@ describe('AdminStoragePlansPage', () => {
total: 1,
})
vi.mocked(generateStorageRedemptionCodes).mockResolvedValue({ items: [], total: 0 })
vi.mocked(revokeStorageRedemptionCode).mockResolvedValue({ code: 'ZS-CODE-1', deleted: true })
vi.mocked(revokeStorageRedemptionCode).mockResolvedValue({ code: 'ZS-CODE-1', revoked: true })
const view = renderAdminPage()
@@ -416,6 +418,42 @@ describe('AdminStoragePlansPage', () => {
await waitFor(() => expect(revokeStorageRedemptionCode).toHaveBeenCalledWith('ZS-CODE-1', expect.anything()))
})
it('deletes an eligible storage redemption code from the codes tab', async () => {
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [], total: 0 })
vi.mocked(listStorageRedemptionCodes).mockResolvedValue({
items: [
{
code: 'ZS-CODE-1',
resourceType: 'storage',
resourceBytes: 107374182400,
maxUses: 1,
usesCount: 0,
expiresAt: null,
createdAt: '2026-05-05T00:00:00.000Z',
revokedAt: null,
},
],
total: 1,
})
vi.mocked(deleteStorageRedemptionCode).mockResolvedValue({ code: 'ZS-CODE-1', deleted: true })
const view = renderAdminPage()
await waitFor(() => expect(view.getByRole('tab', { name: 'admin.storagePlans.tabs.codes' })).toBeTruthy())
fireEvent.click(view.getByRole('tab', { name: 'admin.storagePlans.tabs.codes' }))
await waitFor(() => expect(view.getByText('ZS-CODE-1')).toBeTruthy())
fireEvent.click(view.getByRole('button', { name: 'admin.storagePlans.codes.delete' }))
expect(deleteStorageRedemptionCode).not.toHaveBeenCalled()
const deleteDialog = await view.findByRole('dialog')
expect(within(deleteDialog).getByText('admin.storagePlans.codes.deleteTitle')).toBeTruthy()
fireEvent.click(within(deleteDialog).getByRole('button', { name: 'admin.storagePlans.codes.delete' }))
await waitFor(() => expect(deleteStorageRedemptionCode).toHaveBeenCalledWith('ZS-CODE-1', expect.anything()))
})
it('shows redemption codes in a table and opens generation fields in a dialog', async () => {
vi.mocked(getQuotaStoreSettings).mockResolvedValue(settings())
vi.mocked(listQuotaStorePackages).mockResolvedValue({ items: [], total: 0 })