This is an automated email from the ASF dual-hosted git repository.

EnxDev pushed a commit to branch enxdev/feat/extension-import-delete
in repository https://gitbox.apache.org/repos/asf/superset.git

commit e40f08afe015f140e3ccc7766c830c93879e2b3b
Author: Enzo Martellucci <[email protected]>
AuthorDate: Tue May 26 20:52:12 2026 +0200

    feat(extensions): add import/delete UI and chatbot selection in 
ExtensionsList
    
    Backend:
    - POST /api/v1/extensions/ — admin-only upload of .supx bundles (zip
      validation, safe-zip check, persisted to EXTENSIONS_PATH)
    - DELETE /api/v1/extensions/<publisher>/<name> — admin-only removal;
      rejects LOCAL_EXTENSIONS configured entries
    
    Frontend:
    - Import button in SubMenu (file picker, .supx only) calls upload endpoint
    - Per-row delete action with confirmation dialog
    - Per-row star action to set/clear default chatbot via PUT /settings;
      filled star indicates the active chatbot, fires 
notifyExtensionSettingsChanged
      so ChatbotMount reacts without a page reload
    
    Settings pub/sub (notifyExtensionSettingsChanged / 
subscribeToExtensionSettings)
    allows components to react to admin settings changes without a page reload.
    
    Also fix scripts/oxlint.sh: [ -n "$output" ] && echo would exit 1 under
    set -e when output is empty (no lint errors). Use if/fi instead.
    
    Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---
 scripts/oxlint.sh                                  |   2 +-
 superset-frontend/src/core/extensions/index.ts     |  25 +++
 .../src/extensions/ExtensionsList.tsx              | 212 +++++++++++++++++++-
 superset/extensions/api.py                         | 221 ++++++++++++++++++++-
 4 files changed, 454 insertions(+), 6 deletions(-)

diff --git a/scripts/oxlint.sh b/scripts/oxlint.sh
index 95f48afabb6..9baf026ddf2 100755
--- a/scripts/oxlint.sh
+++ b/scripts/oxlint.sh
@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
     echo "$output" >&2
     exit 1
   }
-  [ -n "$output" ] && echo "$output"
+  if [ -n "$output" ]; then echo "$output"; fi
 else
   echo "No JavaScript/TypeScript files to lint"
 fi
diff --git a/superset-frontend/src/core/extensions/index.ts 
b/superset-frontend/src/core/extensions/index.ts
index ae49a135aed..dedeff005ad 100644
--- a/superset-frontend/src/core/extensions/index.ts
+++ b/superset-frontend/src/core/extensions/index.ts
@@ -29,3 +29,28 @@ export const extensions: typeof extensionsApi = {
   getExtension,
   getAllExtensions,
 };
+
+// Pub/sub for admin extension settings changes. Allows components like
+// ChatbotMount to react when ExtensionsList persists a new settings payload,
+// without coupling them through Redux or a shared API hook.
+type SettingsListener = () => void;
+const settingsListeners = new Set<SettingsListener>();
+
+/**
+ * Notify all subscribers that extension settings have changed.
+ * Call this after a successful PUT /api/v1/extensions/settings.
+ */
+export const notifyExtensionSettingsChanged = (): void => {
+  settingsListeners.forEach(fn => fn());
+};
+
+/**
+ * Subscribe to extension settings changes.
+ * Returns an unsubscribe function.
+ */
+export const subscribeToExtensionSettings = (
+  listener: SettingsListener,
+): (() => void) => {
+  settingsListeners.add(listener);
+  return () => settingsListeners.delete(listener);
+};
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx 
b/superset-frontend/src/extensions/ExtensionsList.tsx
index 6f4f9c2f56d..31bba44f5da 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -17,17 +17,30 @@
  * under the License.
  */
 import { t } from '@apache-superset/core/translation';
-import { FunctionComponent, useMemo } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import {
+  FunctionComponent,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+  useMemo,
+} from 'react';
 import { useListViewResource } from 'src/views/CRUD/hooks';
+import { createErrorHandler } from 'src/views/CRUD/utils';
 import { ListView } from 'src/components';
 import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
 import withToasts from 'src/components/MessageToasts/withToasts';
+import { ConfirmStatusChange, Tooltip } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { notifyExtensionSettingsChanged } from 'src/core/extensions';
 
 const PAGE_SIZE = 25;
 
 type Extension = {
-  id: number;
+  id: string;
   name: string;
+  publisher: string;
   enabled: boolean;
 };
 
@@ -40,6 +53,10 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> 
= ({
   addDangerToast,
   addSuccessToast,
 }) => {
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const [uploading, setUploading] = useState(false);
+  const [activeChatbotId, setActiveChatbotId] = useState<string | null>(null);
+
   const {
     state: { loading, resourceCount, resourceCollection },
     fetchData,
@@ -50,6 +67,100 @@ const ExtensionsList: 
FunctionComponent<ExtensionsListProps> = ({
     addDangerToast,
   );
 
+  // Load current active chatbot from settings on mount
+  useEffect(() => {
+    SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
+      .then(({ json }) => {
+        setActiveChatbotId(json?.result?.active_chatbot_id ?? null);
+      })
+      .catch(() => {
+        // non-fatal: leave activeChatbotId as null
+      });
+  }, []);
+
+  const handleUploadClick = () => {
+    fileInputRef.current?.click();
+  };
+
+  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    if (!file.name.endsWith('.supx')) {
+      addDangerToast(t('File must have a .supx extension.'));
+      e.target.value = '';
+      return;
+    }
+
+    const formData = new FormData();
+    formData.append('bundle', file);
+
+    setUploading(true);
+    SupersetClient.post({
+      endpoint: '/api/v1/extensions/',
+      body: formData,
+      headers: { Accept: 'application/json' },
+    })
+      .then(() => {
+        addSuccessToast(t('Extension installed successfully.'));
+        refreshData();
+      })
+      .catch(
+        createErrorHandler(errMsg =>
+          addDangerToast(
+            t('There was an issue installing the extension: %s', errMsg),
+          ),
+        ),
+      )
+      .finally(() => {
+        setUploading(false);
+        e.target.value = '';
+      });
+  };
+
+  const handleDelete = (extension: Extension) => {
+    const { publisher, name } = extension;
+    SupersetClient.delete({
+      endpoint: `/api/v1/extensions/${publisher}/${name}`,
+    }).then(
+      () => {
+        addSuccessToast(t('Deleted: %s', extension.name));
+        refreshData();
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(
+          t('There was an issue deleting %s: %s', extension.name, errMsg),
+        ),
+      ),
+    );
+  };
+
+  const handleSetDefaultChatbot = useCallback(
+    (extension: Extension) => {
+      const newId = activeChatbotId === extension.id ? null : extension.id;
+      SupersetClient.put({
+        endpoint: '/api/v1/extensions/settings',
+        jsonPayload: { active_chatbot_id: newId },
+      }).then(
+        () => {
+          setActiveChatbotId(newId);
+          notifyExtensionSettingsChanged();
+          addSuccessToast(
+            newId
+              ? t('%s set as default chatbot.', extension.name)
+              : t('Default chatbot cleared.'),
+          );
+        },
+        createErrorHandler(errMsg =>
+          addDangerToast(
+            t('There was an issue updating chatbot settings: %s', errMsg),
+          ),
+        ),
+      );
+    },
+    [activeChatbotId, addDangerToast, addSuccessToast],
+  );
+
   const columns = useMemo(
     () => [
       {
@@ -63,18 +174,111 @@ const ExtensionsList: 
FunctionComponent<ExtensionsListProps> = ({
           },
         }: any) => name,
       },
+      {
+        Header: t('Publisher'),
+        accessor: 'publisher',
+        id: 'publisher',
+        Cell: ({
+          row: {
+            original: { publisher },
+          },
+        }: any) => publisher,
+      },
+      {
+        Header: t('Actions'),
+        id: 'actions',
+        disableSortBy: true,
+        Cell: ({ row: { original } }: any) => {
+          const isDefault = activeChatbotId === original.id;
+          return (
+            <>
+              <Tooltip
+                id="set-chatbot-tooltip"
+                title={
+                  isDefault
+                    ? t('Clear default chatbot')
+                    : t('Set as default chatbot')
+                }
+                placement="bottom"
+              >
+                <span
+                  role="button"
+                  tabIndex={0}
+                  className="action-button"
+                  onClick={() => handleSetDefaultChatbot(original)}
+                >
+                  {isDefault ? (
+                    <Icons.StarFilled iconSize="l" />
+                  ) : (
+                    <Icons.StarOutlined iconSize="l" />
+                  )}
+                </span>
+              </Tooltip>
+              <ConfirmStatusChange
+                title={t('Please confirm')}
+                description={
+                  <>
+                    {t('Are you sure you want to delete')}{' '}
+                    <b>{original.name}</b>?
+                  </>
+                }
+                onConfirm={() => handleDelete(original)}
+              >
+                {(confirmDelete: () => void) => (
+                  <Tooltip
+                    id="delete-extension-tooltip"
+                    title={t('Delete')}
+                    placement="bottom"
+                  >
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={confirmDelete}
+                    >
+                      <Icons.DeleteOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
+              </ConfirmStatusChange>
+            </>
+          );
+        },
+      },
     ],
-    [loading], // We need to monitor loading to avoid stale state in actions
+    [loading, activeChatbotId, handleSetDefaultChatbot],
   );
 
   const menuData: SubMenuProps = {
     activeChild: 'Extensions',
     name: t('Extensions'),
-    buttons: [],
+    buttons: [
+      {
+        name: (
+          <Tooltip
+            id="import-extension-tooltip"
+            title={t('Import extension (.supx)')}
+            placement="bottomRight"
+          >
+            <Icons.DownloadOutlined iconSize="l" />
+          </Tooltip>
+        ),
+        buttonStyle: 'link',
+        onClick: handleUploadClick,
+        loading: uploading,
+      },
+    ],
   };
 
   return (
     <>
+      <input
+        ref={fileInputRef}
+        type="file"
+        accept=".supx"
+        style={{ display: 'none' }}
+        onChange={handleFileChange}
+      />
       <SubMenu {...menuData} />
       <ListView<Extension>
         columns={columns}
diff --git a/superset/extensions/api.py b/superset/extensions/api.py
index b1b5734979e..380e2c94165 100644
--- a/superset/extensions/api.py
+++ b/superset/extensions/api.py
@@ -16,21 +16,43 @@
 # under the License.
 import mimetypes
 from io import BytesIO
+from pathlib import Path
 from typing import Any
+from zipfile import is_zipfile, ZipFile
 
-from flask import send_file
+from flask import current_app, request, send_file
 from flask.wrappers import Response
 from flask_appbuilder.api import BaseApi, expose, protect, safe
 
+from superset.extensions import security_manager
+from superset.extensions.settings import (
+    get_extension_settings,
+    update_extension_settings,
+)
 from superset.extensions.utils import (
     build_extension_data,
+    get_bundle_files_from_zip,
     get_extensions,
+    get_loaded_extension,
 )
+from superset.utils.core import check_is_safe_zip
 
 
 class ExtensionsRestApi(BaseApi):
     allow_browser_login = True
     resource_name = "extensions"
+    class_permission_name = "Extensions"
+    base_permissions = [
+        "can_get_list",
+        "can_get",
+        "can_put",
+        "can_post",
+        "can_delete",
+        "can_content",
+        "can_info",
+        "can_get_settings",
+        "can_put_settings",
+    ]
 
     def response(self, status_code: int, **kwargs: Any) -> Response:
         """Helper method to create JSON responses."""
@@ -167,6 +189,203 @@ class ExtensionsRestApi(BaseApi):
         extension_data = build_extension_data(extension)
         return self.response(200, result=extension_data)
 
+    @protect()
+    @safe
+    @expose("/", methods=("POST",))
+    def post(self, **kwargs: Any) -> Response:
+        """Upload and install an extension bundle (.supx file).
+        ---
+        post:
+          summary: Upload a .supx extension bundle.
+          requestBody:
+            required: true
+            content:
+              multipart/form-data:
+                schema:
+                  type: object
+                  properties:
+                    bundle:
+                      type: string
+                      format: binary
+                      description: The .supx extension bundle file.
+          responses:
+            201:
+              description: Extension installed successfully.
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        type: object
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not security_manager.is_admin():
+            return self.response(403, message="Admin access required.")
+
+        extensions_path = current_app.config.get("EXTENSIONS_PATH")
+        if not extensions_path:
+            return self.response(
+                400,
+                message=(
+                    "EXTENSIONS_PATH is not configured. Set it in 
superset_config.py "
+                    "to enable extension uploads."
+                ),
+            )
+
+        upload = request.files.get("bundle")
+        if not upload:
+            return self.response(
+                400, message="No file provided. Send a 'bundle' field."
+            )
+
+        if not upload.filename or not upload.filename.endswith(".supx"):
+            return self.response(400, message="File must have a .supx 
extension.")
+
+        stream = BytesIO(upload.read())
+        if not is_zipfile(stream):
+            return self.response(400, message="File is not a valid ZIP 
archive.")
+
+        stream.seek(0)
+        try:
+            with ZipFile(stream, "r") as zip_file:
+                check_is_safe_zip(zip_file)
+                files = list(get_bundle_files_from_zip(zip_file))
+                extension = get_loaded_extension(files, 
source_base_path="upload://")
+        except Exception as ex:  # pylint: disable=broad-except
+            return self.response(400, message=f"Invalid extension bundle: 
{ex}")
+
+        # Persist to EXTENSIONS_PATH so the extension survives restarts.
+        dest_dir = Path(extensions_path)
+        dest_dir.mkdir(parents=True, exist_ok=True)
+        dest_file = dest_dir / f"{extension.manifest.id}.supx"
+
+        stream.seek(0)
+        dest_file.write_bytes(stream.read())
+
+        return self.response(201, result=build_extension_data(extension))
+
+    @protect()
+    @safe
+    @expose("/<publisher>/<name>", methods=("DELETE",))
+    def delete(self, publisher: str, name: str, **kwargs: Any) -> Response:
+        """Delete an uploaded extension bundle.
+        ---
+        delete:
+          summary: Delete an extension by its publisher and name.
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: publisher
+          - in: path
+            schema:
+              type: string
+            name: name
+          responses:
+            200:
+              description: Extension deleted.
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        if not security_manager.is_admin():
+            return self.response(403, message="Admin access required.")
+
+        composite_id = f"{publisher}.{name}"
+        extensions = get_extensions()
+        extension = extensions.get(composite_id)
+        if not extension:
+            return self.response_404()
+
+        # LOCAL_EXTENSIONS are managed via config — cannot be deleted through 
the UI.
+        local_paths = {
+            str((Path(p) / "dist").resolve())
+            for p in current_app.config.get("LOCAL_EXTENSIONS", [])
+        }
+        if extension.source_base_path in local_paths:
+            return self.response(
+                400,
+                message=(
+                    "Local extensions configured via LOCAL_EXTENSIONS cannot 
be "
+                    "deleted through the UI. Remove them from your 
configuration."
+                ),
+            )
+
+        # Locate and remove the .supx file from EXTENSIONS_PATH.
+        extensions_path = current_app.config.get("EXTENSIONS_PATH")
+        if not extensions_path:
+            return self.response(
+                400,
+                message="EXTENSIONS_PATH is not configured; cannot remove 
bundle file.",
+            )
+
+        supx_file = Path(extensions_path) / f"{composite_id}.supx"
+        if not supx_file.exists():
+            return self.response_404()
+
+        supx_file.unlink()
+        return self.response(200, message="Extension deleted.")
+
+    @protect()
+    @safe
+    @expose("/settings", methods=("GET",))
+    def get_settings(self, **kwargs: Any) -> Response:
+        """Get global extension admin settings.
+        ---
+        get:
+          summary: Get extension admin settings (active chatbot, enabled 
flags).
+          responses:
+            200:
+              description: Extension settings
+        """
+        return self.response(200, result=get_extension_settings())
+
+    @protect()
+    @safe
+    @expose("/settings", methods=("PUT",))
+    def put_settings(self, **kwargs: Any) -> Response:
+        """Update global extension admin settings.
+        ---
+        put:
+          summary: Update extension admin settings.
+          requestBody:
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    active_chatbot_id:
+                      type: string
+                      nullable: true
+                    enabled:
+                      type: object
+                      additionalProperties:
+                        type: boolean
+          responses:
+            200:
+              description: Updated settings
+            403:
+              $ref: '#/components/responses/403'
+        """
+        if not security_manager.is_admin():
+            return self.response(403, message="Admin access required.")
+        body = request.get_json(silent=True) or {}
+        result = update_extension_settings(body)
+        return self.response(200, result=result)
+
     @protect()
     @safe
     @expose("/<publisher>/<name>/<file>", methods=("GET",))

Reply via email to