Преглед изворни кода

Improve local slicer selection with code review suggestions, adding fail toast and handle (legacy) read permissions

Pascal Heidmann пре 1 месец
родитељ
комит
af7908ca1e

+ 85 - 7
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -31,6 +31,10 @@ vi.mock('../../components/GcodeViewer', () => ({
 
 vi.mock('../../utils/slicer', () => ({
   openInSlicer: vi.fn(),
+  resolveDesktopSlicer: vi.fn(
+    (openInSlicer?: string, preferredSlicer?: string) =>
+      (openInSlicer ?? preferredSlicer ?? 'bambu_studio') as 'bambu_studio' | 'orcaslicer',
+  ),
 }));
 
 const mockCapabilities = {
@@ -517,12 +521,12 @@ describe('ModelViewerModal', () => {
       });
     });
 
-    it('disables Open in Slicer for non-3mf library files', async () => {
+    it('disables Open in Slicer for library files that cannot be handed to a slicer', async () => {
       render(
         <ModelViewerModal
           libraryFileId={1}
-          title="Model.stl"
-          fileType="stl"
+          title="Model.gcode"
+          fileType="gcode"
           onClose={mockOnClose}
         />
       );
@@ -649,12 +653,60 @@ describe('ModelViewerModal', () => {
       expect(screen.queryByText('Open in OrcaSlicer')).not.toBeInTheDocument();
     });
 
+    it('closes the split dropdown on Escape without closing the modal', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+      await waitFor(() => {
+        expect(screen.getByRole('menu')).toBeInTheDocument();
+      });
+
+      fireEvent.keyDown(document, { key: 'Escape' });
+
+      expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+      expect(mockOnClose).not.toHaveBeenCalled();
+    });
+
+    it('closes the split dropdown on an outside click without closing the modal', async () => {
+      render(
+        <ModelViewerModal
+          archiveId={1}
+          title="Test Model"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+      await waitFor(() => {
+        expect(screen.getByRole('menu')).toBeInTheDocument();
+      });
+
+      fireEvent.mouseDown(document.body);
+
+      expect(screen.queryByRole('menu')).not.toBeInTheDocument();
+      expect(mockOnClose).not.toHaveBeenCalled();
+    });
+
     it('does not render a split chevron when the file cannot open in a slicer', async () => {
       render(
         <ModelViewerModal
           libraryFileId={1}
-          title="Model.stl"
-          fileType="stl"
+          title="Model.gcode"
+          fileType="gcode"
           onClose={mockOnClose}
         />
       );
@@ -666,7 +718,28 @@ describe('ModelViewerModal', () => {
       expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
     });
 
-    it('renders a plain Slice button without a split chevron for non-handoff files', async () => {
+    it('offers the desktop handoff for an STL library file', async () => {
+      render(
+        <ModelViewerModal
+          libraryFileId={1}
+          title="Model.stl"
+          fileType="stl"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeEnabled();
+      });
+
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
+    });
+
+    it('offers the split Slice button for an STL when the slicer API is enabled', async () => {
       server.use(
         http.get('/api/v1/settings/', () => {
           return HttpResponse.json({ use_slicer_api: true });
@@ -687,7 +760,12 @@ describe('ModelViewerModal', () => {
         expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
       });
 
-      expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
+      fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
+
+      await waitFor(() => {
+        expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
+        expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
+      });
     });
   });
 });

+ 138 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -8,11 +8,16 @@ import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { FileManagerPage } from '../../pages/FileManagerPage';
 import { openInSlicer } from '../../utils/slicer';
+import { setAuthToken } from '../../api/client';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
 vi.mock('../../utils/slicer', () => ({
   openInSlicer: vi.fn(),
+  resolveDesktopSlicer: vi.fn(
+    (openInSlicer?: string, preferredSlicer?: string) =>
+      (openInSlicer ?? preferredSlicer ?? 'bambu_studio') as 'bambu_studio' | 'orcaslicer',
+  ),
 }));
 
 vi.mock('../../components/SliceModal', () => ({
@@ -1175,9 +1180,19 @@ describe('FileManagerPage', () => {
       );
     });
 
+    afterEach(() => {
+      // Permission tests set a token; clear it so it can't leak into the
+      // list-view tests that follow (mirrors FileManagerFolderDelete.test.tsx).
+      setAuthToken(null);
+    });
+
     const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
       const card = screen.getByText(filename).closest('.group') as HTMLElement;
-      await user.click(card.querySelector('button')!);
+      // Target the kebab (ellipsis) toggle specifically rather than the card's
+      // first button — a button added ahead of the kebab would otherwise
+      // break the menu-opening assumption.
+      const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.click(kebab);
       return card;
     };
 
@@ -1223,5 +1238,127 @@ describe('FileManagerPage', () => {
       const card = await openMenu(user, 'Benchy');
       expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
     });
+
+    // Permission gating is the security-relevant half of the slice action:
+    // the in-app API path needs library:upload (the permission the backend
+    // enforces on the slicer-token endpoint), the desktop handoff mirrors the
+    // backend's ownership check and needs library:read_all / library:read_own
+    // (legacy library:read also accepted).
+    const mockAuthUser = (permissions: string[]) => {
+      setAuthToken('test-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () =>
+          HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+        ),
+        http.get('*/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 7,
+            username: 'operator1',
+            is_admin: false,
+            permissions,
+          }),
+        ),
+        http.get('/api/v1/users/', () => HttpResponse.json([])),
+      );
+    };
+
+    it('disables the Slice menu item without library:upload when the slicer API is enabled', async () => {
+      mockAuthUser([]);
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).toBeDisabled();
+
+      await user.click(sliceItem!);
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('enables the Slice menu item with library:upload when the slicer API is enabled', async () => {
+      mockAuthUser(['library:upload']);
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).not.toBeDisabled();
+    });
+
+    it('disables the Slice menu item without any library read permission for the desktop handoff', async () => {
+      mockAuthUser(['library:upload']);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).toBeDisabled();
+
+      await user.click(sliceItem!);
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
+    it('enables the Slice menu item with library:read_own for the desktop handoff', async () => {
+      mockAuthUser(['library:read_own']);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).not.toBeDisabled();
+    });
+
+    it('slices from the list-view button when the slicer API is disabled', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('List view'));
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
+      await user.click(within(row).getByTitle('Slice'));
+
+      await waitFor(() => {
+        expect(openInSlicer).toHaveBeenCalledWith(
+          expect.stringContaining('/library/files/2/dl/test-token/'),
+          'bambu_studio',
+        );
+      });
+    });
+
+    it('slices from the list-view button into the in-app modal when the slicer API is enabled', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+      );
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      await user.click(screen.getByTitle('List view'));
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
+      await user.click(within(row).getByTitle('Slice'));
+
+      expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
   });
 });

+ 44 - 0
frontend/src/__tests__/pages/MakerworldPage.test.tsx

@@ -61,6 +61,8 @@ function resolveResponse(overrides: Partial<Record<string, unknown>> = {}) {
 // Individual tests layer extra handlers on top via ``server.use``.
 function useAuthedHandlers(opts: {
   slicer?: 'bambu_studio' | 'orcaslicer';
+  openInSlicer?: 'bambu_studio' | 'orcaslicer' | null;
+  useSlicerApi?: boolean;
   recent?: Array<Record<string, unknown>>;
 } = {}) {
   const slicer = opts.slicer ?? 'bambu_studio';
@@ -74,6 +76,8 @@ function useAuthedHandlers(opts: {
         auto_archive: true,
         save_thumbnails: true,
         preferred_slicer: slicer,
+        ...(opts.openInSlicer !== undefined ? { open_in_slicer: opts.openInSlicer } : {}),
+        ...(opts.useSlicerApi !== undefined ? { use_slicer_api: opts.useSlicerApi } : {}),
       }),
     ),
   );
@@ -190,6 +194,46 @@ describe('MakerworldPage', () => {
     expect(sliceButtons.length).toBe(2);
   });
 
+  it('prefers the open_in_slicer override over preferred_slicer for the desktop handoff', async () => {
+    // The URI-handoff label resolves via resolveDesktopSlicer: open_in_slicer
+    // beats preferred_slicer (#1329) when the slicer API is off.
+    useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer' });
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    const sliceButtons = await screen.findAllByRole('button', {
+      name: /Save & Slice in OrcaSlicer/,
+    });
+    expect(sliceButtons.length).toBe(2);
+  });
+
+  it('keeps preferred_slicer for the in-app API slice label, ignoring open_in_slicer', async () => {
+    // With the slicer API on, the sidecar drives the button, so the
+    // open_in_slicer desktop override must not leak into the label.
+    useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer', useSlicerApi: true });
+    server.use(
+      http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
+    );
+    render(<MakerworldPage />);
+    await userEvent.type(
+      await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
+      'https://makerworld.com/en/models/1400373',
+    );
+    await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
+
+    const sliceButtons = await screen.findAllByRole('button', {
+      name: /Save & Slice in Bambu Studio/,
+    });
+    expect(sliceButtons.length).toBe(2);
+  });
+
   it('clears the resolved preview when the URL input is edited after resolve', async () => {
     useAuthedHandlers();
     server.use(

+ 38 - 40
frontend/src/components/ModelViewerModal.tsx

@@ -6,7 +6,8 @@ import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { api, withStreamToken } from '../api/client';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { useToast } from '../contexts/ToastContext';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 type ViewTab = '3d' | 'gcode';
@@ -37,14 +38,14 @@ interface SlicerSplitButtonProps {
   label: string;
   dropdownLabel: string;
   onPrimary: () => void;
-  disabled?: boolean;
   items: Array<{ key: string; label: string; onClick: () => void }>;
 }
 
 // Split button: the primary part runs the default slicer action, the chevron
 // opens a dropdown with the other slicer options. Outside click or Escape
-// (non-propagating) closes the dropdown.
-function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = false, items }: SlicerSplitButtonProps) {
+// (non-propagating) closes the dropdown. The split only renders when the
+// action is already possible, so there is no disabled state to express.
+function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) {
   const [open, setOpen] = useState(false);
   const containerRef = useRef<HTMLDivElement>(null);
 
@@ -79,7 +80,6 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = f
             setOpen(false);
             onPrimary();
           }}
-          disabled={disabled}
           className="rounded-r-none"
         >
           {icon}
@@ -89,7 +89,6 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = f
           variant="secondary"
           size="sm"
           onClick={() => setOpen((prev) => !prev)}
-          disabled={disabled}
           aria-label={dropdownLabel}
           aria-haspopup="menu"
           aria-expanded={open}
@@ -126,12 +125,13 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, disabled = f
 
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
   const { t } = useTranslation();
+  const { showToast } = useToast();
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
   // Desktop "Open in Slicer" target — falls back to preferred_slicer when the
   // user hasn't explicitly chosen a different desktop slicer (#1329). This
   // variable is only used for URI-handoff; sidecar slicing keeps using
   // preferred_slicer directly.
-  const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
+  const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
   const isLibrary = libraryFileId != null;
   const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
   const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
@@ -372,7 +372,13 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     };
   }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
 
-  const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
+  // Which file types can be handed to a desktop slicer via the URL protocol
+  // handler — and sliced in-app via the sidecar. Kept in step with
+  // `isSliceableFilename()` in FileManagerPage so a file's card-menu "Slice"
+  // and its 3D-preview slicer button never disagree on the same type.
+  const normalizedFileType = (fileType || '').toLowerCase();
+  const slicerReadyType = ['3mf', 'stl', 'step', 'stp'].includes(normalizedFileType);
+  const canOpenInSlicer = isLibrary ? slicerReadyType : true;
 
   // When the user has the in-app Slicer API enabled (Settings → Workflow →
   // Slicer → Use Slicer API), library-mode previews route the header's slicer
@@ -380,25 +386,10 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   // in the file-row actions. Falls back to the external-slicer launcher when
   // the API is off, when no in-app handler is wired (e.g. archive preview),
   // or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
-  const sliceableType = (() => {
-    const t = (fileType || '').toLowerCase();
-    return t === '3mf' || t === 'stl' || t === 'step' || t === 'stp';
-  })();
   const useBambuddySlicer = Boolean(
-    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType,
+    isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && slicerReadyType,
   );
 
-  const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
-    ? ['bambu_studio', 'orcaslicer']
-    : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
-  const slicerName = (slicer: SlicerType) =>
-    slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
-  const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
-    key: slicer,
-    label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }),
-    onClick: () => handleOpenInSlicer(slicer),
-  }));
-
   const handleOpenInSlicer = async (slicer: SlicerType) => {
     if (!canOpenInSlicer) return;
     const filename = title || 'model';
@@ -413,7 +404,10 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
         openInSlicer(`${window.location.origin}${path}`, slicer);
       }
     } catch {
-      // Fallback to direct URL (works when auth is disabled)
+      // Fallback to direct URL (works when auth is disabled). With auth on the
+      // slicer may then hit a 401, so surface the failure instead of making a
+      // permission denial look identical to "no slicer installed".
+      showToast(t('modelViewer.openInSlicerFailed'), 'error');
       if (isLibrary) {
         const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
         openInSlicer(downloadUrl, slicer);
@@ -424,6 +418,17 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
     }
   };
 
+  const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
+    ? ['bambu_studio', 'orcaslicer']
+    : [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
+  const slicerName = (slicer: SlicerType) =>
+    slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
+  const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
+    key: slicer,
+    label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }),
+    onClick: () => handleOpenInSlicer(slicer),
+  }));
+
   return (
     <div
       className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
@@ -447,20 +452,13 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
           </div>
           <div className="flex items-center gap-2">
             {useBambuddySlicer ? (
-              canOpenInSlicer ? (
-                <SlicerSplitButton
-                  icon={<Cog className="w-4 h-4" />}
-                  label={t('slice.action')}
-                  dropdownLabel={t('modelViewer.moreSlicerOptions')}
-                  onPrimary={() => onSliceWithBambuddy?.()}
-                  items={slicerDropdownItems}
-                />
-              ) : (
-                <Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
-                  <Cog className="w-4 h-4" />
-                  {t('slice.action')}
-                </Button>
-              )
+              <SlicerSplitButton
+                icon={<Cog className="w-4 h-4" />}
+                label={t('slice.action')}
+                dropdownLabel={t('modelViewer.moreSlicerOptions')}
+                onPrimary={() => onSliceWithBambuddy?.()}
+                items={slicerDropdownItems}
+              />
             ) : canOpenInSlicer ? (
               <SlicerSplitButton
                 icon={<ExternalLink className="w-4 h-4" />}
@@ -470,7 +468,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
                 items={slicerDropdownItems}
               />
             ) : (
-              <Button variant="secondary" size="sm" onClick={() => handleOpenInSlicer(preferredSlicer)} disabled>
+              <Button variant="secondary" size="sm" disabled>
                 <ExternalLink className="w-4 h-4" />
                 {t('modelViewer.openInSlicer')}
               </Button>

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -3820,6 +3820,7 @@ export default {
     scanFolder: 'Scannen',
     toast: {
       folderCreated: 'Ordner erstellt',
+      openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
       folderDeleted: 'Ordner gelöscht',
       fileDeleted: 'Datei gelöscht',
       filesDeleted: '{{count}} Dateien gelöscht',
@@ -5360,6 +5361,7 @@ export default {
     openInSlicer: 'Im Slicer öffnen',
     openInSlicerWith: 'In {{slicer}} öffnen',
     moreSlicerOptions: 'Weitere Slicer-Optionen',
+    openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
     tabs: {
       model: '3D-Modell',
       gcode: 'G-Code Vorschau',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -3849,6 +3849,7 @@ export default {
     scanFolder: 'Scan',
     toast: {
       folderCreated: 'Folder created',
+      openInSlicerFailed: 'Could not open in slicer',
       folderDeleted: 'Folder deleted',
       fileDeleted: 'File deleted',
       filesDeleted: 'Deleted {{count}} files',
@@ -5404,6 +5405,7 @@ export default {
     openInSlicer: 'Open in Slicer',
     openInSlicerWith: 'Open in {{slicer}}',
     moreSlicerOptions: 'More slicer options',
+    openInSlicerFailed: 'Could not open in slicer',
     tabs: {
       model: '3D Model',
       gcode: 'G-code Preview',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -3823,6 +3823,7 @@ export default {
     scanFolder: 'Escanear',
     toast: {
       folderCreated: 'Carpeta creada',
+      openInSlicerFailed: 'No se pudo abrir en el laminador',
       folderDeleted: 'Carpeta eliminada',
       fileDeleted: 'Archivo eliminado',
       filesDeleted: 'Se eliminaron {{count}} archivos',
@@ -5369,6 +5370,7 @@ export default {
     openInSlicer: 'Abrir en el laminador',
     openInSlicerWith: 'Abrir en {{slicer}}',
     moreSlicerOptions: 'Más opciones de laminador',
+    openInSlicerFailed: 'No se pudo abrir en el laminador',
     tabs: {
       model: 'Modelo 3D',
       gcode: 'Vista previa de G-code',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -3809,6 +3809,7 @@ export default {
     scanFolder: 'Scanner',
     toast: {
       folderCreated: 'Dossier créé',
+      openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
       folderDeleted: 'Dossier supprimé',
       fileDeleted: 'Fichier supprimé',
       filesDeleted: '{{count}} fichiers supprimés',
@@ -5350,6 +5351,7 @@ export default {
     openInSlicer: 'Ouvrir dans le Slicer',
     openInSlicerWith: 'Ouvrir dans {{slicer}}',
     moreSlicerOptions: "Plus d'options de slicer",
+    openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
     tabs: {
       model: 'Modèle 3D',
       gcode: 'Aperçu G-code',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: 'Scansiona',
     toast: {
       folderCreated: 'Cartella creata',
+      openInSlicerFailed: 'Impossibile aprire nello slicer',
       folderDeleted: 'Cartella eliminata',
       fileDeleted: 'File eliminato',
       filesDeleted: 'Eliminati {{count}} file',
@@ -5349,6 +5350,7 @@ export default {
     openInSlicer: 'Apri nello slicer',
     openInSlicerWith: 'Apri in {{slicer}}',
     moreSlicerOptions: 'Altre opzioni dello slicer',
+    openInSlicerFailed: 'Impossibile aprire nello slicer',
     tabs: {
       model: 'Modello 3D',
       gcode: 'Anteprima G-code',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -3820,6 +3820,7 @@ export default {
     scanFolder: 'スキャン',
     toast: {
       folderCreated: 'フォルダを作成しました',
+      openInSlicerFailed: 'スライサーで開けませんでした',
       folderDeleted: 'フォルダを削除しました',
       fileDeleted: 'ファイルを削除しました',
       filesDeleted: '{{count}}件のファイルを削除しました',
@@ -5361,6 +5362,7 @@ export default {
     openInSlicer: 'スライサーで開く',
     openInSlicerWith: '{{slicer}}で開く',
     moreSlicerOptions: 'その他のスライサーオプション',
+    openInSlicerFailed: 'スライサーで開けませんでした',
     tabs: {
       model: '3Dモデル',
       gcode: 'G-codeプレビュー',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -3631,6 +3631,7 @@ export default {
     scanFolder: '스캔',
     toast: {
       folderCreated: '폴더 생성됨',
+      openInSlicerFailed: '슬라이서에서 열 수 없습니다',
       folderDeleted: '폴더 삭제됨',
       fileDeleted: '파일 삭제됨',
       filesDeleted: '{{count}}개 파일 삭제됨',
@@ -5096,6 +5097,7 @@ export default {
     openInSlicer: '슬라이서에서 열기',
     openInSlicerWith: '{{slicer}}에서 열기',
     moreSlicerOptions: '슬라이서 옵션 더 보기',
+    openInSlicerFailed: '슬라이서에서 열 수 없습니다',
     tabs: {
       model: '3D 모델',
       gcode: 'G-code 미리보기'

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: 'Escanear',
     toast: {
       folderCreated: 'Pasta criada',
+      openInSlicerFailed: 'Não foi possível abrir no fatiador',
       folderDeleted: 'Pasta excluída',
       fileDeleted: 'Arquivo excluído',
       filesDeleted: 'Excluídos {{count}} arquivos',
@@ -5349,6 +5350,7 @@ export default {
     openInSlicer: 'Abrir no Slicer',
     openInSlicerWith: 'Abrir em {{slicer}}',
     moreSlicerOptions: 'Mais opções de fatiador',
+    openInSlicerFailed: 'Não foi possível abrir no fatiador',
     tabs: {
       model: 'Modelo 3D',
       gcode: 'Pré-visualização G-code',

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -3623,6 +3623,7 @@ export default {
     scanFolder: "Сканировать",
     toast: {
       folderCreated: "Папка создана",
+      openInSlicerFailed: "Не удалось открыть в слайсере",
       folderDeleted: "Папка удалена",
       fileDeleted: "Файл удалён",
       filesDeleted: "Удалено файлов: {{count}}",
@@ -5084,6 +5085,7 @@ export default {
     openInSlicer: "Открыть в слайсере",
     openInSlicerWith: "Открыть в {{slicer}}",
     moreSlicerOptions: "Другие варианты слайсера",
+    openInSlicerFailed: "Не удалось открыть в слайсере",
     tabs: {
       model: "3D-модель",
       gcode: "Предпросмотр G-code",

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -3816,6 +3816,7 @@ export default {
     scanFolder: 'Tara',
     toast: {
       folderCreated: 'Klasör oluşturuldu',
+      openInSlicerFailed: 'Dilimleyicide açılamadı',
       folderDeleted: 'Klasör silindi',
       fileDeleted: 'Dosya silindi',
       filesDeleted: '{{count}} dosya silindi',
@@ -5325,6 +5326,7 @@ export default {
     openInSlicer: 'Dilimleyicide Aç',
     openInSlicerWith: '{{slicer}} ile aç',
     moreSlicerOptions: 'Diğer dilimleyici seçenekleri',
+    openInSlicerFailed: 'Dilimleyicide açılamadı',
     tabs: {
       model: '3B Model',
       gcode: 'G-kod Önizleme',

+ 2 - 0
frontend/src/i18n/locales/uk.ts

@@ -3849,6 +3849,7 @@ export default {
     scanFolder: "Сканувати",
     toast: {
       folderCreated: "Папка створена",
+      openInSlicerFailed: "Не вдалося відкрити у слайсері",
       folderDeleted: "Папку видалено",
       fileDeleted: "Файл видалено",
       filesDeleted: "Видалені файли {{count}}.",
@@ -5404,6 +5405,7 @@ export default {
     openInSlicer: "Відкрити у слайсері",
     openInSlicerWith: "Відкрити у {{slicer}}",
     moreSlicerOptions: "Більше варіантів слайсера",
+    openInSlicerFailed: "Не вдалося відкрити у слайсері",
     tabs: {
       model: "3D-модель",
       gcode: "Попередній перегляд G-коду",

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: '扫描',
     toast: {
       folderCreated: '文件夹已创建',
+      openInSlicerFailed: '无法在切片软件中打开',
       folderDeleted: '文件夹已删除',
       fileDeleted: '文件已删除',
       filesDeleted: '已删除 {{count}} 个文件',
@@ -5349,6 +5350,7 @@ export default {
     openInSlicer: '在切片软件中打开',
     openInSlicerWith: '用{{slicer}}打开',
     moreSlicerOptions: '更多切片软件选项',
+    openInSlicerFailed: '无法在切片软件中打开',
     tabs: {
       model: '3D 模型',
       gcode: 'G-code 预览',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3808,6 +3808,7 @@ export default {
     scanFolder: '掃描',
     toast: {
       folderCreated: '資料夾已建立',
+      openInSlicerFailed: '無法在切片軟體中開啟',
       folderDeleted: '資料夾已刪除',
       fileDeleted: '檔案已刪除',
       filesDeleted: '已刪除 {{count}} 個檔案',
@@ -5349,6 +5350,7 @@ export default {
     openInSlicer: '在切片軟體中開啟',
     openInSlicerWith: '用{{slicer}}開啟',
     moreSlicerOptions: '更多切片軟體選項',
+    openInSlicerFailed: '無法在切片軟體中開啟',
     tabs: {
       model: '3D 模型',
       gcode: 'G-code 預覽',

+ 2 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -64,7 +64,7 @@ import {
 import { api } from '../api/client';
 import { SliceModal } from '../components/SliceModal';
 import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
@@ -2956,7 +2956,7 @@ export function ArchivesPage() {
   // user hasn't explicitly chosen a different desktop slicer (#1329). This is
   // ONLY the URI-handoff target; the in-app SliceModal still uses
   // preferred_slicer for the sidecar.
-  const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
+  const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
   const useSlicerApi = settings?.use_slicer_api ?? false;
   const currency = getCurrencySymbol(settings?.currency || 'USD');
 

+ 21 - 13
frontend/src/pages/FileManagerPage.tsx

@@ -74,7 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -764,6 +764,7 @@ interface FileCardProps {
   onOpenInSlicer?: (file: LibraryFileListItem) => void;
   onRunPipeline?: (file: LibraryFileListItem) => void;
   useSlicerApi?: boolean;
+  canSlice?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
@@ -776,11 +777,9 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
-  const sliceDisabled = useSlicerApi ? !hasPermission('library:upload') : !hasPermission('library:read');
-
   return (
     <div
       className={`group relative bg-bambu-dark-secondary rounded-lg border transition-all cursor-pointer overflow-hidden ${
@@ -904,19 +903,19 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
                   {t('common.print')}
                 </button>
               )}
-              {isSliceableFilename(file.filename) && (onSlice || onOpenInSlicer) && (
+              {isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    !sliceDisabled ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    canSlice ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
                   onClick={() => {
-                    if (sliceDisabled) return;
+                    if (!canSlice) return;
                     if (useSlicerApi) onSlice?.(file);
                     else onOpenInSlicer?.(file);
                     setShowActions(false);
                   }}
-                  disabled={sliceDisabled}
-                  title={sliceDisabled ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
+                  disabled={!canSlice}
+                  title={!canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
                 >
                   {useSlicerApi ? <Cog className="w-3.5 h-3.5" /> : <ExternalLink className="w-3.5 h-3.5" />}
                   {t('slice.action')}
@@ -1156,7 +1155,7 @@ export function FileManagerPage() {
     queryFn: () => api.getSettings() as Promise<AppSettings>,
   });
 
-  const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
+  const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
 
   const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => {
     try {
@@ -1164,18 +1163,26 @@ export function FileManagerPage() {
       const path = api.getLibrarySlicerDownloadUrl(file.id, token, file.filename);
       openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
     } catch {
+      // Fallback to direct URL (works when auth is disabled). With auth on the
+      // slicer may then hit a 401, so surface the failure instead of making a
+      // permission denial look identical to "no slicer installed".
+      showToast(t('fileManager.toast.openInSlicerFailed'), 'error');
       const path = api.getLibraryFileDownloadUrl(file.id);
       openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
     }
-  }, [preferredSlicer]);
+  }, [preferredSlicer, showToast, t]);
 
   // Slice permission: API mode needs upload rights, desktop handoff is a download.
+  // The handoff mirrors the backend's ownership check on the slicer-token
+  // endpoint (library:read_all / library:read_own); `library:read` is a legacy
+  // permission default groups don't carry, so requiring it would disable the
+  // handoff for Operators and Viewers.
   const canSlice = useCallback(() => {
     if (settings?.use_slicer_api) {
       return hasPermission('library:upload');
     }
-    return hasPermission('library:read');
-  }, [settings?.use_slicer_api, hasPermission]);
+    return hasAnyPermission('library:read_all', 'library:read_own', 'library:read');
+  }, [settings?.use_slicer_api, hasPermission, hasAnyPermission]);
   const { data: folders, isLoading: foldersLoading } = useQuery({
     queryKey: ['library-folders'],
     queryFn: () => api.getLibraryFolders(),
@@ -2454,6 +2461,7 @@ export function FileManagerPage() {
                     onOpenInSlicer={handleOpenInSlicer}
                     onRunPipeline={setRunPipelineFile}
                     useSlicerApi={settings?.use_slicer_api ?? false}
+                    canSlice={canSlice()}
                     onPreview3d={(f) => {
                       // Sliced files (.gcode / .gcode.3mf) open the same
                       // full-page gcode viewer the archive card uses, so

+ 2 - 2
frontend/src/pages/MakerworldPage.tsx

@@ -11,7 +11,7 @@ import {
   type MakerworldRecentImport,
   type MakerworldResolvedModel,
 } from '../api/client';
-import { openInSlicer, type SlicerType } from '../utils/slicer';
+import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -185,7 +185,7 @@ export function MakerworldPage() {
   // whichever slicer this button actually drives — depends on useSlicerApi.
   const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
   const apiSlicer: SlicerType = settingsQuery.data?.preferred_slicer || 'bambu_studio';
-  const desktopSlicer: SlicerType = settingsQuery.data?.open_in_slicer || apiSlicer;
+  const desktopSlicer: SlicerType = resolveDesktopSlicer(settingsQuery.data?.open_in_slicer, settingsQuery.data?.preferred_slicer);
   const preferredSlicer: SlicerType = useSlicerApi ? apiSlicer : desktopSlicer;
   const preferredSlicerName =
     preferredSlicer === 'orcaslicer' ? 'OrcaSlicer' : 'Bambu Studio';

+ 13 - 0
frontend/src/utils/slicer.ts

@@ -28,6 +28,19 @@ export type SlicerType = 'bambu_studio' | 'orcaslicer';
 
 type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
 
+/**
+ * Resolve the desktop "Open in Slicer" target. Prefers an explicit
+ * `open_in_slicer` override (#1329), then falls back to the API slicer's
+ * `preferred_slicer`, then Bambu Studio. This is ONLY the URI-handoff target;
+ * the in-app SliceModal keeps using `preferred_slicer` for the sidecar.
+ */
+export function resolveDesktopSlicer(
+  openInSlicer?: SlicerType | null,
+  preferredSlicer?: SlicerType,
+): SlicerType {
+  return openInSlicer ?? preferredSlicer ?? 'bambu_studio';
+}
+
 /**
  * Detect the user's operating system
  */