feat(manage-ui): improve OAuth credentials CRUD with multi-app support#2708
feat(manage-ui): improve OAuth credentials CRUD with multi-app support#2708omar-inkeep merged 4 commits intomainfrom
Conversation
…t and integration visibility Support multiple OAuth app configurations per provider per tenant with full CRUD operations (create, update, delete). Surface linked Nango integration details (masked client ID/secret) on the credential edit page so users can distinguish credentials backed by different app configurations. Replace raw "nango" credential type labels with human-friendly auth mode names (e.g., "OAuth 2.0", "API Key"). Made-with: Cursor
🦋 Changeset detectedLatest commit: ffa1a02 The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Error agent completed without reporting progress |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
Made-with: Cursor
There was a problem hiding this comment.
PR Review Summary
(8) Total Issues | Risk: Medium
🟠⚠️ Major (4) 🟠⚠️
🟠 1) [providerId]/page.tsx Unsafe as any type assertions bypass credential type validation
files:
agents-manage-ui/src/app/[tenantId]/projects/[projectId]/credentials/new/providers/[providerId]/page.tsx:142-146agents-manage-ui/src/app/[tenantId]/projects/[projectId]/credentials/new/providers/[providerId]/page.tsx:201-207
Issue: Both startConnectFlow (line 144) and handleUpdateCredentials (line 205) cast credentials to any before passing to Nango API functions. The ApiPublicIntegrationCredentials type from @nangohq/types is a discriminated union requiring a valid type discriminator, but the assertions skip validation that provider.auth_mode is actually a valid discriminator value.
Why: If provider.auth_mode contains an unexpected value (e.g., a new auth mode added by Nango), the credentials object will have an invalid type field. This could cause runtime errors in the Nango API or silent failures where credentials are not properly set. For update operations, this could corrupt existing integration configurations. The as any assertion silences TypeScript's type narrowing that would otherwise catch mismatches at compile time.
Fix: Create a shared validation helper that validates the auth mode before casting:
function buildCredentialsPayload(
credentials: Record<string, any> | undefined,
authMode: string | undefined
): ApiPublicIntegrationCredentials | undefined {
if (!credentials || !authMode) return undefined;
const supportedModes = ['OAUTH2', 'OAUTH1', 'TBA', 'APP', 'OAUTH2_CC', 'API_KEY', 'BASIC'];
if (!supportedModes.includes(authMode)) {
console.warn(`Unsupported auth mode: ${authMode}`);
return undefined;
}
return { ...credentials, type: authMode } as ApiPublicIntegrationCredentials;
}Then use it in both handlers with proper error handling if validation fails.
Refs:
🟠 2) [providerId]/page.tsx Race condition in delete confirmation dialog
file: agents-manage-ui/src/app/[tenantId]/projects/[projectId]/credentials/new/providers/[providerId]/page.tsx:438-440
Issue: The AlertDialogAction's onClick handler calls handleDeleteIntegration(deleteTarget) and then immediately sets setDeleteTarget(null), closing the dialog before the async delete operation completes.
Why: Users won't see the loading state or any error if the delete fails. The dialog dismisses optimistically, and the delete operation runs fire-and-forget from the user's perspective. If deletion fails, the user sees no feedback and may think it succeeded.
Fix: Make the onClick handler async and await the delete operation:
onClick={async () => {
if (deleteTarget) {
await handleDeleteIntegration(deleteTarget);
}
setDeleteTarget(null);
}}Refs:
🟠 3) [providerId]/page.tsx Raw unique_key displayed as primary card label instead of human-readable name
file: agents-manage-ui/src/app/[tenantId]/projects/[projectId]/credentials/new/providers/[providerId]/page.tsx:371
Issue: The interstitial card displays the raw unique_key (e.g., google-tenant123-a1b2c3) as the primary label. This is an internal implementation detail that provides no semantic meaning to users managing multiple OAuth apps for the same provider.
Why: The display_name field is available in NangoIntegrationWithMaskedCredentials but not used. Users see opaque IDs instead of meaningful identifiers, making it difficult to distinguish between multiple app configurations. This hurts the mental model and increases cognitive load.
Fix: Display a human-readable name: use display_name if set, otherwise derive a friendlier label (e.g., show the provider name with a creation timestamp or sequential number). Keep the unique_key visible as secondary metadata if needed for advanced users.
<span className="text-sm font-medium truncate block">
{integration.display_name || integration.provider}
</span>
<span className="text-xs text-muted-foreground truncate block">
{integration.unique_key}
</span>Refs:
- edit-credential-form.tsx:67 —
NangoIntegrationWithMaskedCredentialstype includesdisplay_name
🟠 4) multi-file Mixed terminology creates concept confusion: "integration" vs "app" vs "credentials"
scope: UI copy across multiple files
Issue: The PR introduces inconsistent terminology for the same concept:
- Delete dialog: "Delete integration" (
page.tsx:427) - Warning alert: "using this integration" (
generic-auth-form.tsx:198) - Button: "Configure new app" (
page.tsx:421) - Section header: "App configuration" (
edit-credential-form.tsx:226)
The existing docs and UI consistently use "credential" throughout.
Why: This vocabulary split increases cognitive load and confuses users about whether "integration", "app configuration", and "credential" are distinct concepts or the same thing. Users may not understand that deleting an "integration" affects their "credentials".
Fix: Align terminology across all new UI surfaces:
- Use "OAuth app" or "app configuration" consistently for the provider-level Client ID/Secret configuration
- Use "credential" or "connection" for the user-specific token
- Update the delete dialog title to "Delete OAuth app" instead of "Delete integration"
- Update warning text to be consistent with the chosen terminology
Refs:
- Existing credentials docs — uses "credential" consistently
Inline Comments:
- 🟠 Major:
nango.ts:243Missing URL encoding for path parameter - 🟠 Major:
[providerId]/page.tsx:187-190Empty catch block silently swallows list refresh errors - 🟠 Major:
[providerId]/page.tsx:387-391Icon-only button missing accessible name
🟡 Minor (3) 🟡
🟡 1) nango.ts:75-118 Test coverage gap for credential masking functions
Issue: The maskSecret() and buildMaskedCredentials() functions lack unit tests. These functions handle security-sensitive credential masking with specific boundary logic.
Why: If masking logic regresses, sensitive credentials could be exposed in the UI. The boundary condition at value.length <= visibleChars is error-prone. The type-specific branching (OAuth vs APP) in buildMaskedCredentials could regress independently.
Fix: Add unit tests covering:
maskSecret: null/undefined/empty inputs, short values (≤ visibleChars), long values, 12-bullet capbuildMaskedCredentials: null credentials, OAuth type requirements, APP type requirements, unknown type fallback
Refs:
- Test pattern:
agents-manage-ui/src/lib/mcp-tools/__tests__/(create if not exists)
🟡 2) [providerId]/page.tsx:366-368 Disabled integration cards lack explanation
Issue: Clicking an integration card with areCredentialsSet: false is silently disabled with disabled={loading || !integration.areCredentialsSet}. The user sees a dimmed card but has no indication why it cannot be selected.
Why: Users don't understand what action is needed to fix the disabled state. They may assume the card is broken rather than incomplete.
Fix: Add a visible indicator or tooltip explaining why the card is disabled (e.g., "Credentials not configured — click Edit to set up"). Alternatively, auto-open the edit flow for incomplete integrations when clicked.
Refs:
Inline Comments:
- 🟡 Minor:
[providerId]/page.tsx:392DropdownMenuContent missing standard styling classes - 🟡 Minor:
edit-credential-form.tsx:90-92Unsafe type assertion onauthMode
💭 Consider (1) 💭
💭 1) nango.ts:54-62 MaskedCredentials interface allows illegal field combinations
Issue: The MaskedCredentials interface uses optional fields for properties that are mutually exclusive based on credential type. OAuth credentials have client_id/client_secret, while APP credentials have app_id/app_link. The current type allows mixing both field sets.
Why: Permits constructing objects like { type: 'OAUTH2', app_id: '123' } which is semantically invalid.
Fix: Consider a discriminated union if stronger type safety is desired:
type MaskedCredentials =
| { type: 'OAUTH2' | 'OAUTH1' | 'TBA'; client_id?: string; client_secret?: string; scopes?: string | null }
| { type: 'APP'; app_id?: string; app_link?: string; has_private_key?: boolean }
| { type: string }; // fallbackTrade-off: More verbose access patterns but compile-time safety.
Refs:
🚫 REQUEST CHANGES
Summary: This is a well-structured feature PR that adds valuable multi-app OAuth support. The main concerns are: (1) type safety issues with as any assertions that could cause runtime failures with new Nango auth modes, (2) a race condition in the delete confirmation flow, and (3) UX issues around terminology consistency and card labeling. The accessibility gap on the menu button is straightforward to fix. After addressing the Major items, this will be ready to merge.
Discarded (8)
| Location | Issue | Reason Discarded |
|---|---|---|
nango.ts:97 |
Client ID exposed unmasked while client_secret is masked | Client IDs are designed to be semi-public (used in OAuth redirects, etc.) — this is intentional design, not a bug |
[providerId]/page.tsx:212-215 |
Empty catch after update | Duplicate of inline comment at 187-190 — same pattern, same fix |
[providerId]/page.tsx:75-84 |
Initial load falls back to empty array | Acceptable graceful degradation — server-side logging exists, and showing create form for empty state is reasonable UX |
[credentialId]/page.tsx:37-41 |
fetchLinkedIntegration swallows errors | Acceptable graceful degradation for optional enhancement — core credential edit still works |
generic-auth-form.tsx decorative icons |
Missing aria-hidden on decorative icons | Very low impact — Radix handles accessible names from text content, icons are supplementary |
[providerId]/page.tsx:419-422 |
Button Plus icon missing aria-hidden | Very low impact — button text provides accessible name |
edit-credential-form.tsx:224-258 |
No edit affordance for app config section | Informational observation — read-only display is intentional to prevent accidental changes |
nango.ts:58-73 |
NANGO_AUTH_MODE_LABELS incomplete coverage | Fallback behavior (showing raw authMode) is acceptable for unknown modes |
Reviewers (7)
| Reviewer | Returned | Main Findings | Consider | While You're Here | Inline Comments | Pending Recs | Discarded |
|---|---|---|---|---|---|---|---|
pr-review-standards |
4 | 0 | 0 | 0 | 2 | 0 | 2 |
pr-review-frontend |
5 | 0 | 0 | 0 | 1 | 0 | 4 |
pr-review-product |
5 | 2 | 0 | 0 | 0 | 0 | 3 |
pr-review-consistency |
7 | 0 | 0 | 0 | 1 | 0 | 6 |
pr-review-tests |
6 | 1 | 0 | 0 | 0 | 0 | 5 |
pr-review-errors |
4 | 0 | 0 | 0 | 1 | 0 | 3 |
pr-review-types |
4 | 1 | 1 | 0 | 1 | 0 | 1 |
| Total | 35 | 4 | 1 | 0 | 6 | 0 | 24 |
Note: High discard rate reflects effective filtering — many findings were duplicate observations across reviewers (e.g., empty catch blocks flagged by both standards and errors reviewers) or positive observations confirming patterns are correct.
| process.env.NANGO_SERVER_URL || | ||
| 'https://api.nango.dev'; | ||
|
|
||
| const response = await fetch(`${host}/integrations/${uniqueKey}`, { |
There was a problem hiding this comment.
🟠 MAJOR: Missing URL encoding for path parameter
Issue: The uniqueKey path parameter is interpolated directly into the URL without encoding.
Why: If uniqueKey contains special characters (e.g., /, ?, #, or URL-reserved characters), this will cause malformed HTTP requests or routing to unintended endpoints. While current key generation uses safe characters, this is a latent bug that could surface if key formats change.
Fix: (1-click apply)
| const response = await fetch(`${host}/integrations/${uniqueKey}`, { | |
| const response = await fetch(`${host}/integrations/${encodeURIComponent(uniqueKey)}`, { |
Refs:
| try { | ||
| const result = await listNangoProviderIntegrations(provider.name, tenantId); | ||
| setIntegrations(result); | ||
| } catch {} |
There was a problem hiding this comment.
🟠 MAJOR: Empty catch block silently swallows list refresh errors
Issue: After startConnectFlow succeeds, the integration list refresh silently discards any errors. The same pattern appears at lines 212-215 after credential updates.
Why: If the refresh fails (network issue, auth problem, Nango outage), the user sees stale data with no indication why. They may think their integration wasn't created, leading to duplicate attempts. Debugging is impossible since no error is logged.
Fix: Add warning-level logging to preserve diagnostic info without disrupting the success flow:
| try { | |
| const result = await listNangoProviderIntegrations(provider.name, tenantId); | |
| setIntegrations(result); | |
| } catch {} | |
| try { | |
| const result = await listNangoProviderIntegrations(provider.name, tenantId); | |
| setIntegrations(result); | |
| } catch (refreshError) { | |
| console.warn('Failed to refresh integrations list after create:', refreshError); | |
| } |
Refs:
- pr-review-errors skill — silent failures guideline
| <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> | ||
| <Button size="icon-sm" variant="ghost" className="shrink-0 ml-4" disabled={loading}> | ||
| <MoreVertical className="h-4 w-4" /> | ||
| </Button> | ||
| </DropdownMenuTrigger> |
There was a problem hiding this comment.
🟠 MAJOR: Icon-only button missing accessible name
Issue: The three-dot menu trigger button has no aria-label to describe its purpose to screen reader users.
Why: Screen reader users hear only "button" with no context. With multiple integration cards rendered via .map(), users cannot distinguish between different menu triggers.
Fix: (1-click apply)
| <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> | |
| <Button size="icon-sm" variant="ghost" className="shrink-0 ml-4" disabled={loading}> | |
| <MoreVertical className="h-4 w-4" /> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> | |
| <Button size="icon-sm" variant="ghost" className="shrink-0 ml-4" disabled={loading} aria-label={`Actions for ${integration.unique_key}`}> | |
| <MoreVertical className="h-4 w-4" /> | |
| </Button> |
Refs:
| <MoreVertical className="h-4 w-4" /> | ||
| </Button> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent align="end"> |
There was a problem hiding this comment.
🟡 Minor: DropdownMenuContent missing standard styling classes
Issue: This dropdown lacks the custom styling classes used consistently by peer components in the credentials UI.
Why: Visual inconsistency with other dropdown menus in the same domain (e.g., credential-item.tsx, mcp-tool-item.tsx).
Fix: (1-click apply)
| <DropdownMenuContent align="end"> | |
| <DropdownMenuContent align="end" className="w-48 shadow-lg border border-border bg-popover/95 backdrop-blur-sm"> |
Refs:
- credential-item.tsx:75-78 — existing pattern
| if (credential.type === CredentialStoreType.nango && credential.retrievalParams?.authMode) { | ||
| const authMode = credential.retrievalParams.authMode as string; | ||
| return NANGO_AUTH_MODE_LABELS[authMode] ?? authMode; |
There was a problem hiding this comment.
🟡 Minor: Unsafe type assertion on authMode
Issue: The authMode value is asserted as string without validation. If retrievalParams.authMode is not a string (e.g., null, undefined, or unexpected type), the fallback ?? authMode would display a non-string value.
Why: Could cause runtime rendering issues if data shape changes or corruption occurs.
Fix: Add type guard:
| if (credential.type === CredentialStoreType.nango && credential.retrievalParams?.authMode) { | |
| const authMode = credential.retrievalParams.authMode as string; | |
| return NANGO_AUTH_MODE_LABELS[authMode] ?? authMode; | |
| if (credential.type === CredentialStoreType.nango && credential.retrievalParams?.authMode) { | |
| const authMode = credential.retrievalParams.authMode; | |
| if (typeof authMode === 'string') { | |
| return NANGO_AUTH_MODE_LABELS[authMode] ?? authMode; | |
| } |
Refs:
- TypeScript type narrowing best practices
There was a problem hiding this comment.
PR Review Summary
(1) Total Issues | Risk: Low
Re-review scoped to delta — Commit
81d1fbd4d("feedback") addresses 6 of 8 issues from the prior review.
✅ Addressed in This Delta
| Prior Finding | Resolution |
|---|---|
🟠 Major #1: Unsafe as any type assertions |
✅ Added buildCredentialsPayload() with proper isCredentialAuthMode() type guard at nango.ts:86-102 |
🟠 Major #3: Raw unique_key as primary label |
✅ Now shows display_name || provider as primary, unique_key as secondary at page.tsx:373-378 |
| 🟠 Major #4: Mixed terminology | ✅ "OAuth app" terminology now used consistently in dialog, toasts, and error messages |
| 🟠 Inline: Missing URL encoding | ✅ encodeURIComponent() added at nango.ts:272 and nango.ts:314 |
| 🟠 Inline: Empty catch blocks | ✅ Added console.error logging at page.tsx:185-186 and page.tsx:215-216 |
| 🟠 Inline: Missing accessible name | ✅ Added aria-label="OAuth app actions" at page.tsx:400 |
🕐 Pending Recommendations (1)
Issues from the prior review that remain unaddressed. The specific code causing these issues was not modified in this delta — they remain valid feedback for a future iteration.
- 🟠
page.tsx:451-453Race condition in delete confirmation dialog —handleDeleteIntegration(deleteTarget)is not awaited beforesetDeleteTarget(null)closes the dialog, preventing users from seeing loading state or error feedback if deletion fails.
💭 Lower Priority (Not Blocking)
These items from the prior review are valid but lower impact — reasonable to address in a follow-up:
- 🟡 Test coverage gap for
maskSecret()andbuildMaskedCredentials()functions - 🟡 Disabled integration cards lack explanation for why they cannot be selected
- 🟡 DropdownMenuContent missing standard styling classes used in peer components
- 💭
MaskedCredentialsinterface could use discriminated union for stronger type safety
💡 APPROVE WITH SUGGESTIONS
Summary: The delta effectively addresses the critical type safety and UX issues from the prior review. The buildCredentialsPayload() helper with proper type guard is a clean solution that eliminates the unsafe as any casts. Terminology is now consistent, accessibility is improved, and error handling is no longer silent. The one remaining Major issue (delete dialog race condition) wasn't touched in this delta and can be addressed in a follow-up. Ship it! 🚀
Reviewers (0)
| Reviewer | Returned | Main Findings | Consider | While You're Here | Inline Comments | Pending Recs | Discarded |
|---|---|---|---|---|---|---|---|
| (delta re-review) | — | 0 | 0 | 0 | 0 | 1 | 0 |
Note: Sub-reviewers not dispatched for this delta re-review since changes were targeted feedback fixes with clear scope.
There was a problem hiding this comment.
PR Review Summary
(0) Total Issues | Risk: Low
Re-review scoped to delta — Commit
ffa1a02d9("feedback") addresses the final pending issue from the prior review.
✅ Addressed in This Delta
| Prior Finding | Resolution |
|---|---|
| 🟠 Delete dialog race condition | ✅ Handler is now async and properly awaits handleDeleteIntegration(deleteTarget) before closing the dialog at page.tsx:451-456 |
Additional Changes
| Change | Analysis |
|---|---|
buildCredentialsPayload now async |
Forward-compatible signature change. Call sites at page.tsx:141 and page.tsx:198 properly await the result. |
📊 Review History Summary
This PR went through 3 review iterations:
- Initial review: 8 issues identified (4 Major, 3 Minor, 1 Consider)
- Delta review #1 (
81d1fbd4d): 6 of 8 issues addressed - Delta review #2 (
ffa1a02d9): Final issue addressed ✅
All originally identified issues have been resolved:
- ✅ Type safety with
buildCredentialsPayload()helper - ✅ Human-readable labels (
display_name || provider) - ✅ Consistent "OAuth app" terminology
- ✅ URL encoding for path parameters
- ✅ Error logging in catch blocks
- ✅ Accessible
aria-labelon menu button - ✅ Delete dialog race condition (this delta)
✅ APPROVE
Summary: All feedback from prior reviews has been addressed. The multi-app OAuth credentials feature is well-implemented with proper type safety, consistent UX terminology, accessibility, and correct async handling. Ship it! 🚀
Reviewers (0)
| Reviewer | Returned | Main Findings | Consider | While You're Here | Inline Comments | Pending Recs | Discarded |
|---|---|---|---|---|---|---|---|
| (delta re-review) | — | 0 | 0 | 0 | 0 | 0 | 0 |
Note: Sub-reviewers not dispatched — delta was a targeted fix for the single remaining pending issue.
The `buildMaskedCredentials` refactor in #2708 removed the catch-all `areCredentialsSet = true` for credential types other than OAUTH/TBA/APP. MCP-generic integrations have no client credentials, so `areCredentialsSet` became `false`, causing `createProviderConnectSession` to skip reuse and attempt to create a duplicate integration — triggering "already exists". Fix: check `existingIntegration` instead of `existingIntegration?.areCredentialsSet`. Made-with: Cursor
…2729) The `buildMaskedCredentials` refactor in #2708 removed the catch-all `areCredentialsSet = true` for credential types other than OAUTH/TBA/APP. MCP-generic integrations have no client credentials, so `areCredentialsSet` became `false`, causing `createProviderConnectSession` to skip reuse and attempt to create a duplicate integration — triggering "already exists". Fix: check `existingIntegration` instead of `existingIntegration?.areCredentialsSet`. Made-with: Cursor
Summary
retrievalParams.authMode(e.g., "OAuth 2.0", "API Key", "App authentication")Changed files
nango.tslistNangoProviderIntegrations,deleteNangoIntegration,updateNangoIntegrationCredentials; refactoredfetchNangoIntegrationto return masked credentials[providerId]/page.tsxgeneric-auth-form.tsxmode(create/update) andonCancelprops; update warning alertedit-credential-form.tsx[credentialId]/page.tsxTest plan
Screenshots here:
Multiple apps configured:

In credentials page:

Made with Cursor