Skip to content

Distinguish 403 Forbidden from generic errors in UI#429

Merged
jhodapp merged 9 commits into
mainfrom
feat/split-403-forbidden-handling
Jul 10, 2026
Merged

Distinguish 403 Forbidden from generic errors in UI#429
jhodapp merged 9 commits into
mainfrom
feat/split-403-forbidden-handling

Conversation

@rnambaale

@rnambaale rnambaale commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Description

The UI previously collapsed all backend failures into generic error messages, so a 403 Forbidden (user is authenticated but lacks permission) was indistinguishable from a network outage or a 5xx. This change adds a first-class 403 distinction so users see a clear "you don't have permission" message instead of a misleading generic error.

GitHub Issue: Closes #refactor-group/refactor-platform-rs#358

Changes

  • Added isForbidden() (403 check) to EntityApiError, plus a shared PERMISSION_DENIED_MESSAGE constant and an isForbiddenError(error: unknown) type guard in src/types/entity-api-error.ts, re-exported through @/types/general and @/lib/api/entity-api.
  • Page-level 403s now render the ForbiddenError component (Actions page, Members page) instead of a generic error block.
  • Mutation/action 403s now surface the permission message via toast across the actions board, members (add/resend/assign/profile/password), dashboard (session form, reschedule series, goals overview card), coaching session title, relationship selector, and coaching preferences.
  • Refactored the actions container's six duplicated network-error branches into a single mutationErrorMessage() helper.
  • Added unit tests for the page-level 403 render and the mutation-level 403 toast.

Screenshots / Videos Showing UI Changes (if applicable)

Helpful captures:

  • Actions page showing the "Actions Access Denied" ForbiddenError screen on a 403 data fetch.
  • Members page showing the "Members Access Denied" ForbiddenError screen.
  • A permission-denied toast ("You don't have permission to perform this action.") after a forbidden mutation (e.g. deleting an action).

Testing Strategy

  • npx vitest run __tests__/components/ui/actions/actions-page-container.test.tsx — all 18 pass, including the two new 403 cases.
  • npx tsc --noEmit — clean.
  • Manual: as a user without permission, trigger a forbidden read (Actions/Members page → access-denied screen) and a forbidden write (e.g. delete/update an action → permission toast); confirm network/validation/5xx errors still show their existing generic messages.

Concerns

The backend PR that introduces the 401/403 split is refactor-group/refactor-platform-rs#367

@rnambaale rnambaale self-assigned this Jun 27, 2026
@rnambaale rnambaale added the enhancement Improves existing functionality or feature label Jun 27, 2026
@greptile-apps

greptile-apps Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces first-class 403 Forbidden handling across the frontend: a shared isForbiddenError() type guard, PERMISSION_DENIED_MESSAGE constant, and viewPermissionDeniedMessage() helper are added to EntityApiError, then wired into page-level renders (Actions, Members) and mutation catch-blocks across a dozen components.

  • Core types (entity-api-error.ts, general.ts, entity-api.ts): isForbidden() method and three convenience exports added cleanly with good JSDoc; the isForbiddenError type predicate correctly narrows to EntityApiError.
  • Page-level 403: Actions and Members pages short-circuit to a ForbiddenError component instead of the generic error block; ordering of the 403 guard before the generic isError check is correct.
  • Mutation-level 403: mutationErrorMessage() helper in the actions container eliminates six duplicated error branches and inserts the 403 path in one place; all other mutation sites use toast.error(PERMISSION_DENIED_MESSAGE) consistently, with the notable exception of member-profile-container.tsx which keeps the pre-existing toast() (neutral) call, making the permission message visually indistinguishable from a success notification.

Confidence Score: 5/5

Safe to merge; the 403 distinction works correctly across all major surfaces and the new type guard is correctly implemented

The core implementation — isForbidden(), isForbiddenError, and the two page-level ForbiddenError renders — is logically correct and well-tested. The two inconsistencies (neutral toast() in member-profile-container.tsx, permission text in a toast description in coaching-session-title.tsx) are pre-existing style patterns that the PR doesn't make meaningfully worse in terms of correctness.

src/components/ui/members/member-profile-container.tsx and src/components/ui/coaching-sessions/coaching-session-title.tsx have minor UX inconsistencies in how the permission message is presented relative to the rest of the PR

Important Files Changed

Filename Overview
src/types/entity-api-error.ts Added isForbidden(), PERMISSION_DENIED_MESSAGE, viewPermissionDeniedMessage, and isForbiddenError type guard — clean, well-documented additions to the existing error class
src/components/ui/actions/actions-page-container.tsx Adds mutationErrorMessage() helper eliminating six duplicated network-error branches, plus page-level ForbiddenError render on 403 — well-executed refactor
src/components/ui/members/member-profile-container.tsx Adds 403 check to profile/password error handlers but inherits the pre-existing toast() (neutral) variant instead of toast.error(), making the permission message visually indistinguishable from success notifications — unlike every other 403 case in this PR
src/components/ui/coaching-sessions/coaching-session-title.tsx Places PERMISSION_DENIED_MESSAGE in the toast description rather than as the primary message, giving a misleading "Failed to save title" headline on 403 — inconsistent with how other surfaces in this PR present the same message
src/app/organizations/[id]/members/page.tsx Adds ForbiddenError page-level render when either the relationships or users list fetch returns 403 — correct guard placement, tests added
src/components/ui/dashboard/coaching-session-form.tsx Adds 403 branch inside existing error chain, after 422/502 checks — ordering is correct and logically sound
src/components/ui/members/add-member-dialog.tsx Uses the nullish-coalescing chain with organizationArchivedMessage taking priority, then 403, then generic — correct priority ordering
src/components/ui/members/member-card.tsx Consistent 403 handling for resend-invite and assign-coach paths using toast.error(); tests added for both branches

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[API Request] --> B{Response}
    B -->|200 OK| C[Render Data]
    B -->|403 Forbidden| D{Request Type}
    B -->|Other Error| E[Generic Error Path]

    D -->|Page-level fetch| F[ForbiddenError Component]
    F --> G["Actions: 'Actions Access Denied'"]
    F --> H["Members: 'Members Access Denied'"]

    D -->|Mutation| I[toast.error - PERMISSION_DENIED_MESSAGE]
    I --> J[actions mutationErrorMessage]
    I --> K[add-member-dialog]
    I --> L[member-card resend/assign]
    I --> M[coaching-session-form]
    I --> N[reschedule-series-dialog]
    I --> O[coaching-preferences-section]
    I --> P["member-profile-container: uses toast() not toast.error()"]

    E -->|Page-level| Q[Generic error block]
    E -->|Mutation| R[Generic error message]

    subgraph guard["isForbiddenError guard"]
        S["error instanceof EntityApiError AND error.status === 403"]
    end

    D -.->|checked via| S
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[API Request] --> B{Response}
    B -->|200 OK| C[Render Data]
    B -->|403 Forbidden| D{Request Type}
    B -->|Other Error| E[Generic Error Path]

    D -->|Page-level fetch| F[ForbiddenError Component]
    F --> G["Actions: 'Actions Access Denied'"]
    F --> H["Members: 'Members Access Denied'"]

    D -->|Mutation| I[toast.error - PERMISSION_DENIED_MESSAGE]
    I --> J[actions mutationErrorMessage]
    I --> K[add-member-dialog]
    I --> L[member-card resend/assign]
    I --> M[coaching-session-form]
    I --> N[reschedule-series-dialog]
    I --> O[coaching-preferences-section]
    I --> P["member-profile-container: uses toast() not toast.error()"]

    E -->|Page-level| Q[Generic error block]
    E -->|Mutation| R[Generic error message]

    subgraph guard["isForbiddenError guard"]
        S["error instanceof EntityApiError AND error.status === 403"]
    end

    D -.->|checked via| S
Loading

Reviews (5): Last reviewed commit: "switch `error.isForbidden()` for `isForb..." | Re-trigger Greptile

Comment thread src/types/entity-api-error.ts Outdated
@rnambaale rnambaale added this to the 1.0.0-beta3 milestone Jun 27, 2026
@rnambaale rnambaale requested a review from jhodapp June 27, 2026 15:57
@jhodapp jhodapp changed the title feat: distinguish 403 Forbidden from generic errors in UI Distinguish 403 Forbidden from generic errors in UI Jun 30, 2026

@jhodapp jhodapp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this Raymond. A few inline changes plus one general one here.

The PR description mentions page-level and mutation-level 403 tests, but only actions-page-container.test.tsx is updated. A few 403 paths added here are untested:

  • Members page page-level ForbiddenError render (src/app/organizations/[id]/members/page.tsx)
  • Mutation toast sites: members add/resend/assign, dashboard session form, reschedule series, goals overview card, coaching preferences

At minimum, a Members-page render test mirroring the new Actions one would lock in the second page-level path.

Comment thread src/components/ui/dashboard/reschedule-series-dialog.tsx Outdated
} else {
let message: string;
if (isDurationValidationError(error)) {
if (error.isForbidden()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error.isForbidden() could read as isForbiddenError(error) for consistency with the other call sites.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice suggestion.

isForbiddenError's type predicate narrows the else branches to never here since error is already EntityApiError; it breaks the sibling error.status / error.isNetworkError() checks.

Happy to iterate further on this one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rnambaale so you're saying you can't use my suggested shape instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have switched to isForbiddenError(error) here dfb6a25.

Comment thread src/components/ui/actions/actions-page-container.tsx
@rnambaale

Copy link
Copy Markdown
Collaborator Author

I have add more tests for the 403 path

99bdfc9...aeb9188

@rnambaale rnambaale requested a review from jhodapp July 3, 2026 10:14

@jhodapp jhodapp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Raymond.

@jhodapp jhodapp merged commit 57933e8 into main Jul 10, 2026
8 checks passed
@jhodapp jhodapp deleted the feat/split-403-forbidden-handling branch July 10, 2026 16:22
@github-project-automation github-project-automation Bot moved this from Review to ✅ Done in Refactor Coaching Platform Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improves existing functionality or feature

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

2 participants