Skip to content

Commit 92ee20a

Browse files
authored
feat(errors): translate infra-api blocked-team 403s into friendly text (#325)
Pairs with the infra-api, which returns 403 "team is blocked[: <reason>]" for endpoints a blocked team can't access. The dashboard previously collapsed every 403 into "You are not authorized to access this resource", hiding the actual reason from the user. This recognizes the wire format inside the central error adapter and translates it into the existing user-facing messages.
1 parent d6b8c08 commit 92ee20a

5 files changed

Lines changed: 311 additions & 8 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
/**
2+
* Known reasons used by infra/billing services when blocking a team.
3+
*
4+
* These values are matched as case-insensitive substrings against the
5+
* `blocked_reason` column (see `teams.blocked_reason`) and against the
6+
* `team is blocked: <reason>` wire format returned by infra-api.
7+
*
8+
*/
19
const TEAM_BLOCKED_REASONS = {
210
missingPayment: 'missing payment method',
311
verification: 'verification required',
12+
billingLimit: 'billing limit',
413
} as const
514

615
export { TEAM_BLOCKED_REASONS }

src/core/shared/errors.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1+
import { getBlockedReasonText } from '@/features/dashboard/team-blocked/team-blocked-message'
12
import type { RepoError, RepoErrorCode } from './result'
23

4+
/**
5+
* Wire-format prefix used by infra-api for 403 responses caused by a
6+
* blocked team. The dashboard pattern-matches this prefix to translate
7+
* the response into a friendly, user-facing message.
8+
*
9+
*/
10+
export const TEAM_BLOCKED_MESSAGE_PREFIX = 'team is blocked'
11+
312
export const PUBLIC_ERROR_MESSAGE_UNAUTHORIZED = 'Unauthorized'
413
export const PUBLIC_ERROR_MESSAGE_FORBIDDEN =
514
'You are not authorized to access this resource'
@@ -45,6 +54,27 @@ export const ApiError = (message: string) => new E2BError('API_ERROR', message)
4554
export const UnknownError = (message?: string) =>
4655
new E2BError('UNKNOWN', message ?? PUBLIC_ERROR_MESSAGE_INTERNAL)
4756

57+
export function isTeamBlockedError(input: {
58+
status?: number
59+
message?: string | null
60+
}): boolean {
61+
if (input.status !== 403 || !input.message) return false
62+
return input.message.toLowerCase().startsWith(TEAM_BLOCKED_MESSAGE_PREFIX)
63+
}
64+
65+
export function extractBlockedReason(
66+
message: string | null | undefined
67+
): string | null {
68+
if (!message) return null
69+
const lower = message.toLowerCase()
70+
if (!lower.startsWith(TEAM_BLOCKED_MESSAGE_PREFIX)) return null
71+
const rest = message
72+
.slice(TEAM_BLOCKED_MESSAGE_PREFIX.length)
73+
.replace(/^[:\s]+/, '')
74+
.trim()
75+
return rest || null
76+
}
77+
4878
export function createRepoError(input: {
4979
code: RepoErrorCode
5080
status: number
@@ -62,8 +92,13 @@ export function createRepoError(input: {
6292
export function getPublicErrorMessage(input: {
6393
code?: RepoErrorCode | string
6494
status?: number
95+
message?: string
6596
}): string {
66-
const { code, status } = input
97+
const { code, status, message } = input
98+
99+
if (isTeamBlockedError({ status, message })) {
100+
return getBlockedReasonText(extractBlockedReason(message))
101+
}
67102

68103
if (code === 'unauthorized' || status === 401)
69104
return PUBLIC_ERROR_MESSAGE_UNAUTHORIZED
@@ -86,7 +121,11 @@ export function getPublicRepoErrorMessage(error: RepoError): string {
86121
case 'conflict':
87122
return error.message
88123
default:
89-
return getPublicErrorMessage({ code: error.code, status: error.status })
124+
return getPublicErrorMessage({
125+
code: error.code,
126+
status: error.status,
127+
message: error.message,
128+
})
90129
}
91130
}
92131

src/features/dashboard/team-blocked/team-blocked-message.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,55 @@ export interface BlockedMessage {
77
href: string | null
88
}
99

10+
export function getBlockedReasonText(blockedReason: string | null): string {
11+
const reason = blockedReason?.toLowerCase() ?? ''
12+
13+
if (reason.includes(TEAM_BLOCKED_REASONS.billingLimit)) {
14+
return 'Billing limit reached.'
15+
}
16+
if (reason.includes(TEAM_BLOCKED_REASONS.missingPayment)) {
17+
return 'Missing payment method.'
18+
}
19+
if (reason.includes(TEAM_BLOCKED_REASONS.verification)) {
20+
return 'Verification required.'
21+
}
22+
23+
return blockedReason ?? 'Team suspended.'
24+
}
25+
1026
export function getBlockedMessage(
1127
slug: string,
1228
blockedReason: string | null
1329
): BlockedMessage {
1430
const reason = blockedReason?.toLowerCase() ?? ''
31+
const text = getBlockedReasonText(blockedReason)
1532

16-
if (reason.includes('billing limit')) {
33+
if (reason.includes(TEAM_BLOCKED_REASONS.billingLimit)) {
1734
return {
18-
text: 'Billing limit reached.',
35+
text,
1936
cta: 'Update limit.',
2037
href: PROTECTED_URLS.LIMITS(slug),
2138
}
2239
}
2340

2441
if (reason.includes(TEAM_BLOCKED_REASONS.missingPayment)) {
2542
return {
26-
text: 'Missing payment method.',
43+
text,
2744
cta: 'Add payment method.',
2845
href: null,
2946
}
3047
}
3148

3249
if (reason.includes(TEAM_BLOCKED_REASONS.verification)) {
3350
return {
34-
text: 'Verification required.',
51+
text,
3552
cta: 'Complete verification.',
3653
href: null,
3754
}
3855
}
3956

4057
return {
41-
text: blockedReason ?? 'Team suspended.',
58+
text,
4259
cta: null,
4360
href: null,
4461
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
extractBlockedReason,
4+
getPublicErrorMessage,
5+
getPublicRepoErrorMessage,
6+
isTeamBlockedError,
7+
PUBLIC_ERROR_MESSAGE_FORBIDDEN,
8+
PUBLIC_ERROR_MESSAGE_INTERNAL,
9+
} from '@/core/shared/errors'
10+
11+
describe('isTeamBlockedError', () => {
12+
it('matches 403 with the bare "team is blocked" prefix', () => {
13+
expect(
14+
isTeamBlockedError({ status: 403, message: 'team is blocked' })
15+
).toBe(true)
16+
})
17+
18+
it('matches 403 with a reason suffix', () => {
19+
expect(
20+
isTeamBlockedError({
21+
status: 403,
22+
message: 'team is blocked: billing limit',
23+
})
24+
).toBe(true)
25+
})
26+
27+
it('is case-insensitive on the prefix', () => {
28+
expect(
29+
isTeamBlockedError({
30+
status: 403,
31+
message: 'Team Is Blocked: Verification Required',
32+
})
33+
).toBe(true)
34+
})
35+
36+
it('rejects non-403 statuses', () => {
37+
expect(
38+
isTeamBlockedError({ status: 401, message: 'team is blocked' })
39+
).toBe(false)
40+
expect(
41+
isTeamBlockedError({ status: 500, message: 'team is blocked' })
42+
).toBe(false)
43+
})
44+
45+
it('rejects 403 responses without the prefix', () => {
46+
expect(isTeamBlockedError({ status: 403, message: 'team is banned' })).toBe(
47+
false
48+
)
49+
expect(isTeamBlockedError({ status: 403, message: 'forbidden' })).toBe(
50+
false
51+
)
52+
})
53+
54+
it('rejects missing message', () => {
55+
expect(isTeamBlockedError({ status: 403, message: null })).toBe(false)
56+
expect(isTeamBlockedError({ status: 403, message: undefined })).toBe(false)
57+
})
58+
})
59+
60+
describe('extractBlockedReason', () => {
61+
it('extracts a simple reason', () => {
62+
expect(extractBlockedReason('team is blocked: billing limit')).toBe(
63+
'billing limit'
64+
)
65+
expect(extractBlockedReason('team is blocked: verification required')).toBe(
66+
'verification required'
67+
)
68+
})
69+
70+
it('returns null for the bare prefix', () => {
71+
expect(extractBlockedReason('team is blocked')).toBeNull()
72+
})
73+
74+
it('returns null for non-blocked messages', () => {
75+
expect(extractBlockedReason('forbidden')).toBeNull()
76+
expect(extractBlockedReason('team is banned')).toBeNull()
77+
})
78+
79+
it('trims extra whitespace around the reason', () => {
80+
expect(
81+
extractBlockedReason('team is blocked: verification required ')
82+
).toBe('verification required')
83+
})
84+
85+
it('returns null on null/undefined/empty input', () => {
86+
expect(extractBlockedReason(null)).toBeNull()
87+
expect(extractBlockedReason(undefined)).toBeNull()
88+
expect(extractBlockedReason('')).toBeNull()
89+
})
90+
})
91+
92+
describe('getPublicErrorMessage with team-blocked translation', () => {
93+
it('translates billing-limit blocked messages', () => {
94+
expect(
95+
getPublicErrorMessage({
96+
status: 403,
97+
message: 'team is blocked: billing limit',
98+
})
99+
).toBe('Billing limit reached.')
100+
})
101+
102+
it('translates verification-required blocked messages', () => {
103+
expect(
104+
getPublicErrorMessage({
105+
status: 403,
106+
message: 'team is blocked: verification required',
107+
})
108+
).toBe('Verification required.')
109+
})
110+
111+
it('translates missing-payment-method blocked messages', () => {
112+
expect(
113+
getPublicErrorMessage({
114+
status: 403,
115+
message: 'team is blocked: missing payment method',
116+
})
117+
).toBe('Missing payment method.')
118+
})
119+
120+
it('returns the raw reason for unrecognized blocked reasons', () => {
121+
expect(
122+
getPublicErrorMessage({
123+
status: 403,
124+
message: 'team is blocked: blocked by support',
125+
})
126+
).toBe('blocked by support')
127+
})
128+
129+
it('returns generic "Team suspended." for the bare prefix', () => {
130+
expect(
131+
getPublicErrorMessage({ status: 403, message: 'team is blocked' })
132+
).toBe('Team suspended.')
133+
})
134+
135+
it('falls through to generic forbidden when message is missing', () => {
136+
expect(getPublicErrorMessage({ status: 403 })).toBe(
137+
PUBLIC_ERROR_MESSAGE_FORBIDDEN
138+
)
139+
expect(getPublicErrorMessage({ code: 'forbidden' })).toBe(
140+
PUBLIC_ERROR_MESSAGE_FORBIDDEN
141+
)
142+
})
143+
144+
it('falls through to generic forbidden for non-blocked 403 messages', () => {
145+
expect(
146+
getPublicErrorMessage({ status: 403, message: 'something else' })
147+
).toBe(PUBLIC_ERROR_MESSAGE_FORBIDDEN)
148+
})
149+
150+
it('does not translate when status is not 403', () => {
151+
expect(
152+
getPublicErrorMessage({
153+
status: 500,
154+
message: 'team is blocked: billing limit',
155+
})
156+
).toBe(PUBLIC_ERROR_MESSAGE_INTERNAL)
157+
})
158+
})
159+
160+
describe('getPublicRepoErrorMessage', () => {
161+
it('forwards message so team-blocked translation fires', () => {
162+
expect(
163+
getPublicRepoErrorMessage({
164+
code: 'forbidden',
165+
status: 403,
166+
message: 'team is blocked: billing limit',
167+
})
168+
).toBe('Billing limit reached.')
169+
})
170+
171+
it('obfuscates non-blocked 403s', () => {
172+
expect(
173+
getPublicRepoErrorMessage({
174+
code: 'forbidden',
175+
status: 403,
176+
message: 'unauthorized for this endpoint',
177+
})
178+
).toBe(PUBLIC_ERROR_MESSAGE_FORBIDDEN)
179+
})
180+
181+
it('returns raw message for not_found / validation / conflict', () => {
182+
expect(
183+
getPublicRepoErrorMessage({
184+
code: 'not_found',
185+
status: 404,
186+
message: 'team not found',
187+
})
188+
).toBe('team not found')
189+
expect(
190+
getPublicRepoErrorMessage({
191+
code: 'validation',
192+
status: 400,
193+
message: 'invalid input: foo',
194+
})
195+
).toBe('invalid input: foo')
196+
expect(
197+
getPublicRepoErrorMessage({
198+
code: 'conflict',
199+
status: 409,
200+
message: 'team slug taken',
201+
})
202+
).toBe('team slug taken')
203+
})
204+
})

tests/unit/team-blocked-message.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from 'vitest'
2-
import { getBlockedMessage } from '@/features/dashboard/team-blocked/team-blocked-message'
2+
import {
3+
getBlockedMessage,
4+
getBlockedReasonText,
5+
} from '@/features/dashboard/team-blocked/team-blocked-message'
36

47
describe('getBlockedMessage', () => {
58
it('links billing limit blocks to limits', () => {
@@ -34,3 +37,34 @@ describe('getBlockedMessage', () => {
3437
})
3538
})
3639
})
40+
41+
describe('getBlockedReasonText', () => {
42+
it('returns the friendly text for billing limit', () => {
43+
expect(getBlockedReasonText('billing limit')).toBe('Billing limit reached.')
44+
expect(getBlockedReasonText('Billing limit reached')).toBe(
45+
'Billing limit reached.'
46+
)
47+
})
48+
49+
it('returns the friendly text for missing payment method', () => {
50+
expect(getBlockedReasonText('missing payment method')).toBe(
51+
'Missing payment method.'
52+
)
53+
})
54+
55+
it('returns the friendly text for verification required', () => {
56+
expect(getBlockedReasonText('verification required')).toBe(
57+
'Verification required.'
58+
)
59+
})
60+
61+
it('returns the raw reason for support-set messages', () => {
62+
expect(getBlockedReasonText('blocked by support')).toBe(
63+
'blocked by support'
64+
)
65+
})
66+
67+
it('returns a generic fallback for null reason', () => {
68+
expect(getBlockedReasonText(null)).toBe('Team suspended.')
69+
})
70+
})

0 commit comments

Comments
 (0)