File tree Expand file tree Collapse file tree
Expand file tree Collapse file tree Original file line number Diff line number Diff line change @@ -102,6 +102,18 @@ function enrich403Detail(rawDetail: string | undefined): string {
102102 * @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539
103103 */
104104export function enrich401Detail ( rawDetail : string | undefined ) : string {
105+ // Seat-limit lockout, not an auth failure. Sentry returns 401 with
106+ // `code: member-disabled-over-limit` when the org is over its member limit
107+ // and the caller's seat is disabled — re-authenticating cannot fix this.
108+ if ( rawDetail ?. includes ( "member-disabled-over-limit" ) ) {
109+ return [
110+ "Your account is disabled in this organization because it is over its member limit." ,
111+ "This is a billing/seat-limit issue, not an auth problem — re-authenticating won't help." ,
112+ "Ask an org owner to upgrade the plan or free up a seat, then retry." ,
113+ "Or target a different org, e.g.: sentry init my-other-org/" ,
114+ ] . join ( "\n " ) ;
115+ }
116+
105117 const lines : string [ ] = [ ] ;
106118 if ( rawDetail ) {
107119 lines . push ( rawDetail , "" ) ;
Original file line number Diff line number Diff line change @@ -334,6 +334,12 @@ async function resolveTeam(
334334 if ( error instanceof ApiError && error . status === 403 ) {
335335 return ;
336336 }
337+ // Other ApiErrors (e.g. 401 member-disabled-over-limit) carry enriched,
338+ // actionable detail. Use format() so the guidance reaches the user instead
339+ // of a bare "Failed to list teams" + status line.
340+ if ( error instanceof ApiError ) {
341+ throw new WizardError ( error . format ( ) ) ;
342+ }
337343 throw error instanceof WizardError
338344 ? error
339345 : new WizardError ( error instanceof Error ? error . message : String ( error ) ) ;
@@ -381,6 +387,12 @@ async function listTeamsForImplicitInit(
381387 if ( error instanceof ApiError && error . status === 404 ) {
382388 return await buildOrgNotFoundError ( org , "sentry init" ) ;
383389 }
390+ // Other ApiErrors (e.g. 401 member-disabled-over-limit) carry enriched,
391+ // actionable detail. Use format() so the guidance reaches the user instead
392+ // of a bare "Failed to list teams" + status line.
393+ if ( error instanceof ApiError ) {
394+ throw new WizardError ( error . format ( ) ) ;
395+ }
384396 throw error instanceof WizardError
385397 ? error
386398 : new WizardError ( error instanceof Error ? error . message : String ( error ) ) ;
Original file line number Diff line number Diff line change @@ -119,6 +119,8 @@ export type ResolvedTeam = ResolvedConcreteTeam | DeferredResolvedTeam;
119119 * - 403 → member lacks team:read; re-thrown as `ApiError` so callers that
120120 * implement a member-accessible fallback can detect it and use
121121 * POST /organizations/{org}/projects/ instead.
122+ * - 401 → re-thrown as `ApiError` so the enriched detail (expired session,
123+ * member-disabled-over-limit, etc.) survives instead of being flattened.
122124 * - other → generic ResolutionError (5xx, network, etc.)
123125 */
124126async function handleListTeamsError (
@@ -137,6 +139,9 @@ async function handleListTeamsError(
137139 if ( error . status === 403 ) {
138140 throw error ;
139141 }
142+ if ( error . status === 401 ) {
143+ throw error ;
144+ }
140145 throw new ResolutionError (
141146 `Organization '${ orgSlug } '` ,
142147 `could not be accessed (${ error . status } )` ,
Original file line number Diff line number Diff line change @@ -445,6 +445,39 @@ describe("throwApiError", () => {
445445 }
446446 } ) ;
447447
448+ test ( "treats member-disabled-over-limit as a seat-limit issue, not auth" , ( ) => {
449+ const mockResponse = new Response ( "" , {
450+ status : 401 ,
451+ statusText : "Unauthorized" ,
452+ } ) ;
453+
454+ let captured : ApiError | undefined ;
455+ try {
456+ throwApiError (
457+ {
458+ detail : {
459+ code : "member-disabled-over-limit" ,
460+ message : "Organization over member limit" ,
461+ extra : { next : "/organizations/chisme/disabled-member/" } ,
462+ } ,
463+ } ,
464+ mockResponse ,
465+ "Failed to list teams"
466+ ) ;
467+ } catch ( error ) {
468+ captured = error as ApiError ;
469+ }
470+
471+ expect ( captured ) . toBeDefined ( ) ;
472+ expect ( captured ?. status ) . toBe ( 401 ) ;
473+ expect ( captured ?. detail ) . toContain ( "over its member limit" ) ;
474+ expect ( captured ?. detail ) . toContain ( "billing/seat-limit" ) ;
475+ // The fix must NOT give the misleading re-auth advice for this case.
476+ expect ( captured ?. detail ) . not . toContain ( "sentry auth login" ) ;
477+ expect ( captured ?. detail ) . not . toContain ( "session has expired" ) ;
478+ expect ( captured ?. detail ) . not . toContain ( "SENTRY_AUTH_TOKEN" ) ;
479+ } ) ;
480+
448481 describe ( "with OAuth token (no env var)" , ( ) => {
449482 let savedAuthToken : string | undefined ;
450483 let savedToken : string | undefined ;
Original file line number Diff line number Diff line change @@ -583,6 +583,27 @@ describe("resolveInitContext", () => {
583583 expect ( errorCall ?. message ) . toContain ( "beta" ) ;
584584 } ) ;
585585
586+ test ( "surfaces the enriched detail when implicit listTeams returns 401" , async ( ) => {
587+ // member-disabled-over-limit: a 401 from listTeams must reach the user with
588+ // its actionable detail, not a bare "Failed to list teams" + status line.
589+ listTeamsSpy . mockRejectedValueOnce (
590+ new ApiError (
591+ "Failed to list teams" ,
592+ 401 ,
593+ "Your account is disabled in this organization because it is over its member limit."
594+ )
595+ ) ;
596+
597+ const { ui, calls } = createMockUI ( ) ;
598+ await expect ( resolveInitContext ( makeOptions ( ) , ui ) ) . rejects . toThrow ( ) ;
599+
600+ const errorCall = calls . find (
601+ ( c ) : c is Extract < MockCall , { kind : "log.error" } > =>
602+ c . kind === "log.error"
603+ ) ;
604+ expect ( errorCall ?. message ) . toContain ( "over its member limit" ) ;
605+ } ) ;
606+
586607 test ( "fails early when listTeams is forbidden and member project creation is disabled" , async ( ) => {
587608 listTeamsSpy . mockRejectedValueOnce (
588609 new ApiError ( "Forbidden" , 403 , "No team:read access" )
You can’t perform that action at this time.
0 commit comments