diff --git a/src/locales/en/main.json b/src/locales/en/main.json index bcdfed150d5..f13ed7f017e 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -3303,6 +3303,8 @@ }, "inviteResent": "Invite resent", "inviteResendFailed": "Failed to resend invite", + "inviteResendCooldown": "You're resending invites too quickly", + "inviteResendCooldownDetail": "You can resend in {seconds} second | You can resend in {seconds} seconds", "failedToUpdateWorkspace": "Failed to update workspace", "failedToCreateWorkspace": "Failed to create workspace", "failedToDeleteWorkspace": "Failed to delete workspace", diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 30c3c3104e3..08eaa10ef0f 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -160,13 +160,22 @@ export class WorkspaceApiError extends Error { constructor( message: string, public readonly status?: number, - public readonly code?: string + public readonly code?: string, + public readonly retryAfter?: number ) { super(message) this.name = 'WorkspaceApiError' } } +function parseRetryAfterSeconds(value: unknown): number | undefined { + if (typeof value !== 'string' || value.trim() === '') return undefined + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 + ? Math.ceil(seconds) + : undefined +} + const workspaceApiClient = axios.create({ headers: { 'Content-Type': 'application/json' @@ -189,10 +198,15 @@ function handleAxiosError(err: unknown): never { ) // Callers compare `code` against server-defined values, so the parser's // "no code reported" sentinel must stay out of that contract. + const retryAfter = + status === 429 + ? parseRetryAfterSeconds(err.response?.headers?.['retry-after']) + : undefined throw new WorkspaceApiError( message, status, - code === UNKNOWN_ERROR_CODE ? undefined : code + code === UNKNOWN_ERROR_CODE ? undefined : code, + retryAfter ) } throw err diff --git a/src/platform/workspace/composables/useMembersPanel.test.ts b/src/platform/workspace/composables/useMembersPanel.test.ts index 4efb8798906..755a0d0a1a3 100644 --- a/src/platform/workspace/composables/useMembersPanel.test.ts +++ b/src/platform/workspace/composables/useMembersPanel.test.ts @@ -324,9 +324,13 @@ vi.mock('primevue/usetoast', () => ({ useToast: () => ({ add: mockToastAdd }) })) -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ t: (key: string) => key }) -})) +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + useI18n: () => ({ t: (key: string) => key }) + } +}) vi.mock('pinia', async (importOriginal) => { const actual = await importOriginal() diff --git a/src/platform/workspace/composables/useMembersPanel.ts b/src/platform/workspace/composables/useMembersPanel.ts index aa51c8b9ae2..dc6fa4ddde1 100644 --- a/src/platform/workspace/composables/useMembersPanel.ts +++ b/src/platform/workspace/composables/useMembersPanel.ts @@ -8,6 +8,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useBillingContext } from '@/composables/billing/useBillingContext' import { useFeatureFlags } from '@/composables/useFeatureFlags' import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog' +import { WorkspaceApiError } from '@/platform/workspace/api/workspaceApi' import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi' import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan' import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' @@ -349,7 +350,23 @@ export function useMembersPanel() { summary: t('workspacePanel.toast.inviteResent'), life: 2000 }) - } catch { + } catch (err) { + if (err instanceof WorkspaceApiError && err.status === 429) { + const seconds = err.retryAfter + toast.add({ + severity: 'warn', + summary: t('workspacePanel.toast.inviteResendCooldown'), + detail: seconds + ? t( + 'workspacePanel.toast.inviteResendCooldownDetail', + { seconds }, + seconds + ) + : undefined, + life: Math.min((seconds ?? 5) * 1000, 10_000) + }) + return + } toast.add({ severity: 'error', summary: t('workspacePanel.toast.inviteResendFailed')