Skip to content

Conversation

@UMDKyle
Copy link

@UMDKyle UMDKyle commented Nov 13, 2025

Description

Summary

This PR adds a user-facing option to delete or retain API keys/credentials when uninstalling a plugin, directly solving the issue where credentials persist after plugin deletion.

Problem (Issue #27531)

When users deleted a plugin, the associated API keys and credentials remained in the database without any notification or option to manage them. As reported in #27531:

"I deleted the OpenAI plugin, and after reinstalling it, the original API key was automatically restored. I want to clear it."

This caused:

  • ❌ Credentials silently retained after plugin deletion
  • ❌ No notification to users about credential status
  • ❌ No way to clean up unused credentials
  • ❌ Confusion when credentials auto-restored on reinstall

Solution

Implemented a two-step deletion process with user choice:

  1. Check credentials: Before showing the delete confirmation, check if the plugin has associated credentials
  2. User choice: If credentials exist, show radio button options:
    • Delete these API keys (default, recommended) - Solves the issue completely
    • Keep these API keys (for future reinstallation) - Preserves current behavior if desired
  3. Execute: Delete the plugin and handle credentials according to user choice

Users can now:

Changes

Backend Changes

New Service: PluginCredentialService

  • get_plugin_credentials(): Retrieves all credentials for a plugin (both ProviderCredential and ProviderModelCredential)
  • delete_plugin_credentials(): Deletes specific credentials by ID
  • delete_all_plugin_credentials(): Deletes all credentials for a plugin

Updated Service: PluginService

  • Added check_plugin_credentials(): Check credentials before uninstall
  • Modified uninstall(): Added delete_credentials and credential_ids parameters

API Endpoints:

  • New: GET /workspaces/current/plugin/uninstall/check-credentials
  • Updated: POST /workspaces/current/plugin/uninstall with credential options

Frontend Changes

Components:

  • Updated plugin-item/action.tsx with credential checking and deletion UI
  • Updated plugin-detail-panel/detail-header.tsx with same functionality

Service Layer:

  • Added checkPluginCredentials() function
  • Updated uninstallPlugin() with credential parameters

UI:

  • Radio button interface for delete/keep choice
  • Credential list display
  • Default to "delete" (safer option)

Internationalization:

  • Added en-US translations for credential management

Tests

Comprehensive test coverage with 41 tests:

Backend Tests (26 tests):

  • ✅ 8 tests for PluginCredentialService
  • ✅ 7 tests for PluginService.uninstall()
  • ✅ 11 tests for API controller endpoints

Frontend Tests (15 tests):

  • ✅ Service function tests
  • ✅ State management tests
  • ✅ Integration flow tests
  • ✅ Edge case handling

Test Documentation:

  • api/tests/unit_tests/services/plugin/README.md - Backend test guide
  • web/__tests__/README.plugin-delete-tests.md - Frontend test guide

How This Solves Issue #27531

Before: API keys persisted after deletion with no way to clear them
before

After: Users explicitly choose what happens to credentials (only if credential exist)
after

The issue reporter can now:

  1. Delete the OpenAI plugin
  2. See the dialog showing their API key
  3. Select "Delete these API keys"
  4. Confirm deletion
  5. ✅ API key is completely cleared from the database

Database Queries

The implementation queries two tables with plugin_id/% pattern:

  • provider_credential (provider-level credentials)
  • provider_model_credential (model-specific credentials)

Both credential types are shown and can be deleted.

Testing

All 41 tests passing:

# Backend tests (26 tests)
cd dify/docker
docker compose exec api python -m pytest tests/unit_tests/services/plugin/ -v

# Frontend tests (15 tests)
cd dify/web
pnpm test __tests__/plugin-delete-credential-options.test.tsx

Type Safety

Fixed Python 3.9 compatibility:

  • Changed list[str] | NoneOptional[Sequence[str]]

Backward Compatibility

Fully backward compatible

  • Default behavior: Keep credentials (safe, preserves existing behavior)
  • New parameters are optional
  • Existing API calls work without changes
  • No database migrations required

Security

  • Credentials only visible to users with plugin management permissions
  • Deletion scoped to tenant_id (no cross-tenant access)
  • Credentials not exposed in frontend (only IDs and names)
  • Follows existing authentication/authorization patterns

Performance

  • Minimal impact: One additional query to check credentials
  • Query is fast (indexed on tenant_id and provider)
  • No impact if plugin has no credentials

Files Changed

New Files (5):

  • api/services/plugin/plugin_credential_service.py
  • api/tests/unit_tests/services/plugin/test_plugin_credential_service.py
  • api/tests/unit_tests/services/plugin/test_plugin_service_uninstall.py
  • api/tests/unit_tests/controllers/console/workspace/test_plugin_uninstall_api.py
  • web/__tests__/plugin-delete-credential-options.test.tsx

Modified Files (6):

  • api/services/plugin/plugin_service.py
  • api/controllers/console/workspace/plugin.py
  • web/app/components/plugins/plugin-item/action.tsx
  • web/app/components/plugins/plugin-detail-panel/detail-header.tsx
  • web/service/plugins.ts
  • web/i18n/en-US/plugin.ts

Documentation (2):

  • api/tests/unit_tests/services/plugin/README.md
  • web/__tests__/README.plugin-delete-tests.md

Total: 15 files, 2161+ insertions, 23 deletions

Checklist

  • Code follows Dify coding standards
  • Tests added and passing (41 tests)
  • Documentation added
  • No linting errors
  • Backward compatible
  • Type safe (Python 3.9+ compatible)
  • Manually tested in Docker environment
  • Internationalization added (en-US)
  • Security reviewed
  • Performance impact assessed
  • Linked to issue

Related Issue

Fixes #27531


@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. 💪 enhancement New feature or request labels Nov 13, 2025
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @UMDKyle, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the plugin uninstallation experience by providing users with explicit control over the lifecycle of their API keys and credentials. Previously, these sensitive details would remain in the database even after a plugin was removed, leading to potential security concerns and user confusion upon reinstallation. The new implementation introduces a clear, user-friendly mechanism to either securely delete these credentials or intentionally retain them, directly resolving a critical user feedback and improving data hygiene.

Highlights

  • User Control over API Keys: Introduces a user-facing option to either delete or retain associated API keys and credentials when uninstalling a plugin, addressing a long-standing issue where credentials would persist silently.
  • Two-Step Deletion Process: Implements a new workflow where, upon initiating plugin uninstallation, the system first checks for associated credentials and then presents the user with clear choices: delete all credentials (default) or keep them for future reinstallation.
  • New Backend Service for Credential Management: A dedicated PluginCredentialService has been added to handle the retrieval, specific deletion, and bulk deletion of plugin-related credentials from the database.
  • Updated API Endpoints and Frontend UI: New API endpoints facilitate credential checking, and existing uninstall endpoints now accept parameters for credential deletion. The frontend UI has been updated with radio buttons and a list display of credentials to enable user choice.
  • Comprehensive Test Coverage: The feature is backed by 41 new tests, covering both backend logic (26 tests for services and API controllers) and frontend components (15 tests for service functions, state management, and integration flows).
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a valuable feature for managing plugin credentials during uninstallation. The backend and frontend changes are comprehensive and well-structured. However, I've identified several critical issues in the backend tests that need immediate attention, as they are not correctly testing the implementation and could lead to a false sense of security. There are also significant performance inefficiencies in the database operations for deleting credentials, which should be refactored to use bulk operations for better performance. On the frontend, there's an opportunity to improve maintainability by reducing code duplication with a custom hook, and there are some UI and internationalization inconsistencies that should be addressed.

Comment on lines +91 to +131
def delete_plugin_credentials(tenant_id: str, credential_ids: Sequence[str]) -> int:
"""
Delete plugin credentials by IDs.
Args:
tenant_id: Tenant ID
credential_ids: List of credential IDs to delete
Returns:
Number of credentials deleted
"""
logger.info(f"Deleting credentials: {credential_ids} for tenant: {tenant_id}")
deleted_count = 0

for credential_id in credential_ids:
# Try deleting from provider_credentials
provider_cred = db.session.scalars(
select(ProviderCredential).where(
ProviderCredential.id == credential_id, ProviderCredential.tenant_id == tenant_id
)
).first()

if provider_cred:
db.session.delete(provider_cred)
deleted_count += 1
continue

# Try deleting from provider_model_credentials
model_cred = db.session.scalars(
select(ProviderModelCredential).where(
ProviderModelCredential.id == credential_id, ProviderModelCredential.tenant_id == tenant_id
)
).first()

if model_cred:
db.session.delete(model_cred)
deleted_count += 1

db.session.commit()
logger.info(f"Deleted {deleted_count} plugin credentials for tenant {tenant_id}")
return deleted_count
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The current implementation of delete_plugin_credentials is inefficient. It iterates through each credential_id and performs up to two SELECT queries inside the loop to find the credential before deleting it. This leads to an N+1 query problem, which can cause performance issues when deleting many credentials at once.

    def delete_plugin_credentials(tenant_id: str, credential_ids: Sequence[str]) -> int:
        """
        Delete plugin credentials by IDs.

        Args:
            tenant_id: Tenant ID
            credential_ids: List of credential IDs to delete

        Returns:
            Number of credentials deleted
        """
        if not credential_ids:
            return 0

        logger.info(f"Deleting credentials: {credential_ids} for tenant: {tenant_id}")

        # Bulk delete from ProviderCredential
        provider_creds_deleted = db.session.query(ProviderCredential).filter(
            ProviderCredential.tenant_id == tenant_id,
            ProviderCredential.id.in_(credential_ids)
        ).delete(synchronize_session=False)

        # Bulk delete from ProviderModelCredential
        model_creds_deleted = db.session.query(ProviderModelCredential).filter(
            ProviderModelCredential.tenant_id == tenant_id,
            ProviderModelCredential.id.in_(credential_ids)
        ).delete(synchronize_session=False)

        deleted_count = provider_creds_deleted + model_creds_deleted

        db.session.commit()
        logger.info(f"Deleted {deleted_count} plugin credentials for tenant {tenant_id}")
        return deleted_count

Comment on lines +134 to +147
def delete_all_plugin_credentials(tenant_id: str, plugin_id: str) -> int:
"""
Delete all credentials associated with a plugin.
Args:
tenant_id: Tenant ID
plugin_id: Plugin ID in format "organization/plugin_name"
Returns:
Number of credentials deleted
"""
credentials = PluginCredentialService.get_plugin_credentials(tenant_id, plugin_id)
credential_ids = [cred.credential_id for cred in credentials]
return PluginCredentialService.delete_plugin_credentials(tenant_id, credential_ids)
Copy link
Contributor

Choose a reason for hiding this comment

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

high

This method is inefficient. It first fetches all credential objects and their IDs, and then calls delete_plugin_credentials, which in its current form loops through them one by one. This results in multiple database roundtrips.

    def delete_all_plugin_credentials(tenant_id: str, plugin_id: str) -> int:
        """
        Delete all credentials associated with a plugin.

        Args:
            tenant_id: Tenant ID
            plugin_id: Plugin ID in format "organization/plugin_name"

        Returns:
            Number of credentials deleted
        """
        logger.info(f"Deleting all credentials for plugin_id: {plugin_id}, tenant_id: {tenant_id}")

        # Bulk delete from ProviderCredential
        provider_creds_deleted = db.session.query(ProviderCredential).filter(
            ProviderCredential.tenant_id == tenant_id,
            ProviderCredential.provider_name.like(f"{plugin_id}/%")
        ).delete(synchronize_session=False)

        # Bulk delete from ProviderModelCredential
        model_creds_deleted = db.session.query(ProviderModelCredential).filter(
            ProviderModelCredential.tenant_id == tenant_id,
            ProviderModelCredential.provider_name.like(f"{plugin_id}/%")
        ).delete(synchronize_session=False)

        deleted_count = provider_creds_deleted + model_creds_deleted

        db.session.commit()
        logger.info(f"Deleted {deleted_count} plugin credentials for tenant {tenant_id}")
        return deleted_count

Comment on lines +522 to +524
manager = PluginInstaller()
plugins = manager.list_plugins(tenant_id)
plugin = next((p for p in plugins if p.installation_id == plugin_installation_id), None)
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

In both check_plugin_credentials and uninstall, you are fetching the list of all plugins for a tenant and then iterating in Python to find the one with the matching plugin_installation_id. This can be inefficient if a tenant has a large number of installed plugins. A similar pattern is repeated in the uninstall method.

Suggested change
manager = PluginInstaller()
plugins = manager.list_plugins(tenant_id)
plugin = next((p for p in plugins if p.installation_id == plugin_installation_id), None)
# Get plugin installation to extract plugin_id
manager = PluginInstaller()
plugin = manager.get_plugin_by_installation_id(tenant_id, plugin_installation_id)

Comment on lines +387 to +411
{credentials.length > 0 && (
<div className='space-y-2'>
<div className='system-sm-semibold text-text-secondary'>
{t(`${i18nPrefix}.credentialsWarning`)}
</div>
<div className='max-h-32 overflow-y-auto space-y-1 rounded-lg border border-divider-subtle bg-background-section-burn p-2'>
{credentials.map(cred => (
<div key={cred.credential_id} className='system-xs-regular text-text-tertiary'>
{cred.credential_name} ({cred.credential_type})
</div>
))}
</div>
<label className='flex items-center space-x-2 cursor-pointer'>
<input
type='checkbox'
checked={deleteCredentials}
onChange={e => setDeleteCredentials(e.target.checked)}
className='rounded border-divider-regular'
/>
<span className='system-sm-regular text-text-secondary'>
{t(`${i18nPrefix}.deleteCredentials`)}
</span>
</label>
</div>
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

This component uses a single checkbox for the credential deletion option, while plugin-item/action.tsx uses a pair of radio-button-style options. The PR description also mentions "radio button options". Using different UI controls for the same action can be confusing for users.

For UI consistency, consider replacing the checkbox with the same radio button group used in plugin-item/action.tsx. This will provide a consistent user experience across the application.

Comment on lines +154 to +184
<div className='space-y-2 pt-2'>
<div className='system-sm-semibold text-text-secondary'>
What should happen to these API keys?
</div>
<div className='flex flex-col gap-2'>
<label
className='flex items-center gap-2 p-2 rounded cursor-pointer hover:bg-background-default-hover transition-colors'
onClick={(e) => {
e.stopPropagation()
setDeleteCredentials(true)
}}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${deleteCredentials ? 'border-blue-600' : 'border-gray-300'}`}>
{deleteCredentials && <div className='w-2 h-2 rounded-full bg-blue-600' />}
</div>
<span className='system-sm-regular'>Delete these API keys</span>
</label>
<label
className='flex items-center gap-2 p-2 rounded cursor-pointer hover:bg-background-default-hover transition-colors'
onClick={(e) => {
e.stopPropagation()
setDeleteCredentials(false)
}}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${!deleteCredentials ? 'border-blue-600' : 'border-gray-300'}`}>
{!deleteCredentials && <div className='w-2 h-2 rounded-full bg-blue-600' />}
</div>
<span className='system-sm-regular'>Keep these API keys (reuse if reinstalling plugin)</span>
</label>
</div>
</div>
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The text for the credential deletion options ("Delete these API keys", "Keep these API keys...") is hardcoded in this component. This will prevent them from being translated into other languages.

@alterxyz alterxyz requested review from Yeuoly and crazywoola and removed request for Yeuoly and crazywoola November 14, 2025 04:07
@alterxyz alterxyz marked this pull request as draft November 14, 2025 04:09
- Add PluginCredentialService for credential management
- Add check_plugin_credentials() and update uninstall() in PluginService
- Add API endpoints for credential checking and deletion options
- Update frontend with credential deletion UI and radio buttons
- Add i18n strings for credential management

Fixes langgenius#27531
@UMDKyle UMDKyle force-pushed the feature/plugin-credential-deletion-option branch from c734463 to 78a5274 Compare November 15, 2025 12:20
@asukaminato0721
Copy link
Contributor

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a valuable feature that allows users to decide whether to delete or retain API keys upon uninstalling a plugin. The implementation is solid, with both backend and frontend changes to support this new functionality. I've identified a couple of areas for improvement: one related to database performance and another concerning internationalization of new UI strings. Overall, this is a well-executed feature addition.

Comment on lines +103 to +128
deleted_count = 0

for credential_id in credential_ids:
# Try deleting from provider_credentials
provider_cred = db.session.scalars(
select(ProviderCredential).where(
ProviderCredential.id == credential_id, ProviderCredential.tenant_id == tenant_id
)
).first()

if provider_cred:
db.session.delete(provider_cred)
deleted_count += 1
continue

# Try deleting from provider_model_credentials
model_cred = db.session.scalars(
select(ProviderModelCredential).where(
ProviderModelCredential.id == credential_id, ProviderModelCredential.tenant_id == tenant_id
)
).first()

if model_cred:
db.session.delete(model_cred)
deleted_count += 1

Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The current implementation iterates through each credential_id and executes individual database queries within a loop. This can lead to an N+1 query problem, which is inefficient if a plugin has many credentials. A more performant approach would be to use a single bulk DELETE statement for each credential table.

        deleted_count = 0
        if not credential_ids:
            return deleted_count

        from sqlalchemy import delete

        # Delete from provider_credentials
        provider_stmt = delete(ProviderCredential).where(
            ProviderCredential.tenant_id == tenant_id,
            ProviderCredential.id.in_(credential_ids)
        )
        provider_result = db.session.execute(provider_stmt)

        # Delete from provider_model_credentials
        model_stmt = delete(ProviderModelCredential).where(
            ProviderModelCredential.tenant_id == tenant_id,
            ProviderModelCredential.id.in_(credential_ids)
        )
        model_result = db.session.execute(model_stmt)

        deleted_count = provider_result.rowcount + model_result.rowcount

Comment on lines +154 to +184
<div className='space-y-2 pt-2'>
<div className='system-sm-semibold text-text-secondary'>
What should happen to these API keys?
</div>
<div className='flex flex-col gap-2'>
<label
className='flex items-center gap-2 p-2 rounded cursor-pointer hover:bg-background-default-hover transition-colors'
onClick={(e) => {
e.stopPropagation()
setDeleteCredentials(true)
}}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${deleteCredentials ? 'border-blue-600' : 'border-gray-300'}`}>
{deleteCredentials && <div className='w-2 h-2 rounded-full bg-blue-600' />}
</div>
<span className='system-sm-regular'>Delete these API keys</span>
</label>
<label
className='flex items-center gap-2 p-2 rounded cursor-pointer hover:bg-background-default-hover transition-colors'
onClick={(e) => {
e.stopPropagation()
setDeleteCredentials(false)
}}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${!deleteCredentials ? 'border-blue-600' : 'border-gray-300'}`}>
{!deleteCredentials && <div className='w-2 h-2 rounded-full bg-blue-600' />}
</div>
<span className='system-sm-regular'>Keep these API keys (reuse if reinstalling plugin)</span>
</label>
</div>
</div>
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

There are several hardcoded strings in the UI for managing credentials upon plugin deletion. These should be internationalized to support multiple languages and improve maintainability.

Please add the following keys to web/i18n/en-US/plugin.ts under action:

whatToDoWithKeys: 'What should happen to these API keys?',
deleteApiKeys: 'Delete these API keys',
keepApiKeys: 'Keep these API keys (reuse if reinstalling plugin)',

Then, use the t function to render these strings in the component.

          <div className='space-y-2 pt-2'>
            <div className='system-sm-semibold text-text-secondary'>
              {t(`${i18nPrefix}.whatToDoWithKeys`)}
            </div>
            <div className='flex flex-col gap-2'>
              <label
                className='flex items-center gap-2 p-2 rounded cursor-pointer hover:bg-background-default-hover transition-colors'
                onClick={(e) => {
                  e.stopPropagation()
                  setDeleteCredentials(true)
                }}
              >
                <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${deleteCredentials ? 'border-blue-600' : 'border-gray-300'}`}>
                  {deleteCredentials && <div className='w-2 h-2 rounded-full bg-blue-600' />}
                </div>
                <span className='system-sm-regular'>{t(`${i18nPrefix}.deleteApiKeys`)}</span>
              </label>
              <label
                className='flex items-center gap-2 p-2 rounded cursor-pointer hover:bg-background-default-hover transition-colors'
                onClick={(e) => {
                  e.stopPropagation()
                  setDeleteCredentials(false)
                }}
              >
                <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${!deleteCredentials ? 'border-blue-600' : 'border-gray-300'}`}>
                  {!deleteCredentials && <div className='w-2 h-2 rounded-full bg-blue-600' />}
                </div>
                <span className='system-sm-regular'>{t(`${i18nPrefix}.keepApiKeys`)}</span>
              </label>
            </div>
          </div>

@alterxyz alterxyz marked this pull request as ready for review November 15, 2025 19:33
Copilot AI review requested due to automatic review settings November 15, 2025 19:33
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:XXL This PR changes 1000+ lines, ignoring generated files. labels Nov 15, 2025
Copilot finished reviewing on behalf of alterxyz November 15, 2025 19:36
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds functionality allowing users to choose whether to delete or keep API keys/credentials when uninstalling plugins, directly addressing issue #27531 where credentials persisted after plugin deletion without user awareness.

Key Changes:

  • Added backend service (PluginCredentialService) to query and delete plugin credentials
  • Enhanced uninstall API endpoint with credential management parameters
  • Implemented frontend UI showing credentials and providing delete/keep options before uninstallation

Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
api/services/plugin/plugin_credential_service.py New service to retrieve and delete plugin credentials from database
api/services/plugin/plugin_service.py Enhanced uninstall method with credential deletion logic
api/controllers/console/workspace/plugin.py Added credential check endpoint and updated uninstall endpoint with new parameters
web/service/plugins.ts Added credential check function and updated uninstall function signature
web/app/components/plugins/plugin-item/action.tsx Implemented credential check and deletion UI with radio buttons
web/app/components/plugins/plugin-detail-panel/detail-header.tsx Implemented credential check and deletion UI with checkbox
web/i18n/en-US/plugin.ts Added English translations for credential management UI
Comments suppressed due to low confidence (3)

web/app/components/plugins/plugin-item/action.tsx:12

  • Unused import CredentialCheckbox.
import CredentialCheckbox from './credential-checkbox'

web/i18n/en-US/plugin.ts:196

  action: {
    checkForUpdates: 'Check for updates',
    pluginInfo: 'Plugin info',
    delete: 'Remove plugin',
    deleteContentLeft: 'Would you like to remove ',
    deleteContentRight: ' plugin?',
    usedInApps: 'This plugin is being used in {{num}} apps.',
  },

api/services/plugin/plugin_service.py:551

  • This import of module logging is redundant, as it was previously imported on line 1.
        import logging

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

import Tooltip from '../../base/tooltip'
import Confirm from '../../base/confirm'
import { uninstallPlugin } from '@/service/plugins'
import CredentialCheckbox from './credential-checkbox'
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

There's an unused import CredentialCheckbox on line 12. This component is imported but never used in the file. The credential UI is implemented inline with custom radio buttons instead.

Suggested change
import CredentialCheckbox from './credential-checkbox'

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +409
<label className='flex items-center space-x-2 cursor-pointer'>
<input
type='checkbox'
checked={deleteCredentials}
onChange={e => setDeleteCredentials(e.target.checked)}
className='rounded border-divider-regular'
/>
<span className='system-sm-regular text-text-secondary'>
{t(`${i18nPrefix}.deleteCredentials`)}
</span>
</label>
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

[nitpick] The default credential deletion behavior differs between the two components:

  • In plugin-item/action.tsx (line 107): Defaults to true (delete credentials) when credentials exist
  • In plugin-detail-panel/detail-header.tsx (line 212): Also defaults to true when credentials exist, but uses a checkbox instead of radio buttons

The checkbox pattern in detail-header.tsx is confusing because the label says "Also delete these API keys (if unchecked, they will remain...)" but the default is checked=true. With a checkbox, users might interpret this as an opt-in rather than understanding the default behavior.

Suggestion: Use radio buttons consistently in both locations to make the choice clearer.

Suggested change
<label className='flex items-center space-x-2 cursor-pointer'>
<input
type='checkbox'
checked={deleteCredentials}
onChange={e => setDeleteCredentials(e.target.checked)}
className='rounded border-divider-regular'
/>
<span className='system-sm-regular text-text-secondary'>
{t(`${i18nPrefix}.deleteCredentials`)}
</span>
</label>
<div className='flex flex-col space-y-2'>
<label className='flex items-center space-x-2 cursor-pointer'>
<input
type='radio'
name='delete-credentials'
value='delete'
checked={deleteCredentials === true}
onChange={() => setDeleteCredentials(true)}
className='rounded border-divider-regular'
/>
<span className='system-sm-regular text-text-secondary'>
{t(`${i18nPrefix}.deleteCredentials`)}
</span>
</label>
<label className='flex items-center space-x-2 cursor-pointer'>
<input
type='radio'
name='delete-credentials'
value='keep'
checked={deleteCredentials === false}
onChange={() => setDeleteCredentials(false)}
className='rounded border-divider-regular'
/>
<span className='system-sm-regular text-text-secondary'>
{t(`${i18nPrefix}.keepCredentials`)}
</span>
</label>
</div>

Copilot uses AI. Check for mistakes.
Comment on lines 557 to +560
parser_uninstall = reqparse.RequestParser().add_argument(
"plugin_installation_id", type=str, required=True, location="json"
).add_argument("delete_credentials", type=bool, required=False, default=False, location="json").add_argument(
"credential_ids", type=list, required=False, location="json"
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

The parameter type on line 560 uses type=list which is not specific enough for proper validation. The API expects a list of strings (credential IDs), but the current parser doesn't enforce this.

Suggestion: Consider using a more specific type like action='append' or validating the list contents are strings, to prevent potential type errors when non-string values are passed.

Suggested change
parser_uninstall = reqparse.RequestParser().add_argument(
"plugin_installation_id", type=str, required=True, location="json"
).add_argument("delete_credentials", type=bool, required=False, default=False, location="json").add_argument(
"credential_ids", type=list, required=False, location="json"
def list_of_strings(value):
if not isinstance(value, list):
raise ValueError("credential_ids must be a list of strings")
if not all(isinstance(item, str) for item in value):
raise ValueError("All credential_ids must be strings")
return value
parser_uninstall = reqparse.RequestParser().add_argument(
"plugin_installation_id", type=str, required=True, location="json"
).add_argument("delete_credentials", type=bool, required=False, default=False, location="json").add_argument(
"credential_ids", type=list_of_strings, required=False, location="json"

Copilot uses AI. Check for mistakes.
tenant_id,
args["plugin_installation_id"],
delete_credentials=args.get("delete_credentials", False),
credential_ids=args.get("credential_ids", []),
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

On line 581, when credential_ids is not provided, the code passes an empty list [] instead of None. However, the service method uninstall() signature uses Optional[Sequence[str]] for this parameter to distinguish between "no IDs provided" (None) and "empty list provided" ([]).

The logic in lines 569-576 of plugin_service.py shows that when credential_ids is provided (even if empty), it tries to delete specific credentials, but an empty list would do nothing. When credential_ids is None, it deletes all credentials.

Suggestion: Change line 581 to credential_ids=args.get("credential_ids") (without the default []) to properly pass None when not provided, allowing the service to delete all credentials as intended.

Suggested change
credential_ids=args.get("credential_ids", []),
credential_ids=args.get("credential_ids"),

Copilot uses AI. Check for mistakes.
result = PluginService.uninstall(
tenant_id,
args["plugin_installation_id"],
delete_credentials=args.get("delete_credentials", False),
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

[nitpick] The delete_credentials parameter on line 580 uses .get() with a default of False, which is already defined in the parser on line 559. This creates redundant default value handling. While not harmful, it's unnecessary duplication.

Suggestion: Since the parser already has default=False, you can use args["delete_credentials"] directly instead of args.get("delete_credentials", False).

Suggested change
delete_credentials=args.get("delete_credentials", False),
delete_credentials=args["delete_credentials"],

Copilot uses AI. Check for mistakes.
Comment on lines +399 to +410
<label className='flex items-center space-x-2 cursor-pointer'>
<input
type='checkbox'
checked={deleteCredentials}
onChange={e => setDeleteCredentials(e.target.checked)}
className='rounded border-divider-regular'
/>
<span className='system-sm-regular text-text-secondary'>
{t(`${i18nPrefix}.deleteCredentials`)}
</span>
</label>
</div>
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

The UI implementation differs between the two deletion dialogs in the codebase:

  • In plugin-item/action.tsx (lines 158-183): Uses custom radio buttons
  • In plugin-detail-panel/detail-header.tsx (lines 399-410): Uses a checkbox

This inconsistency creates a confusing user experience. Both locations should use the same UI pattern for deleting credentials.

Suggestion: Standardize on one approach (preferably radio buttons as they better represent the mutually exclusive choice).

Copilot uses AI. Check for mistakes.
"""Service for managing plugin credentials."""

@staticmethod
def get_plugin_credentials(tenant_id: str, plugin_id: str) -> list[PluginCredentialInfo]:
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

The return type annotation on line 37 uses list[PluginCredentialInfo] which is Python 3.9+ syntax, but this requires from __future__ import annotations at the top of the file to work in Python 3.9. Without this import, the code will fail at runtime in Python 3.9.

Since the file already imports Optional from typing (line 4 in plugin_service.py shows this pattern), this file should consistently use List[PluginCredentialInfo] from the typing module or add from __future__ import annotations.

Suggestion: Change to from typing import List and use List[PluginCredentialInfo], or add from __future__ import annotations at the top of the file.

Copilot uses AI. Check for mistakes.
Comment on lines +551 to +554
import logging
from services.plugin.plugin_credential_service import PluginCredentialService

logger = logging.getLogger(__name__)
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

The variable logger is defined on line 554 using logging.getLogger(__name__), but there's already a module-level logger imported on line 1 and typically available as a global. The local logger definition is unnecessary and creates confusion about which logger instance is being used.

Additionally, there's a redundant import statement on line 551 (import logging) when logging is already imported at the module level (line 1).

Suggestion: Remove the redundant import on line 551 and the local logger definition on line 554, and use the module-level logger instead.

Suggested change
import logging
from services.plugin.plugin_credential_service import PluginCredentialService
logger = logging.getLogger(__name__)
from services.plugin.plugin_credential_service import PluginCredentialService

Copilot uses AI. Check for mistakes.
}
catch (error) {
console.error('checkPluginCredentials error', error)
setCredentials([])
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

Similar to the issue in action.tsx, the error handling on lines 214-217 catches and logs errors from checkPluginCredentials(), but then proceeds without any user notification. If the credentials check fails, the user won't know that credential information couldn't be loaded.

Additionally, when an error occurs, deleteCredentials remains false (line 206 initialization), which means credentials won't be deleted even if they exist.

Suggestion: Add user-facing error notification (toast) when the credential check fails, or default deleteCredentials to true to ensure credentials are cleaned up even if the check fails.

Suggested change
setCredentials([])
setCredentials([])
setDeleteCredentials(true)
Toast.notify({
type: 'error',
message: 'Failed to load plugin credentials. Credentials will be deleted if present.',
})

Copilot uses AI. Check for mistakes.

from extensions.ext_database import db
from models.provider import ProviderCredential, ProviderModelCredential
from models.provider_ids import GenericProviderID
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

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

Import of 'GenericProviderID' is not used.

Suggested change
from models.provider_ids import GenericProviderID

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💪 enhancement New feature or request size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

I deleted the OpenAI plugin, and after reinstalling it, the original API key was automatically restored. I want to clear it.

2 participants