From 00eabe8ab9dd9de16cf07f62a165b98671c40a97 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sat, 5 Sep 2026 19:20:10 +0800 Subject: [PATCH] feat(accounts): add month and year expiry presets --- .../components/account/CreateAccountModal.vue | 9 ++++ .../components/account/EditAccountModal.vue | 9 ++++ .../__tests__/CreateAccountModal.spec.ts | 46 ++++++++++++++++++- .../__tests__/EditAccountModal.spec.ts | 46 ++++++++++++++++++- .../account/__tests__/accountExpiry.spec.ts | 19 ++++++++ .../src/components/account/accountExpiry.ts | 7 +++ 6 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/account/__tests__/accountExpiry.spec.ts create mode 100644 frontend/src/components/account/accountExpiry.ts diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 451ac2a1ef..f93c486833 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2961,6 +2961,14 @@
+
+ + +

{{ t('admin.accounts.expiresAtHint') }} {{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }} @@ -3868,6 +3876,7 @@ import { parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' +import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' import { OPENAI_WS_MODE_CTX_POOL, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8968bdc2e0..684181c6af 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1617,6 +1617,14 @@

+
+ + +

{{ t('admin.accounts.expiresAtHint') }} {{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }} @@ -2964,6 +2972,7 @@ import { parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' +import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry' import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' import { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index c13a69fbdd..a1a4d80a3e 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -1,6 +1,6 @@ import { defineComponent } from 'vue' import { flushPromises, mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { createAccountMock, @@ -212,6 +212,50 @@ describe('CreateAccountModal OpenAI long-context billing', () => { createOpenAICodexPATMock.mockReset().mockResolvedValue({}) }) + afterEach(() => vi.useRealTimers()) + + it('sets month and year expiry presets without submitting the account form', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-01-31T12:34:00')) + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('expiry account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + const input = wrapper.get('input[type="datetime-local"]') + + for (const [label, expected] of [ + ['payment.oneMonth', '2026-02-28T12:34'], + ['payment.oneYear', '2027-01-31T12:34'], + ]) { + const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)! + expect(button.attributes('type')).toBe('button') + await button.trigger('click') + expect(input.element.value).toBe(expected) + expect(createAccountMock).not.toHaveBeenCalled() + } + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2027-01-31T12:34:00').getTime() / 1000) + wrapper.unmount() + }) + + it('allows a manually entered expiry to override a preset before account creation', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('custom expiry account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await selectButtonByText(wrapper, 'payment.oneMonth') + await wrapper.get('input[type="datetime-local"]').setValue('2030-04-15T09:20') + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2030-04-15T09:20:00').getTime() / 1000) + wrapper.unmount() + }) + it('hides only the redundant account toggle when every selected group enables tier pricing', async () => { authIsSimpleMode.value = false const wrapper = mountModal([ diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 7d50e0f91f..5c85b7c2af 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' import { mount } from '@vue/test-utils' @@ -328,6 +328,50 @@ describe('EditAccountModal', () => { authIsSimpleMode.value = true }) + afterEach(() => vi.useRealTimers()) + + it('sets expiry presets from now instead of extending the saved expiry', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2028-02-29T12:34:00')) + const account = buildAccount() + account.expires_at = new Date('2030-06-15T09:00:00').getTime() / 1000 + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + const input = wrapper.get('input[type="datetime-local"]') + + for (const [label, expected] of [ + ['payment.oneMonth', '2028-03-29T12:34'], + ['payment.oneYear', '2029-02-28T12:34'], + ]) { + const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)! + expect(button.attributes('type')).toBe('button') + await button.trigger('click') + expect(input.element.value).toBe(expected) + expect(updateAccountMock).not.toHaveBeenCalled() + } + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(new Date('2029-02-28T12:34:00').getTime() / 1000) + wrapper.unmount() + }) + + it('can clear a selected expiry preset before saving the account', async () => { + const account = buildAccount() + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + const button = wrapper.findAll('button').find((candidate) => candidate.text() === 'payment.oneYear')! + await button.trigger('click') + const input = wrapper.get('input[type="datetime-local"]') + expect(input.element.value).not.toBe('') + await input.setValue('') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(0) + wrapper.unmount() + }) + it('reopening the same account rehydrates the OpenAI whitelist from props', async () => { const account = buildAccount() updateAccountMock.mockReset() diff --git a/frontend/src/components/account/__tests__/accountExpiry.spec.ts b/frontend/src/components/account/__tests__/accountExpiry.spec.ts new file mode 100644 index 0000000000..7d243a8434 --- /dev/null +++ b/frontend/src/components/account/__tests__/accountExpiry.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { getAccountExpiryTimestamp } from '../accountExpiry' + +describe('getAccountExpiryTimestamp', () => { + it.each([ + ['2026-01-31T12:34:56.789', 1, '2026-02-28T12:34:56'], + ['2028-01-31T12:34:56.789', 1, '2028-02-29T12:34:56'], + ['2028-02-29T12:34:56.789', 12, '2029-02-28T12:34:56'], + ['2026-12-31T12:34:56.789', 1, '2027-01-31T12:34:56'], + // With TZ=Australia/Melbourne, October 1 contains a DST gap; avoid using it as an intermediate date. + ['2028-10-31T02:30:00', 1, '2028-11-30T02:30:00'], + ])('adds %s by %i calendar months with month-end clamping', (input, months, expected) => { + const now = new Date(input) + const originalTimestamp = now.getTime() + + expect(getAccountExpiryTimestamp(months, now)).toBe(new Date(expected).getTime() / 1000) + expect(now.getTime()).toBe(originalTimestamp) + }) +}) diff --git a/frontend/src/components/account/accountExpiry.ts b/frontend/src/components/account/accountExpiry.ts new file mode 100644 index 0000000000..33665d340c --- /dev/null +++ b/frontend/src/components/account/accountExpiry.ts @@ -0,0 +1,7 @@ +/** Set an expiry from local calendar months, clamping month-end dates. */ +export function getAccountExpiryTimestamp(months: number, now = new Date()): number { + const expiry = new Date(now.getTime()) + const lastDay = new Date(expiry.getFullYear(), expiry.getMonth() + months + 1, 0).getDate() + expiry.setMonth(expiry.getMonth() + months, Math.min(expiry.getDate(), lastDay)) + return Math.floor(expiry.getTime() / 1000) +}