Просмотр исходного кода

Security hardening (maziggy/bambuddy-security #39)

maziggy 6 дней назад
Родитель
Сommit
5782e46639
86 измененных файлов с 241 добавлено и 8 удалено
  1. 11 0
      backend/app/api/routes/archives.py
  2. 139 0
      backend/tests/integration/test_archive_cost_fields_by_owner.py
  3. 63 1
      frontend/src/__tests__/components/EditArchiveModal.test.tsx
  4. 10 2
      frontend/src/components/EditArchiveModal.tsx
  5. 1 0
      frontend/src/i18n/locales/de.ts
  6. 1 0
      frontend/src/i18n/locales/en.ts
  7. 1 0
      frontend/src/i18n/locales/es.ts
  8. 1 0
      frontend/src/i18n/locales/fr.ts
  9. 1 0
      frontend/src/i18n/locales/it.ts
  10. 1 0
      frontend/src/i18n/locales/ja.ts
  11. 1 0
      frontend/src/i18n/locales/ko.ts
  12. 1 0
      frontend/src/i18n/locales/nl.ts
  13. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  14. 1 0
      frontend/src/i18n/locales/ru.ts
  15. 1 0
      frontend/src/i18n/locales/sv.ts
  16. 1 0
      frontend/src/i18n/locales/tr.ts
  17. 1 0
      frontend/src/i18n/locales/uk.ts
  18. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  19. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  20. 0 0
      static/assets/AdditionalSection-NvWEni3f.js
  21. 0 0
      static/assets/AmsUnitCard-I81ah2ip.js
  22. 0 0
      static/assets/ArchivesPage-e1lq3Hwe.js
  23. 0 0
      static/assets/AssignToAmsModal-BjW17NBF.js
  24. 0 0
      static/assets/CamWallPage-C6FQRN_Q.js
  25. 0 0
      static/assets/CameraDiagnoseModal-BD0C5dsA.js
  26. 0 0
      static/assets/CameraPage-dRwts9Kf.js
  27. 0 0
      static/assets/CameraWall-cECotNXX.js
  28. 0 0
      static/assets/ColumnConfigModal-B5IVKg3e.js
  29. 0 0
      static/assets/ConfigureAmsSlotModal-CV3dFcV2.js
  30. 0 0
      static/assets/ConnectAuthorizePage-4YBTZsND.js
  31. 0 0
      static/assets/ContextMenu-BTSQ58LF.js
  32. 0 0
      static/assets/ExternalLinkPage-_rwGvJH6.js
  33. 0 1
      static/assets/FileManagerPage-DWNgcbjM.js
  34. 0 0
      static/assets/FileUploadModal-DrAhAX-E.js
  35. 0 0
      static/assets/FinancePage-dhn799Sw.js
  36. 0 0
      static/assets/GCodeViewerPage-DMzV-UMr.js
  37. 0 0
      static/assets/GroupEditPage-sBjuWe1z.js
  38. 0 0
      static/assets/ImagePreviewModal-s9tYw9cC.js
  39. 0 0
      static/assets/InventoryPage-D_ZkChSL.js
  40. 0 0
      static/assets/LibraryTrashPage-BHnFZujh.js
  41. 0 0
      static/assets/LoginPage-0w4Ks61G.js
  42. 0 0
      static/assets/MaintenancePage-DXJasg5e.js
  43. 0 0
      static/assets/ModelSourcesPage-ykcPRwBS.js
  44. 0 0
      static/assets/NotificationsPage-QqMTORjY.js
  45. 0 1
      static/assets/PdfPreviewModal-Dbyn1I8n.js
  46. 0 0
      static/assets/PhotoGalleryModal-C4b7n-od.js
  47. 0 0
      static/assets/PrinterLocationsPage-C4zDiaJQ.js
  48. 0 0
      static/assets/PrintersPage-D1posYsG.js
  49. 0 0
      static/assets/ProfilesPage-K0hNYpN1.js
  50. 0 0
      static/assets/ProjectDetailPage-DLLkHg9U.js
  51. 0 0
      static/assets/ProjectsPage-CYDQWgzk.js
  52. 0 0
      static/assets/QueuePage-BYPQpLWT.js
  53. 0 0
      static/assets/RichTextEditor-D1qLu_1H.js
  54. 0 0
      static/assets/SettingsPage-DTFOg6ih.js
  55. 0 0
      static/assets/SetupPage-DoSkNMSz.js
  56. 0 0
      static/assets/SliceModal-C_oCay21.js
  57. 0 0
      static/assets/SpoolBuddyAmsPage-DGsn1-hC.js
  58. 0 0
      static/assets/SpoolBuddyCalibrationPage-BfDNotZr.js
  59. 0 0
      static/assets/SpoolBuddyDashboard-BXTh3VZR.js
  60. 0 0
      static/assets/SpoolBuddyInventoryPage-t5uZdk6I.js
  61. 1 1
      static/assets/SpoolBuddySettingsPage-Bl-Ytz52.js
  62. 0 0
      static/assets/SpoolBuddyWriteTagPage-D9bZn7rH.js
  63. 0 0
      static/assets/SpreadsheetPreviewModal-B-ArdfWe.js
  64. 0 0
      static/assets/StatsPage-DCXY1ndf.js
  65. 0 0
      static/assets/StreamOverlayPage-jb28HGv6.js
  66. 0 0
      static/assets/SystemInfoPage-ChREr5bw.js
  67. 0 0
      static/assets/de-B5PD1nV9.js
  68. 0 0
      static/assets/es-C2EAQisu.js
  69. 0 0
      static/assets/fr-sh2oOClc.js
  70. 0 0
      static/assets/index-ohIj6le-.js
  71. 1 1
      static/assets/inventorySearch-CbvL5APk.js
  72. 0 0
      static/assets/it-DgTXaOlw.js
  73. 0 0
      static/assets/ja-D0KrNphO.js
  74. 0 0
      static/assets/ko-CRRBf-Pd.js
  75. 0 0
      static/assets/locationSensorDefaults-CZzyK3VO.js
  76. 0 0
      static/assets/nl-Dg6mqVom.js
  77. 0 0
      static/assets/pdf-CkX306dm.js
  78. 0 0
      static/assets/pt-BR-BlXI84o5.js
  79. 0 0
      static/assets/ru-DVjTv0eJ.js
  80. 0 0
      static/assets/sv-D-8Gw-l7.js
  81. 0 0
      static/assets/tr-DKGsTdh_.js
  82. 0 0
      static/assets/uk-DdthraQj.js
  83. 0 0
      static/assets/utils-CBa833Oq.js
  84. 0 0
      static/assets/zh-CN-NbP9JgJn.js
  85. 0 0
      static/assets/zh-TW-BN0Qh71v.js
  86. 1 1
      static/index.html

+ 11 - 0
backend/app/api/routes/archives.py

@@ -1935,6 +1935,16 @@ async def update_archive(
     previous_filament_grams = archive.filament_used_grams
 
     update_payload = update_data.model_dump(exclude_unset=True)
+    # Later runs of the archive are charged from its cost and weight: a caller
+    # limited to their own archives sets neither. The one exception is the
+    # weight of an archive without its 3MF (#1820), which has none and cannot
+    # be printed again.
+    if not can_modify_all:
+        if "cost" in update_payload:
+            raise HTTPException(403, "Only users who can edit all archives can set an archive's cost")
+        if "filament_used_grams" in update_payload and update_payload["filament_used_grams"] != previous_filament_grams:
+            if previous_filament_grams is not None or archive.file_path:
+                raise HTTPException(403, "Only users who can edit all archives can change an archive's filament weight")
     # A new printer must be one the caller may use (as on upload), and both
     # targets must exist. None clears either: a printer-less archive is in
     # every scope, like one uploaded without a printer.
@@ -1958,6 +1968,7 @@ async def update_archive(
         update_payload.get("filament_used_grams")
         and "cost" not in update_payload
         and update_payload["filament_used_grams"] != previous_filament_grams
+        and (can_modify_all or archive.cost is None)
     ):
         archive.cost = await _one_print_cost(db, archive, previous_grams=previous_filament_grams)
         repriced = True

+ 139 - 0
backend/tests/integration/test_archive_cost_fields_by_owner.py

@@ -0,0 +1,139 @@
+"""Which archive figures a user who may only edit their own archives can set.
+
+The archive's cost and filament weight are what later runs of it are charged
+from. Callers who may edit every archive set both; callers limited to their
+own archives may fill in a weight only for an archive without its 3MF, which
+has none and cannot be printed again.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth, _group, _user
+
+OWN = ["archives:read_own", "archives:update_own"]
+
+
+async def _owner_and_archive(async_client, archive_factory, printer_factory, *, grams, cost, file_path=None):
+    admin = await _admin_token(async_client)
+    group = await _group(async_client, admin, "own_archives", permissions=OWN)
+    jwt, user_id = await _user(async_client, admin, "owner", [group])
+    printer = await printer_factory()
+    extra = {} if file_path is None else {"file_path": file_path}
+    archive = await archive_factory(
+        printer.id, created_by_id=user_id, filament_used_grams=grams, cost=cost, filament_type="PLA", **extra
+    )
+    return admin, jwt, archive.id
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_owner_cannot_set_the_cost(async_client: AsyncClient, archive_factory, printer_factory):
+    _, jwt, archive_id = await _owner_and_archive(async_client, archive_factory, printer_factory, grams=100.0, cost=2.5)
+
+    response = await async_client.patch(f"/api/v1/archives/{archive_id}", headers=_auth(jwt), json={"cost": 0.01})
+
+    assert response.status_code == 403
+    after = await async_client.get(f"/api/v1/archives/{archive_id}", headers=_auth(jwt))
+    assert after.json()["cost"] == 2.5
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_owner_cannot_change_an_existing_weight(async_client: AsyncClient, archive_factory, printer_factory):
+    _, jwt, archive_id = await _owner_and_archive(async_client, archive_factory, printer_factory, grams=100.0, cost=2.5)
+
+    response = await async_client.patch(
+        f"/api/v1/archives/{archive_id}", headers=_auth(jwt), json={"filament_used_grams": 90000}
+    )
+
+    assert response.status_code == 403
+    after = await async_client.get(f"/api/v1/archives/{archive_id}", headers=_auth(jwt))
+    assert (after.json()["filament_used_grams"], after.json()["cost"]) == (100.0, 2.5)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_owner_fills_a_missing_weight(async_client: AsyncClient, archive_factory, printer_factory):
+    _, jwt, archive_id = await _owner_and_archive(
+        async_client, archive_factory, printer_factory, grams=None, cost=None, file_path=""
+    )
+
+    response = await async_client.patch(
+        f"/api/v1/archives/{archive_id}", headers=_auth(jwt), json={"filament_used_grams": 120}
+    )
+
+    assert response.status_code == 200
+    assert (response.json()["filament_used_grams"], response.json()["cost"]) == (120, 3.0)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_filling_the_weight_keeps_an_existing_cost(async_client: AsyncClient, archive_factory, printer_factory):
+    _, jwt, archive_id = await _owner_and_archive(
+        async_client, archive_factory, printer_factory, grams=None, cost=7.0, file_path=""
+    )
+
+    response = await async_client.patch(
+        f"/api/v1/archives/{archive_id}", headers=_auth(jwt), json={"filament_used_grams": 120}
+    )
+
+    assert response.status_code == 200
+    assert response.json()["cost"] == 7.0
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_owner_still_edits_everything_else(async_client: AsyncClient, archive_factory, printer_factory):
+    _, jwt, archive_id = await _owner_and_archive(async_client, archive_factory, printer_factory, grams=100.0, cost=2.5)
+
+    response = await async_client.patch(
+        f"/api/v1/archives/{archive_id}",
+        headers=_auth(jwt),
+        json={"notes": "fine", "filament_used_grams": 100.0},
+    )
+
+    assert response.status_code == 200
+    assert response.json()["notes"] == "fine"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_editing_every_archive_sets_both(async_client: AsyncClient, archive_factory, printer_factory):
+    admin, _, archive_id = await _owner_and_archive(
+        async_client, archive_factory, printer_factory, grams=100.0, cost=2.5
+    )
+
+    weight = await async_client.patch(
+        f"/api/v1/archives/{archive_id}", headers=_auth(admin), json={"filament_used_grams": 200}
+    )
+    cost = await async_client.patch(f"/api/v1/archives/{archive_id}", headers=_auth(admin), json={"cost": 9.99})
+
+    assert (weight.status_code, weight.json()["cost"]) == (200, 5.0)
+    assert (cost.status_code, cost.json()["cost"]) == (200, 9.99)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_owner_cannot_fill_the_weight_of_an_archive_with_its_3mf(
+    async_client: AsyncClient, archive_factory, printer_factory
+):
+    """It can be printed again, and those runs are charged from the weight."""
+    _, jwt, archive_id = await _owner_and_archive(async_client, archive_factory, printer_factory, grams=None, cost=None)
+
+    response = await async_client.patch(
+        f"/api/v1/archives/{archive_id}", headers=_auth(jwt), json={"filament_used_grams": 0.01}
+    )
+
+    assert response.status_code == 403
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("body", [{"filament_used_grams": None}, {"cost": None}])
+async def test_owner_cannot_clear_either_figure(async_client: AsyncClient, archive_factory, printer_factory, body):
+    _, jwt, archive_id = await _owner_and_archive(async_client, archive_factory, printer_factory, grams=100.0, cost=2.5)
+
+    response = await async_client.patch(f"/api/v1/archives/{archive_id}", headers=_auth(jwt), json=body)
+
+    assert response.status_code == 403

+ 63 - 1
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -2,13 +2,14 @@
  * Tests for the EditArchiveModal component.
  */
 
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { EditArchiveModal } from '../../components/EditArchiveModal';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
 
 const mockArchive = {
   id: 1,
@@ -327,6 +328,67 @@ describe('EditArchiveModal', () => {
       return seen;
     }
 
+    describe('without the right to edit every archive', () => {
+      let meServed = false;
+
+      const signInWith = (me: Record<string, unknown>) => {
+        meServed = false;
+        setAuthToken('own-archives-token', 'session');
+        server.use(
+          http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+          http.get('/api/v1/auth/me', () => {
+            meServed = true;
+            return HttpResponse.json({
+              id: 7,
+              username: 'owner',
+              is_active: true,
+              groups: [],
+              created_at: '2026-10-16T00:00:00Z',
+              ...me,
+            });
+          }),
+        );
+      };
+      const owner = { is_admin: false, permissions: ['archives:read_own', 'archives:update_own'] };
+
+      afterEach(() => {
+        setAuthToken(null);
+      });
+
+      it('cannot change a weight the archive already has', async () => {
+        signInWith(owner);
+        render(<EditArchiveModal archive={{ ...mockArchive, filament_used_grams: 50 }} onClose={mockOnClose} onSave={mockOnSave} />);
+
+        await waitFor(() => expect(screen.getByLabelText(/filament used/i)).toBeDisabled());
+        expect(screen.getByText(/only users who can edit all archives can change this figure/i)).toBeInTheDocument();
+      });
+
+      it('cannot fill in the weight of an archive that has its 3MF', async () => {
+        signInWith(owner);
+        render(<EditArchiveModal archive={{ ...mockArchive, file_path: 'archive/1/benchy.gcode.3mf' }} onClose={mockOnClose} onSave={mockOnSave} />);
+
+        await waitFor(() => expect(screen.getByLabelText(/filament used/i)).toBeDisabled());
+      });
+
+      it('can fill in the weight of an archive without its 3MF', async () => {
+        signInWith(owner);
+        render(<EditArchiveModal archive={{ ...mockArchive, file_path: '' }} onClose={mockOnClose} onSave={mockOnSave} />);
+
+        await waitFor(() => expect(meServed).toBe(true));
+        await new Promise((resolve) => setTimeout(resolve, 0));
+        expect(screen.getByLabelText(/filament used/i)).toBeEnabled();
+      });
+
+      it('stays editable for an admin', async () => {
+        signInWith({ is_admin: true, permissions: [] });
+        render(<EditArchiveModal archive={{ ...mockArchive, filament_used_grams: 50 }} onClose={mockOnClose} onSave={mockOnSave} />);
+
+        await waitFor(() => expect(meServed).toBe(true));
+        await new Promise((resolve) => setTimeout(resolve, 0));
+        expect(screen.getByLabelText(/filament used/i)).toBeEnabled();
+      });
+    });
+
     it('sends a figure typed for an archive that has none', async () => {
       const user = userEvent.setup();
       const seen = patchSpy();

+ 10 - 2
frontend/src/components/EditArchiveModal.tsx

@@ -3,6 +3,7 @@ import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link, Weight } from 'lucide-react';
 import { api } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
 import type { Archive } from '../api/client';
 import { Button } from './Button';
 import { PrintLogTable } from './PrintLogTable';
@@ -101,6 +102,12 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const [filamentGrams, setFilamentGrams] = useState(
     archive.filament_used_grams != null ? String(archive.filament_used_grams) : ''
   );
+  const { hasPermission } = useAuth();
+  // Later prints of the archive are charged from its weight: without the right
+  // to edit every archive, it can only be filled in for an archive without its
+  // 3MF, which has none and cannot be printed again (the API refuses the rest).
+  const filamentGramsLocked =
+    !hasPermission('archives:update_all') && (archive.filament_used_grams != null || !!archive.file_path);
   const [photos, setPhotos] = useState<string[]>(archive.photos || []);
   const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
   const [uploadingPhoto, setUploadingPhoto] = useState(false);
@@ -422,11 +429,12 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
                 if (current === '' || !Number.isFinite(parsed)) return '';
                 return String(Math.min(parsed, MAX_FILAMENT_GRAMS));
               })}
-              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
               placeholder={t('editArchive.filamentUsedPlaceholder')}
+              disabled={filamentGramsLocked}
             />
             <p className="text-xs text-bambu-gray mt-1">
-              {t('editArchive.filamentUsedHelp')}
+              {filamentGramsLocked ? t('editArchive.filamentUsedLocked') : t('editArchive.filamentUsedHelp')}
             </p>
           </div>
 

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

@@ -5928,6 +5928,7 @@ export default {
     filamentUsed: 'Verbrauchtes Filament (g)',
     filamentUsedPlaceholder: 'z. B. 46.16',
     filamentUsedHelp: 'Von Hand eintragen, wenn ein Druck ohne 3MF archiviert wurde, damit er in die Filament-Summen eingeht. Beim erneuten Einlesen eines Archivs mit 3MF wird der Wert wieder aus der Datei übernommen.',
+    filamentUsedLocked: 'Nur Benutzer, die alle Archive bearbeiten dürfen, können diesen Wert ändern: Spätere Drucke dieses Archivs werden danach berechnet.',
     notes: 'Notizen',
     notesPlaceholder: 'Notizen zu diesem Druck hinzufügen...',
     externalLink: 'Externer Link',

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

@@ -5981,6 +5981,7 @@ export default {
     filamentUsed: 'Filament used (g)',
     filamentUsedPlaceholder: 'e.g. 46.16',
     filamentUsedHelp: 'Set this by hand when a print archived without its 3MF, so it still counts towards the filament totals. Rescanning an archive that has its 3MF reads the figure back from the file.',
+    filamentUsedLocked: 'Only users who can edit all archives can change this figure: later prints of this archive are charged from it.',
     notes: 'Notes',
     notesPlaceholder: 'Add notes about this print...',
     externalLink: 'External Link',

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

@@ -5936,6 +5936,7 @@ export default {
     filamentUsed: 'Filamento usado (g)',
     filamentUsedPlaceholder: 'p. ej. 46.16',
     filamentUsedHelp: 'Introdúcelo a mano cuando una impresión se haya archivado sin su 3MF, para que siga contando en los totales de filamento. Al volver a escanear un archivo que sí tiene su 3MF, el valor se lee de nuevo del fichero.',
+    filamentUsedLocked: 'Solo los usuarios que pueden editar todos los archivos pueden cambiar este valor: las impresiones posteriores de este archivo se cobran a partir de él.',
     notes: 'Notas',
     notesPlaceholder: 'Añada notas sobre esta impresión...',
     externalLink: 'Enlace externo',

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

@@ -5916,6 +5916,7 @@ export default {
     filamentUsed: 'Filament utilisé (g)',
     filamentUsedPlaceholder: 'p. ex. 46.16',
     filamentUsedHelp: 'À saisir à la main lorsqu\'une impression a été archivée sans son 3MF, afin qu\'elle compte quand même dans les totaux de filament. Une nouvelle analyse d\'une archive qui possède son 3MF relit la valeur depuis le fichier.',
+    filamentUsedLocked: 'Seuls les utilisateurs autorisés à modifier toutes les archives peuvent changer cette valeur : les impressions suivantes de cette archive sont facturées à partir d\'elle.',
     notes: 'Notes',
     notesPlaceholder: 'Notes sur l\'impression...',
     externalLink: 'Lien externe',

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

@@ -5915,6 +5915,7 @@ export default {
     filamentUsed: 'Filamento usato (g)',
     filamentUsedPlaceholder: 'es. 46.16',
     filamentUsedHelp: 'Inseriscilo a mano quando una stampa è stata archiviata senza il suo 3MF, così da farla rientrare comunque nei totali del filamento. La riscansione di un archivio che ha il suo 3MF rilegge il valore dal file.',
+    filamentUsedLocked: 'Solo gli utenti che possono modificare tutti gli archivi possono cambiare questo valore: le stampe successive di questo archivio vengono addebitate in base ad esso.',
     notes: 'Note',
     notesPlaceholder: 'Aggiungi note su questa stampa...',
     externalLink: 'Link esterno',

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

@@ -5929,6 +5929,7 @@ export default {
     filamentUsed: '使用フィラメント (g)',
     filamentUsedPlaceholder: '例: 46.16',
     filamentUsedHelp: '3MF なしでアーカイブされた印刷は、手入力するとフィラメント合計に反映されます。3MF があるアーカイブを再スキャンすると、この値はファイルから読み直されます。',
+    filamentUsedLocked: 'この値を変更できるのは、すべてのアーカイブを編集できるユーザーだけです。このアーカイブの以降の印刷はこの値に基づいて課金されます。',
     notes: 'メモ',
     notesPlaceholder: 'この印刷についてメモを追加...',
     externalLink: '外部リンク',

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

@@ -5676,6 +5676,7 @@ export default {
     filamentUsed: '사용된 필라멘트 (g)',
     filamentUsedPlaceholder: '예: 46.16',
     filamentUsedHelp: '3MF 없이 보관된 인쇄는 직접 입력하면 필라멘트 합계에 반영됩니다. 3MF가 있는 보관 항목을 다시 스캔하면 이 값은 파일에서 다시 읽어옵니다.',
+    filamentUsedLocked: '모든 아카이브를 편집할 수 있는 사용자만 이 값을 변경할 수 있습니다. 이 아카이브의 이후 출력은 이 값을 기준으로 청구됩니다.',
     notes: '메모',
     notesPlaceholder: '이 인쇄에 대한 메모 추가...',
     externalLink: '외부 링크',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -5979,6 +5979,7 @@ export default {
     filamentUsed: 'Gebruikt filament (g)',
     filamentUsedPlaceholder: 'bijv. 46.16',
     filamentUsedHelp: 'Stel dit handmatig in wanneer een afdruk zonder 3MF is gearchiveerd, zodat deze toch meetelt in de filamenttotalen. Bij het opnieuw scannen van een archief met een 3MF wordt de waarde weer uit het bestand gelezen.',
+    filamentUsedLocked: 'Alleen gebruikers die alle archieven mogen bewerken, kunnen deze waarde wijzigen: latere afdrukken van dit archief worden ermee afgerekend.',
     notes: 'Notities',
     notesPlaceholder: 'Voeg notities over deze afdruk toe...',
     externalLink: 'Externe link',

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

@@ -5915,6 +5915,7 @@ export default {
     filamentUsed: 'Filamento usado (g)',
     filamentUsedPlaceholder: 'ex.: 46.16',
     filamentUsedHelp: 'Informe manualmente quando uma impressão for arquivada sem o 3MF, para que ela ainda conte nos totais de filamento. Reescanear um arquivo que tem o 3MF lê o valor novamente do arquivo.',
+    filamentUsedLocked: 'Somente usuários que podem editar todos os arquivos podem alterar este valor: as impressões seguintes deste arquivo são cobradas com base nele.',
     notes: 'Notas',
     notesPlaceholder: 'Adicione notas sobre esta impressão...',
     externalLink: 'Link Externo',

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

@@ -5664,6 +5664,7 @@ export default {
     filamentUsed: "Израсходованный филамент (г)",
     filamentUsedPlaceholder: "напр. 46.16",
     filamentUsedHelp: "Укажите вручную, если печать заархивирована без 3MF — тогда она попадёт в итоги по филаменту. При повторном сканировании архива с 3MF значение снова считывается из файла.",
+    filamentUsedLocked: "Изменить это значение могут только пользователи, которым разрешено редактировать все архивы: по нему рассчитываются последующие печати этого архива.",
     notes: "Заметки",
     notesPlaceholder: "Добавьте заметки об этой печати...",
     externalLink: "Внешняя ссылка",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -5977,6 +5977,7 @@ errors: {
     filamentUsed: 'Filament använt (g)',
     filamentUsedPlaceholder: 't.ex. 46,16',
     filamentUsedHelp: 'Ställ in detta manuellt när en utskrift arkiveras utan sin 3MF, så att den fortfarande räknas mot filamenttotalen. Om man skannar om ett arkiv som har sin 3MF läser man tillbaka siffran från filen.',
+    filamentUsedLocked: 'Endast användare som får redigera alla arkiv kan ändra detta värde: senare utskrifter av arkivet debiteras utifrån det.',
     notes: 'Anteckningar',
     notesPlaceholder: 'Lägg till anteckningar om denna utskrift...',
     externalLink: 'Extern länk',

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

@@ -5900,6 +5900,7 @@ export default {
     filamentUsed: 'Kullanılan filament (g)',
     filamentUsedPlaceholder: 'ör. 46.16',
     filamentUsedHelp: 'Bir baskı 3MF dosyası olmadan arşivlendiyse elle girin; böylece filament toplamlarına yine de dahil olur. 3MF dosyası olan bir arşiv yeniden tarandığında bu değer dosyadan yeniden okunur.',
+    filamentUsedLocked: 'Bu değeri yalnızca tüm arşivleri düzenleyebilen kullanıcılar değiştirebilir: bu arşivin sonraki baskıları buna göre ücretlendirilir.',
     notes: 'Notlar',
     notesPlaceholder: 'Bu baskı hakkında notlar ekleyin...',
     externalLink: 'Harici Bağlantı',

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

@@ -5969,6 +5969,7 @@ export default {
     filamentUsed: "Витрачений філамент (г)",
     filamentUsedPlaceholder: "напр. 46.16",
     filamentUsedHelp: "Вкажіть вручну, якщо друк заархівовано без 3MF — тоді він потрапить до підсумків з філаменту. Повторне сканування архіву, який має 3MF, зчитує значення з файлу.",
+    filamentUsedLocked: "Змінити це значення можуть лише користувачі, яким дозволено редагувати всі архіви: за ним розраховуються наступні друки цього архіву.",
     notes: "Примітки",
     notesPlaceholder: "Додайте примітки щодо цього друку...",
     externalLink: "Зовнішнє посилання",

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

@@ -5915,6 +5915,7 @@ export default {
     filamentUsed: '已用耗材 (g)',
     filamentUsedPlaceholder: '例如 46.16',
     filamentUsedHelp: '当打印任务在没有 3MF 的情况下归档时,可手动填写,使其仍计入耗材总量。重新扫描带有 3MF 的归档时,该数值会从文件中重新读取。',
+    filamentUsedLocked: '只有可以编辑所有归档的用户才能更改此数值:此归档之后的打印将据此计费。',
     notes: '备注',
     notesPlaceholder: '添加关于此打印的备注...',
     externalLink: '外部链接',

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

@@ -5915,6 +5915,7 @@ export default {
     filamentUsed: '已用耗材 (g)',
     filamentUsedPlaceholder: '例如 46.16',
     filamentUsedHelp: '當列印工作在沒有 3MF 的情況下封存時,可手動填寫,使其仍計入耗材總量。重新掃描具有 3MF 的封存項目時,此數值會從檔案重新讀取。',
+    filamentUsedLocked: '只有可以編輯所有封存的使用者才能變更此數值:此封存之後的列印將據此計費。',
     notes: '備註',
     notesPlaceholder: '新增關於此列印的備註...',
     externalLink: '外部連結',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AdditionalSection-NvWEni3f.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AmsUnitCard-I81ah2ip.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ArchivesPage-e1lq3Hwe.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AssignToAmsModal-BjW17NBF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CamWallPage-C6FQRN_Q.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraDiagnoseModal-BD0C5dsA.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraPage-dRwts9Kf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraWall-cECotNXX.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ColumnConfigModal-B5IVKg3e.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CV3dFcV2.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConnectAuthorizePage-4YBTZsND.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ContextMenu-BTSQ58LF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ExternalLinkPage-_rwGvJH6.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/FileManagerPage-DWNgcbjM.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FileUploadModal-DrAhAX-E.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FinancePage-dhn799Sw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GCodeViewerPage-DMzV-UMr.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GroupEditPage-sBjuWe1z.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-s9tYw9cC.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/InventoryPage-D_ZkChSL.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LibraryTrashPage-BHnFZujh.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LoginPage-0w4Ks61G.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/MaintenancePage-DXJasg5e.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ModelSourcesPage-ykcPRwBS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/NotificationsPage-QqMTORjY.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/PdfPreviewModal-Dbyn1I8n.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PhotoGalleryModal-C4b7n-od.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrinterLocationsPage-C4zDiaJQ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintersPage-D1posYsG.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProfilesPage-K0hNYpN1.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectDetailPage-DLLkHg9U.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectsPage-CYDQWgzk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueuePage-BYPQpLWT.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/RichTextEditor-D1qLu_1H.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SettingsPage-DTFOg6ih.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SetupPage-DoSkNMSz.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SliceModal-C_oCay21.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyAmsPage-DGsn1-hC.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BfDNotZr.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyDashboard-BXTh3VZR.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-t5uZdk6I.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Bl-Ytz52.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-D9bZn7rH.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-B-ArdfWe.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StatsPage-DCXY1ndf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StreamOverlayPage-jb28HGv6.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SystemInfoPage-ChREr5bw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/de-B5PD1nV9.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/es-C2EAQisu.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/fr-sh2oOClc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-ohIj6le-.js


+ 1 - 1
static/assets/inventorySearch-DQzOI6l_.js → static/assets/inventorySearch-CbvL5APk.js

@@ -1 +1 @@
-import{mt as e}from"./index-CG6Y41X9.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{mt as e}from"./index-ohIj6le-.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/it-DgTXaOlw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ja-D0KrNphO.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ko-CRRBf-Pd.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/locationSensorDefaults-CZzyK3VO.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/nl-Dg6mqVom.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pdf-CkX306dm.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pt-BR-BlXI84o5.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ru-DVjTv0eJ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/sv-D-8Gw-l7.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/tr-DKGsTdh_.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/uk-DdthraQj.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/utils-CBa833Oq.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/zh-CN-NbP9JgJn.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/zh-TW-BN0Qh71v.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CG6Y41X9.js"></script>
+    <script type="module" crossorigin src="/assets/index-ohIj6le-.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-C5b1pVUf.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-BqikUEEO.js">

Некоторые файлы не были показаны из-за большого количества измененных файлов