Przeglądaj źródła

fix(auth): the password reset email is built from the Password Reset template

maziggy 1 tydzień temu
rodzic
commit
9d91887ee9
90 zmienionych plików z 365 dodań i 45 usunięć
  1. 1 0
      CHANGELOG.md
  2. 27 0
      backend/app/core/database.py
  3. 1 1
      backend/app/models/notification_template.py
  4. 1 3
      backend/app/schemas/notification_template.py
  5. 46 5
      backend/app/services/email_service.py
  6. 71 0
      backend/tests/integration/test_password_reset_template_routes.py
  7. 184 0
      backend/tests/unit/services/test_password_reset_template_email.py
  8. 2 2
      frontend/src/i18n/locales/de.ts
  9. 2 2
      frontend/src/i18n/locales/en.ts
  10. 2 2
      frontend/src/i18n/locales/es.ts
  11. 2 2
      frontend/src/i18n/locales/fr.ts
  12. 2 2
      frontend/src/i18n/locales/it.ts
  13. 2 2
      frontend/src/i18n/locales/ja.ts
  14. 2 2
      frontend/src/i18n/locales/ko.ts
  15. 2 2
      frontend/src/i18n/locales/nl.ts
  16. 2 2
      frontend/src/i18n/locales/pt-BR.ts
  17. 2 2
      frontend/src/i18n/locales/ru.ts
  18. 2 2
      frontend/src/i18n/locales/sv.ts
  19. 2 2
      frontend/src/i18n/locales/tr.ts
  20. 2 2
      frontend/src/i18n/locales/uk.ts
  21. 2 2
      frontend/src/i18n/locales/zh-CN.ts
  22. 2 2
      frontend/src/i18n/locales/zh-TW.ts
  23. 1 1
      frontend/src/pages/SettingsPage.tsx
  24. 0 0
      static/assets/AdditionalSection-CeTSMeWd.js
  25. 0 0
      static/assets/AmsUnitCard-DHutbYJO.js
  26. 0 0
      static/assets/ArchivesPage-DQw0X0PL.js
  27. 0 0
      static/assets/AssignToAmsModal-BJqYeXz8.js
  28. 0 0
      static/assets/CamWallPage-CODFA_kr.js
  29. 0 0
      static/assets/CameraDiagnoseModal-t8tftoh-.js
  30. 0 0
      static/assets/CameraPage-BEjYTbxN.js
  31. 0 0
      static/assets/CameraWall-5rpCsphb.js
  32. 0 0
      static/assets/ColumnConfigModal-dmsyMmMD.js
  33. 0 0
      static/assets/ConfigureAmsSlotModal-BqvnXes3.js
  34. 0 0
      static/assets/ConnectAuthorizePage-CkHwpEGU.js
  35. 0 0
      static/assets/ContextMenu-BCG7gM3u.js
  36. 0 0
      static/assets/ExternalLinkPage-BvXQgPBI.js
  37. 0 1
      static/assets/FileManagerPage-DQ3zhb2N.js
  38. 0 0
      static/assets/FileUploadModal-CJK7HgZE.js
  39. 0 0
      static/assets/FinancePage-nWHIbrQB.js
  40. 0 0
      static/assets/GCodeViewerPage-5L1DPz-O.js
  41. 0 0
      static/assets/GroupEditPage-izOj8Hff.js
  42. 0 0
      static/assets/ImagePreviewModal-DWEuxys2.js
  43. 0 0
      static/assets/InventoryPage-CADBDMHL.js
  44. 0 0
      static/assets/LibraryTrashPage-Ae9IHEr_.js
  45. 0 0
      static/assets/LoginPage-BAmfszM6.js
  46. 0 0
      static/assets/MaintenancePage-C5sb2mE4.js
  47. 0 0
      static/assets/ModelSourcesPage-DliMrCzn.js
  48. 0 0
      static/assets/NotificationsPage-H5kvgR_s.js
  49. 0 1
      static/assets/PdfPreviewModal-DQV07C8a.js
  50. 0 0
      static/assets/PhotoGalleryModal-CwOmpOuI.js
  51. 0 0
      static/assets/PrinterLocationsPage-B0KO-CVv.js
  52. 0 0
      static/assets/PrintersPage-Z88f_skD.js
  53. 0 0
      static/assets/ProfilesPage-BmWDkdcx.js
  54. 0 0
      static/assets/ProjectDetailPage-4U_qGgYX.js
  55. 0 0
      static/assets/ProjectsPage-BSKXROwO.js
  56. 0 0
      static/assets/QueuePage-DAm8R-1W.js
  57. 0 0
      static/assets/RichTextEditor-CQppfQ_3.js
  58. 0 0
      static/assets/SettingsPage-HSTTX5Rp.js
  59. 0 0
      static/assets/SetupPage-rJ3UlSsk.js
  60. 0 0
      static/assets/SliceModal-8aXL5RKP.js
  61. 0 0
      static/assets/SpoolBuddyAmsPage-C1tG86Uh.js
  62. 0 0
      static/assets/SpoolBuddyCalibrationPage-DgKG0Y5g.js
  63. 0 0
      static/assets/SpoolBuddyDashboard-CukGIuUh.js
  64. 0 0
      static/assets/SpoolBuddyInventoryPage-B7zUWUdQ.js
  65. 1 1
      static/assets/SpoolBuddySettingsPage-C85Czllh.js
  66. 0 0
      static/assets/SpoolBuddyWriteTagPage-C-A0iU13.js
  67. 0 0
      static/assets/SpreadsheetPreviewModal-DBdLL5Ej.js
  68. 0 0
      static/assets/StatsPage-uaziMgiP.js
  69. 0 0
      static/assets/StreamOverlayPage-BrnOdR4t.js
  70. 0 0
      static/assets/SystemInfoPage-D9GmD431.js
  71. 0 0
      static/assets/de-D4AlbJ6i.js
  72. 0 0
      static/assets/es-DmwdMLZJ.js
  73. 0 0
      static/assets/fr-D8dZZp-x.js
  74. 0 0
      static/assets/index-BTd06JLS.js
  75. 1 1
      static/assets/inventorySearch-B6J6zY_Q.js
  76. 0 0
      static/assets/it-DKgAEfi0.js
  77. 0 0
      static/assets/ja-ClREudEY.js
  78. 0 0
      static/assets/ko-BRHsjJsU.js
  79. 0 0
      static/assets/locationSensorDefaults-DINS4Wfs.js
  80. 0 0
      static/assets/nl-BWzViPU7.js
  81. 0 0
      static/assets/pdf-Xu8IqaMS.js
  82. 0 0
      static/assets/pt-BR-C21SpMRa.js
  83. 0 0
      static/assets/ru-Cyv09mOU.js
  84. 0 0
      static/assets/sv-TslErSpo.js
  85. 0 0
      static/assets/tr-BcgyKEPU.js
  86. 0 0
      static/assets/uk-0qZ7497W.js
  87. 0 0
      static/assets/utils-hsTfBNbz.js
  88. 0 0
      static/assets/zh-CN-DeEwk-a9.js
  89. 0 0
      static/assets/zh-TW-iDqpSfDk.js
  90. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **The "Password Reset" email template is used again** — Since password resets send a link instead of a new password, the reset email (self-service and admin) was always the built-in one: editing the "Password Reset" template under Notifications changed nothing, and its default text still promised a new password. The reset email is now built from that template, and Bambuddy adds the reset link at the end of it (and as a button); the template itself cannot place or contain the link. `{password}` and `{login_url}` are gone from this template. The default text is updated on upgrade unless you edited it; if you did, reword it for a link instead of a new password.
 - **A cost centre is charged for a print whose user was deleted while it ran** — When a user was deleted during their print, the print was never charged: the cost centre's ledger missed its cost, and its budget reservation stayed active for good, holding back that much of the budget. The print is now charged to its cost centre, also to the deleted user's own private one, which their personal prints go through, and its reservation is settled. A queue print is charged under the user who queued it, never under the owner of the archive it reprints. Ledger entries without a user now read "No user" instead of "Deleted user": with login turned off, prints on a cost centre are now charged too and never had a user. Deleting a user together with their items now also releases the budget their queued prints still held; a print of theirs still running is kept until it ends (without a user) and billed to its cost centre like any other. Only a print with no cost centre at all (started while billing was off) has nobody left to charge.
 - **Deleting a printer no longer deletes an archive of a print starting at that moment (PostgreSQL)** — If a queue or reprint print started on the printer while it was being deleted, the delete could take the print's archive with it, even with "keep archives" and even when the archive belonged to another printer or a virtual printer queue. The delete now locks the printer while it runs, so such a print start waits and is turned away, and its archive stays as it was; this is logged as a print start for a deleted printer, not as an error, and sends no start notification. An archive that could not be written no longer leaves its copied 3MF behind in the archive folder.
 - **A printer deleted while its print was starting no longer gets an archive or tracking** — When a print starts, Bambuddy downloads its 3MF from the printer, which can take minutes. A printer deleted during that download still got an archive afterwards: on SQLite one that showed "printing" forever, since the deleted printer would never report the end, along with a layer timelapse and tracking for it; on PostgreSQL the archive was refused and an error logged. Bambuddy now checks that the printer still exists once the download is done, and again once the archive is written; a queue or reprint print starting just as its printer is deleted is no longer attached to that printer either. Archives the delete was told to keep stay as they are. A queue print dispatched to a printer deleted during its upload no longer records its owner for that printer either.

+ 27 - 0
backend/app/core/database.py

@@ -5576,6 +5576,12 @@ async def _run_migrations(conn):
     # a template an admin has edited is left alone.
     await _migrate_stock_alert_template_sku_variables(conn)
 
+    # Migration: the Password Reset template is for the reset link. Its old
+    # default emailed a new password, a flow that no longer exists. A plain
+    # UPDATE guarded on the old text, so a template an admin has edited is left
+    # alone (the email adds the link to any template, at the end).
+    await _migrate_password_reset_template_link(conn)
+
     # Migration: supplier master list + spool assignments (#2988).
     # create_all() covers fresh installs; this covers upgrades.
     await _migrate_create_supplier_tables(conn)
@@ -6575,6 +6581,27 @@ async def _migrate_stock_alert_template_sku_variables(conn) -> None:
         )
 
 
+_PASSWORD_RESET_TEMPLATE_BODY = (
+    "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
+    "Hello {username},\n\nA password reset was requested for your {app_name} account. If you did not request it, you can ignore this email: your password has not been changed.\n\nSet a new password with the link below (valid for 1 hour, usable once):",
+)
+
+
+async def _migrate_password_reset_template_link(conn) -> None:
+    """Reword the Password Reset template for the reset link, not a new password.
+
+    Updates the body only while it is still the old shipped default, so an
+    admin's own wording is kept.
+    """
+    from sqlalchemy import text
+
+    old, new = _PASSWORD_RESET_TEMPLATE_BODY
+    await conn.execute(
+        text("UPDATE notification_templates SET body_template = :new WHERE event_type = :et AND body_template = :old"),
+        {"new": new, "et": "password_reset", "old": old},
+    )
+
+
 async def _migrate_location_ha_sensor_unique_binding(conn) -> None:
     """Unique index on location_ha_sensors (location_id, entity_id) (#2824).
 

+ 1 - 1
backend/app/models/notification_template.py

@@ -228,7 +228,7 @@ DEFAULT_TEMPLATES = [
         "event_type": "password_reset",
         "name": "Password Reset",
         "title_template": "{app_name} - Password Reset",
-        "body_template": "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
+        "body_template": "Hello {username},\n\nA password reset was requested for your {app_name} account. If you did not request it, you can ignore this email: your password has not been changed.\n\nSet a new password with the link below (valid for 1 hour, usable once):",
     },
     # Inventory stock alert templates
     {

+ 1 - 3
backend/app/schemas/notification_template.py

@@ -131,7 +131,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     ],
     # User management notifications
     "user_created": ["username", "password", "login_url", "app_name", "timestamp"],
-    "password_reset": ["username", "password", "login_url", "app_name", "timestamp"],
+    "password_reset": ["username", "app_name", "timestamp"],
     # User email print notifications
     "user_print_start": ["username", "printer", "filename", "timestamp", "app_name"],
     "user_print_complete": ["username", "printer", "filename", "finish_photo_url", "timestamp", "app_name"],
@@ -408,8 +408,6 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
     },
     "password_reset": {
         "username": "john_doe",
-        "password": "<new-password>",
-        "login_url": "https://bambuddy.example.com/login",
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
     },

+ 46 - 5
backend/app/services/email_service.py

@@ -379,14 +379,55 @@ BamBuddy Team
 
 
 async def create_password_reset_link_email_from_template(
-    db: AsyncSession, username: str, reset_url: str
+    db: AsyncSession, username: str, reset_url: str, app_name: str = "BamBuddy"
 ) -> tuple[str, str, str]:
-    """Create the password-reset link email.
+    """Create the password-reset link email from the "password_reset" template.
 
-    There is no notification template for it (the seeded "password_reset" one is
-    for the old emailed-password flow), so this is always the built-in email.
+    The link is never a template variable: the code adds it, on a line of its
+    own at the end and as the button. The template is editable with
+    notification_templates:update (Operators), and a link placed by the
+    template could be wrapped in another URL (https://evil/?next={reset_url})
+    whose page reads the token from its fragment and resets the account, an
+    admin's too. A {reset_url} written into the template renders as nothing.
+    No template: the built-in email.
+
+    Returns:
+        Tuple of (subject, text_body, html_body)
     """
-    return create_password_reset_link_email(username, reset_url)
+    template = await get_notification_template(db, "password_reset")
+    if not template:
+        return create_password_reset_link_email(username, reset_url)
+
+    variables = {
+        "app_name": app_name,
+        "username": username,
+        "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
+    }
+    subject = render_template(template.title_template, variables)
+    text_body = f"{render_template(template.body_template, variables).rstrip()}\n\n{reset_url}"
+
+    escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
+    html_body = f"""<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
+    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;">
+        <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
+    </div>
+    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
+        <div style="font-size: 16px;">{escaped_text_body}</div>
+
+        <div style="text-align: center; margin: 30px 0;">
+            <a href="{html.escape(reset_url)}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reset Password</a>
+        </div>
+    </div>
+</body>
+</html>
+"""
+    return subject, text_body, html_body
 
 
 async def create_welcome_email_from_template(

+ 71 - 0
backend/tests/integration/test_password_reset_template_routes.py

@@ -0,0 +1,71 @@
+"""Both reset paths send the "Password Reset" template, the reset link added at the end.
+
+The self-service reset (/auth/forgot-password) and the admin one
+(/auth/reset-password) built the link email from fixed text, so an admin's
+edit of the template never reached anyone.
+"""
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.notification_template import NotificationTemplate
+from backend.tests.integration.test_advanced_auth_api import _setup_admin, _setup_smtp_and_advanced_auth
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _prepare(async_client: AsyncClient, db_session) -> tuple[dict, int]:
+    token = await _setup_admin(async_client, "tpladmin", "AdminPass1!")
+    headers = {"Authorization": f"Bearer {token}"}
+    db_session.add(
+        NotificationTemplate(
+            event_type="password_reset",
+            name="Password Reset",
+            title_template="Custom reset for {username}",
+            body_template="Our own words.",
+            is_default=False,
+        )
+    )
+    await db_session.commit()
+    with patch("backend.app.api.routes.users.send_email"):
+        await _setup_smtp_and_advanced_auth(async_client, token)
+        created = await async_client.post(
+            "/api/v1/users/",
+            headers=headers,
+            json={"username": "tpluser", "email": "tpluser@test.com", "role": "user"},
+        )
+    assert created.status_code == 201, created.text
+    return headers, created.json()["id"]
+
+
+def _assert_template_sent(mock_send) -> None:
+    mock_send.assert_called_once()
+    _smtp, to, subject, text_body, html_body = mock_send.call_args[0][:5]
+    assert to == "tpluser@test.com"
+    assert subject == "Custom reset for tpluser"
+    words, link = text_body.split("\n\n")
+    assert words == "Our own words."
+    assert "#reset_token=" in link
+    assert "#reset_token=" in html_body
+
+
+async def test_the_self_service_reset_sends_the_template(async_client: AsyncClient, db_session):
+    await _prepare(async_client, db_session)
+
+    with patch("backend.app.api.routes.auth.send_email") as mock_send:
+        response = await async_client.post("/api/v1/auth/forgot-password", json={"email": "tpluser@test.com"})
+
+    assert response.status_code == 200
+    _assert_template_sent(mock_send)
+
+
+async def test_the_admin_reset_sends_the_template(async_client: AsyncClient, db_session):
+    headers, user_id = await _prepare(async_client, db_session)
+
+    with patch("backend.app.api.routes.auth.send_email") as mock_send:
+        response = await async_client.post("/api/v1/auth/reset-password", headers=headers, json={"user_id": user_id})
+
+    assert response.status_code == 200
+    _assert_template_sent(mock_send)

+ 184 - 0
backend/tests/unit/services/test_password_reset_template_email.py

@@ -0,0 +1,184 @@
+"""The password-reset link email is built from the "Password Reset" template.
+
+The template was seeded and editable, but since the reset became a link email
+no code read it: the email was always the built-in one, whatever the admin
+wrote. Its default still described the old flow (a new password emailed in
+plain text). The email now renders the template, and the code adds the link at
+the end. The link is not a template variable: Operators may edit templates,
+and a link the template places could be wrapped in another URL whose page
+reads the token from its fragment and resets the account, an admin's too.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+
+from backend.app.core import database as _db
+from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
+from backend.app.schemas.notification_template import EVENT_VARIABLES, SAMPLE_DATA
+from backend.app.services.email_service import create_password_reset_link_email_from_template
+
+RESET_URL = "https://bambuddy.example.com/login#reset_token=abc123"
+DEFAULT = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "password_reset")
+
+
+def _template(title: str, body: str) -> NotificationTemplate:
+    return NotificationTemplate(
+        event_type="password_reset", name="Password Reset", title_template=title, body_template=body, is_default=False
+    )
+
+
+async def _render(template: NotificationTemplate | None):
+    with patch("backend.app.services.email_service.get_notification_template", return_value=template):
+        return await create_password_reset_link_email_from_template(AsyncMock(spec=AsyncSession), "maker", RESET_URL)
+
+
+async def test_the_default_template_sends_the_link():
+    subject, text_body, html_body = await _render(_template(DEFAULT["title_template"], DEFAULT["body_template"]))
+
+    assert subject == "BamBuddy - Password Reset"
+    assert "maker" in text_body
+    assert text_body.endswith(f"\n\n{RESET_URL}")
+    assert text_body.count(RESET_URL) == 1
+    assert f'href="{RESET_URL}"' in html_body
+    assert "{" not in text_body
+
+
+async def test_an_edited_template_is_what_the_user_gets_with_the_link_at_the_end():
+    subject, text_body, html_body = await _render(
+        _template("Reset for {username}", "Hi {username}\nThe {app_name} team")
+    )
+
+    assert subject == "Reset for maker"
+    assert text_body == f"Hi maker\nThe BamBuddy team\n\n{RESET_URL}"
+    assert "<br>" in html_body
+
+
+async def test_a_template_cannot_place_the_link():
+    """An Operator's body wrapping the link in their own URL: the token would
+    land in the fragment of their page."""
+    subject, text_body, html_body = await _render(
+        _template("Reset {reset_url}", "Reset here: https://evil.example/r?next={reset_url}")
+    )
+
+    assert text_body == f"Reset here: https://evil.example/r?next=\n\n{RESET_URL}"
+    assert "reset_token" not in subject
+    assert "next=https" not in text_body and "next=https" not in html_body
+    assert html_body.count("reset_token") == 2  # the line at the end and the button
+
+
+async def test_a_template_for_the_old_flow_still_carries_the_link():
+    """Customised for the old flow: {password} is gone (renders empty)."""
+    _subject, text_body, html_body = await _render(
+        _template("Reset", "Hello {username},\nNew Password: {password}\nLogin at: {login_url}")
+    )
+
+    assert text_body.endswith(f"\n\n{RESET_URL}")
+    assert f'href="{RESET_URL}"' in html_body
+
+
+async def test_the_body_is_escaped_in_the_html():
+    _subject, _text, html_body = await _render(_template("Reset", "<b>{username}</b>"))
+
+    assert "<b>maker</b>" not in html_body
+    assert "&lt;b&gt;maker&lt;/b&gt;" in html_body
+
+
+async def test_without_a_template_the_built_in_email_is_sent():
+    subject, text_body, html_body = await _render(None)
+
+    assert subject == "BamBuddy - Password Reset Request"
+    assert RESET_URL in text_body and RESET_URL in html_body
+
+
+def test_the_editor_offers_neither_the_link_nor_a_password():
+    for gone in ("reset_url", "password", "login_url"):
+        assert gone not in EVENT_VARIABLES["password_reset"]
+        assert gone not in SAMPLE_DATA["password_reset"]
+
+
+def test_the_default_template_has_no_link_and_no_password():
+    assert "{reset_url}" not in DEFAULT["body_template"]
+    assert "{password}" not in DEFAULT["body_template"]
+
+
+# --- upgrade: the shipped default is rewritten, an edited one is kept ---
+
+
+@pytest.fixture
+async def engine():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(NotificationTemplate.__table__.create)
+    try:
+        yield engine
+    finally:
+        await engine.dispose()
+
+
+async def _insert(conn, body: str) -> None:
+    await conn.execute(
+        text(
+            "INSERT INTO notification_templates (event_type, name, title_template, body_template, is_default) "
+            "VALUES ('password_reset', 'Password Reset', '{app_name} - Password Reset', :b, 1)"
+        ),
+        {"b": body},
+    )
+
+
+async def _body(conn) -> str:
+    return (
+        await conn.execute(text("SELECT body_template FROM notification_templates WHERE event_type = 'password_reset'"))
+    ).scalar_one()
+
+
+async def test_the_upgrade_rewrites_the_old_default(engine):
+    old, new = _db._PASSWORD_RESET_TEMPLATE_BODY
+    async with engine.begin() as conn:
+        await _insert(conn, old)
+
+    for _ in range(2):  # runs on every start
+        async with engine.begin() as conn:
+            await _db._migrate_password_reset_template_link(conn)
+
+    async with engine.begin() as conn:
+        assert await _body(conn) == new
+
+
+async def test_the_upgrade_keeps_an_edited_template(engine):
+    async with engine.begin() as conn:
+        await _insert(conn, "My own words {password}")
+
+    async with engine.begin() as conn:
+        await _db._migrate_password_reset_template_link(conn)
+        assert await _body(conn) == "My own words {password}"
+
+
+def test_the_upgrade_gives_what_a_fresh_install_gets():
+    assert _db._PASSWORD_RESET_TEMPLATE_BODY[1] == DEFAULT["body_template"]
+
+
+async def test_start_up_runs_the_upgrade(tmp_path):
+    from sqlalchemy.ext.asyncio import AsyncSession as Session
+
+    from backend.app.core.database import Base, run_migrations
+    from backend.app.main import app  # noqa: F401  registers every model, as init_db does
+
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'old.db'}")
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+            await _insert(conn, _db._PASSWORD_RESET_TEMPLATE_BODY[0])
+
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+
+        async with Session(engine) as session:
+            body = (await session.execute(text("SELECT body_template FROM notification_templates"))).scalar_one()
+        assert body == _db._PASSWORD_RESET_TEMPLATE_BODY[1]
+    finally:
+        await engine.dispose()

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

@@ -3419,7 +3419,7 @@ export default {
     },
     forgotPasswordTitle: 'Passwort vergessen',
     forgotPasswordMessage: 'Wenn Sie Ihr Passwort vergessen haben, wenden Sie sich bitte an Ihren Systemadministrator.',
-    forgotPasswordEmailMessage: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen ein neues Passwort.',
+    forgotPasswordEmailMessage: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link, mit dem Sie ein neues Passwort festlegen.',
     emailAddress: 'E-Mail-Adresse',
     emailPlaceholder: 'ihre.email@beispiel.de',
     cancel: 'Abbrechen',
@@ -3765,7 +3765,7 @@ export default {
       groups: 'Gruppen',
       optional: 'optional',
       autoGeneratedPassword: 'Ein sicheres Passwort wird automatisch generiert und per E-Mail an den Benutzer gesendet.',
-      passwordManagedByAdvancedAuth: 'Das Passwort wird durch erweiterte Authentifizierung verwaltet. Verwenden Sie "Passwort zurücksetzen", um ein neues Passwort per E-Mail an den Benutzer zu senden.',
+      passwordManagedByAdvancedAuth: 'Das Passwort wird durch erweiterte Authentifizierung verwaltet. Verwenden Sie "Passwort zurücksetzen", um dem Benutzer per E-Mail einen Link zum Festlegen eines neuen Passworts zu senden.',
       resetPassword: 'Passwort zurücksetzen',
       resettingPassword: 'Passwort wird zurückgesetzt...',
     },

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

@@ -3451,7 +3451,7 @@ export default {
     },
     forgotPasswordTitle: 'Forgot Password',
     forgotPasswordMessage: "If you've forgotten your password, please contact your system administrator to reset it.",
-    forgotPasswordEmailMessage: "Enter your email address and we'll send you a new password.",
+    forgotPasswordEmailMessage: "Enter your email address and we'll send you a link to set a new password.",
     emailAddress: 'Email Address',
     emailPlaceholder: 'your.email@example.com',
     cancel: 'Cancel',
@@ -3797,7 +3797,7 @@ export default {
       groups: 'Groups',
       optional: 'optional',
       autoGeneratedPassword: 'A secure password will be automatically generated and emailed to the user.',
-      passwordManagedByAdvancedAuth: 'Password is managed by Advanced Authentication. Use "Reset Password" to send a new password to the user via email.',
+      passwordManagedByAdvancedAuth: 'Password is managed by Advanced Authentication. Use "Reset Password" to email the user a link to set a new password.',
       resetPassword: 'Reset Password',
       resettingPassword: 'Resetting Password...',
     },

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

@@ -3421,7 +3421,7 @@ export default {
     },
     forgotPasswordTitle: 'Contraseña olvidada',
     forgotPasswordMessage: 'Si ha olvidado su contraseña, póngase en contacto con el administrador de su sistema para restablecerla.',
-    forgotPasswordEmailMessage: 'Introduzca su dirección de correo y le enviaremos una contraseña nueva.',
+    forgotPasswordEmailMessage: 'Introduzca su dirección de correo y le enviaremos un enlace para establecer una contraseña nueva.',
     emailAddress: 'Dirección de correo',
     emailPlaceholder: 'su.correo@ejemplo.com',
     cancel: 'Cancelar',
@@ -3767,7 +3767,7 @@ export default {
       groups: 'Grupos',
       optional: 'opcional',
       autoGeneratedPassword: 'Se generará automáticamente una contraseña segura y se enviará al usuario por correo.',
-      passwordManagedByAdvancedAuth: 'La contraseña la gestiona la autenticación avanzada. Use «Restablecer contraseña» para enviar una nueva contraseña al usuario por correo.',
+      passwordManagedByAdvancedAuth: 'La contraseña la gestiona la autenticación avanzada. Use «Restablecer contraseña» para enviar al usuario por correo un enlace para establecer una contraseña nueva.',
       resetPassword: 'Restablecer contraseña',
       resettingPassword: 'Restableciendo la contraseña...',
     },

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

@@ -3407,7 +3407,7 @@ export default {
     },
     forgotPasswordTitle: 'Mot de passe oublié',
     forgotPasswordMessage: 'Contactez votre administrateur pour réinitialiser votre accès.',
-    forgotPasswordEmailMessage: 'Entrez votre email pour recevoir un nouveau mot de passe.',
+    forgotPasswordEmailMessage: 'Entrez votre email pour recevoir un lien permettant de définir un nouveau mot de passe.',
     emailAddress: 'Adresse Email',
     emailPlaceholder: 'votre.email@exemple.com',
     cancel: 'Annuler',
@@ -3753,7 +3753,7 @@ export default {
       groups: 'Groupes',
       optional: 'optionnel',
       autoGeneratedPassword: 'Un mot de passe sera généré et envoyé par email.',
-      passwordManagedByAdvancedAuth: 'Géré par Auth Avancée. Utilisez "Réinitialiser" pour envoyer un nouveau mdp par email.',
+      passwordManagedByAdvancedAuth: 'Géré par Auth Avancée. Utilisez "Réinitialiser" pour envoyer par email un lien permettant de définir un nouveau mdp.',
       resetPassword: 'Réinitialiser le mot de passe',
       resettingPassword: 'Réinitialisation...',
     },

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

@@ -3406,7 +3406,7 @@ export default {
     },
     forgotPasswordTitle: 'Password dimenticata',
     forgotPasswordMessage: 'Se hai dimenticato la password, contatta il tuo amministratore di sistema per reimpostarla.',
-    forgotPasswordEmailMessage: 'Inserisci il tuo indirizzo email e ti invieremo una nuova password.',
+    forgotPasswordEmailMessage: 'Inserisci il tuo indirizzo email e ti invieremo un link per impostare una nuova password.',
     emailAddress: 'Indirizzo email',
     emailPlaceholder: 'tua.email@esempio.com',
     cancel: 'Annulla',
@@ -3752,7 +3752,7 @@ export default {
       groups: 'Gruppi',
       optional: 'opzionale',
       autoGeneratedPassword: 'Una password sicura verrà generata automaticamente e inviata via email all\'utente.',
-      passwordManagedByAdvancedAuth: 'La password è gestita dall\'autenticazione avanzata. Usa "Reimposta password" per inviare una nuova password all\'utente via email.',
+      passwordManagedByAdvancedAuth: 'La password è gestita dall\'autenticazione avanzata. Usa "Reimposta password" per inviare all\'utente via email un link per impostare una nuova password.',
       resetPassword: 'Reimposta password',
       resettingPassword: 'Reimpostazione password...',
     },

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

@@ -3419,7 +3419,7 @@ export default {
     },
     forgotPasswordTitle: 'パスワードを忘れた場合',
     forgotPasswordMessage: 'パスワードを忘れた場合は、システム管理者に連絡してリセットしてもらってください。',
-    forgotPasswordEmailMessage: 'メールアドレスを入力すると、新しいパスワードを送信します。',
+    forgotPasswordEmailMessage: 'メールアドレスを入力すると、新しいパスワードを設定するためのリンクを送信します。',
     emailAddress: 'メールアドレス',
     emailPlaceholder: 'your.email@example.com',
     cancel: 'キャンセル',
@@ -3765,7 +3765,7 @@ export default {
       groups: 'グループ',
       optional: 'オプション',
       autoGeneratedPassword: '安全なパスワードが自動的に生成され、ユーザーにメールで送信されます。',
-      passwordManagedByAdvancedAuth: 'パスワードは高度な認証によって管理されています。「パスワードのリセット」を使用して、メールで新しいパスワードをユーザーに送信してください。',
+      passwordManagedByAdvancedAuth: 'パスワードは高度な認証によって管理されています。「パスワードのリセット」を使用して、新しいパスワードを設定するためのリンクをメールでユーザーに送信してください。',
       resetPassword: 'パスワードのリセット',
       resettingPassword: 'パスワードをリセット中...',
     },

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

@@ -3258,7 +3258,7 @@ export default {
     },
     forgotPasswordTitle: '비밀번호 찾기',
     forgotPasswordMessage: '비밀번호를 잊으셨다면 시스템 관리자에게 초기화를 요청하세요.',
-    forgotPasswordEmailMessage: '이메일 주소를 입력하면 새 비밀번호를 전송해 드립니다.',
+    forgotPasswordEmailMessage: '이메일 주소를 입력하면 새 비밀번호를 설정할 수 있는 링크를 전송해 드립니다.',
     emailAddress: '이메일 주소',
     emailPlaceholder: 'your.email@example.com',
     cancel: '취소',
@@ -3591,7 +3591,7 @@ export default {
       groups: '그룹',
       optional: '선택사항',
       autoGeneratedPassword: '안전한 비밀번호가 자동으로 생성되어 사용자에게 이메일로 전송됩니다.',
-      passwordManagedByAdvancedAuth: '비밀번호는 고급 인증에 의해 관리됩니다. "비밀번호 재설정"을 사용하여 이메일로 새 비밀번호를 전송하세요.',
+      passwordManagedByAdvancedAuth: '비밀번호는 고급 인증에 의해 관리됩니다. "비밀번호 재설정"을 사용하여 새 비밀번호를 설정할 수 있는 링크를 이메일로 전송하세요.',
       resetPassword: '비밀번호 재설정',
       resettingPassword: '비밀번호 재설정 중...'
     },

+ 2 - 2
frontend/src/i18n/locales/nl.ts

@@ -3449,7 +3449,7 @@ export default {
     },
     forgotPasswordTitle: 'Wachtwoord vergeten',
     forgotPasswordMessage: 'Als je je wachtwoord bent vergeten, neem dan contact op met je systeembeheerder om het te laten resetten.',
-    forgotPasswordEmailMessage: 'Voer je e-mailadres in en we sturen je een nieuw wachtwoord.',
+    forgotPasswordEmailMessage: 'Voer je e-mailadres in en we sturen je een link om een nieuw wachtwoord in te stellen.',
     emailAddress: 'E-mailadres',
     emailPlaceholder: 'jouw.email@voorbeeld.nl',
     cancel: 'Annuleren',
@@ -3795,7 +3795,7 @@ export default {
       groups: 'Groepen',
       optional: 'optioneel',
       autoGeneratedPassword: 'Een veilig wachtwoord wordt automatisch gegenereerd en per e-mail naar de gebruiker gestuurd.',
-      passwordManagedByAdvancedAuth: 'Wachtwoord wordt beheerd door Geavanceerde authenticatie. Gebruik "Wachtwoord resetten" om via e-mail een nieuw wachtwoord naar de gebruiker te sturen.',
+      passwordManagedByAdvancedAuth: 'Wachtwoord wordt beheerd door Geavanceerde authenticatie. Gebruik "Wachtwoord resetten" om de gebruiker via e-mail een link te sturen om een nieuw wachtwoord in te stellen.',
       resetPassword: 'Wachtwoord resetten',
       resettingPassword: 'Wachtwoord resetten...',
     },

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

@@ -3406,7 +3406,7 @@ export default {
     },
     forgotPasswordTitle: 'Esqueceu a Senha',
     forgotPasswordMessage: 'Se você esqueceu sua senha, entre em contato com o administrador do sistema para redefini-la.',
-    forgotPasswordEmailMessage: 'Digite seu endereço de email e enviaremos uma nova senha.',
+    forgotPasswordEmailMessage: 'Digite seu endereço de email e enviaremos um link para definir uma nova senha.',
     emailAddress: 'Endereço de Email',
     emailPlaceholder: 'seu.email@exemplo.com',
     cancel: 'Cancelar',
@@ -3752,7 +3752,7 @@ export default {
       groups: 'Grupos',
       optional: 'opcional',
       autoGeneratedPassword: 'Uma senha segura será gerada automaticamente e enviada por e-mail ao usuário.',
-      passwordManagedByAdvancedAuth: 'A senha é gerenciada pela Autenticação Avançada. Use "Redefinir Senha" para enviar uma nova senha ao usuário por e-mail.',
+      passwordManagedByAdvancedAuth: 'A senha é gerenciada pela Autenticação Avançada. Use "Redefinir Senha" para enviar ao usuário por e-mail um link para definir uma nova senha.',
       resetPassword: 'Redefinir Senha',
       resettingPassword: 'Redefinindo Senha...',
     },

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

@@ -3250,7 +3250,7 @@ export default {
     },
     forgotPasswordTitle: "Восстановление пароля",
     forgotPasswordMessage: "Обратитесь к системному администратору для сброса пароля.",
-    forgotPasswordEmailMessage: "Введите адрес электронной почты, и мы отправим новый пароль.",
+    forgotPasswordEmailMessage: 'Введите адрес электронной почты, и мы отправим ссылку для установки нового пароля.',
     emailAddress: "Адрес электронной почты",
     emailPlaceholder: "your.email@example.com",
     cancel: "Отмена",
@@ -3583,7 +3583,7 @@ export default {
       groups: "Группы",
       optional: "необязательно",
       autoGeneratedPassword: "Надёжный пароль будет создан автоматически и отправлен пользователю по email.",
-      passwordManagedByAdvancedAuth: "Пароль управляется расширенной аутентификацией. Используйте «Сбросить пароль», чтобы отправить пользователю новый пароль по email.",
+      passwordManagedByAdvancedAuth: 'Пароль управляется расширенной аутентификацией. Используйте «Сбросить пароль», чтобы отправить пользователю по email ссылку для установки нового пароля.',
       resetPassword: "Сбросить пароль",
       resettingPassword: "Сброс пароля...",
     },

+ 2 - 2
frontend/src/i18n/locales/sv.ts

@@ -3448,7 +3448,7 @@ errors: {
     },
     forgotPasswordTitle: 'Glömt lösenord',
     forgotPasswordMessage: 'Om du har glömt ditt lösenord, kontakta din systemadministratör för att återställa det.',
-    forgotPasswordEmailMessage: 'Ange din e-postadress så skickar vi ett nytt lösenord till dig.',
+    forgotPasswordEmailMessage: 'Ange din e-postadress så skickar vi en länk där du kan ange ett nytt lösenord.',
     emailAddress: 'E-postadress',
     emailPlaceholder: 'din.e-post@example.com',
     cancel: 'Avbryt',
@@ -3794,7 +3794,7 @@ errors: {
       groups: 'Grupper',
       optional: 'valfritt',
       autoGeneratedPassword: 'Ett säkert lösenord kommer att genereras automatiskt och skickas via e-post till användaren.',
-      passwordManagedByAdvancedAuth: 'Lösenordet hanteras av avancerad autentisering. Använd "Återställ lösenord" för att skicka ett nytt lösenord till användaren via e-post.',
+      passwordManagedByAdvancedAuth: 'Lösenordet hanteras av avancerad autentisering. Använd "Återställ lösenord" för att via e-post skicka användaren en länk för att ange ett nytt lösenord.',
       resetPassword: 'Återställ lösenord',
       resettingPassword: 'Återställer lösenord...',
     },

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

@@ -3421,7 +3421,7 @@ export default {
     },
     forgotPasswordTitle: 'Parolayı Unuttum',
     forgotPasswordMessage: 'Parolanızı unuttuysanız, sıfırlamak için lütfen sistem yöneticinizle iletişime geçin.',
-    forgotPasswordEmailMessage: 'E-posta adresinizi girin, size yeni bir parola göndereceğiz.',
+    forgotPasswordEmailMessage: 'E-posta adresinizi girin, size yeni bir parola belirlemeniz için bir bağlantı göndereceğiz.',
     emailAddress: 'E-posta Adresi',
     emailPlaceholder: 'eposta.adresi@example.com',
     cancel: 'İptal',
@@ -3767,7 +3767,7 @@ export default {
       groups: 'Gruplar',
       optional: 'isteğe bağlı',
       autoGeneratedPassword: 'Güvenli bir parola otomatik olarak oluşturulacak ve kullanıcıya e-posta ile gönderilecek.',
-      passwordManagedByAdvancedAuth: 'Parola Gelişmiş Kimlik Doğrulama tarafından yönetilir. Kullanıcıya e-posta yoluyla yeni bir parola göndermek için "Parolayı Sıfırla"yı kullanın.',
+      passwordManagedByAdvancedAuth: 'Parola Gelişmiş Kimlik Doğrulama tarafından yönetilir. Kullanıcıya e-posta yoluyla yeni bir parola belirleme bağlantısı göndermek için "Parolayı Sıfırla"yı kullanın.',
       resetPassword: 'Parolayı Sıfırla',
       resettingPassword: 'Parola Sıfırlanıyor...',
     },

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

@@ -3446,7 +3446,7 @@ export default {
     },
     forgotPasswordTitle: "Забули пароль",
     forgotPasswordMessage: "Якщо ви забули свій пароль, зверніться до системного адміністратора, щоб скинути його.",
-    forgotPasswordEmailMessage: "Введіть адресу електронної пошти, і ми надішлемо вам новий пароль.",
+    forgotPasswordEmailMessage: 'Введіть адресу електронної пошти, і ми надішлемо вам посилання для встановлення нового пароля.',
     emailAddress: "Адреса електронної пошти",
     emailPlaceholder: "your.email@example.com",
     cancel: "Скасувати",
@@ -3792,7 +3792,7 @@ export default {
       groups: "Групи",
       optional: "необов’язково",
       autoGeneratedPassword: "Надійний пароль буде автоматично згенеровано та надіслано користувачеві електронною поштою.",
-      passwordManagedByAdvancedAuth: "Пароль керується розширеною автентифікацією. Використовуйте «Скинути пароль», щоб надіслати новий пароль користувачеві електронною поштою.",
+      passwordManagedByAdvancedAuth: 'Пароль керується розширеною автентифікацією. Використовуйте «Скинути пароль», щоб надіслати користувачеві електронною поштою посилання для встановлення нового пароля.',
       resetPassword: "Скинути пароль",
       resettingPassword: "Скидання пароля...",
     },

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

@@ -3406,7 +3406,7 @@ export default {
     },
     forgotPasswordTitle: '忘记密码',
     forgotPasswordMessage: '如果您忘记了密码,请联系系统管理员进行重置。',
-    forgotPasswordEmailMessage: '输入您的邮箱地址,我们将向您发送新密码。',
+    forgotPasswordEmailMessage: '输入您的邮箱地址,我们将向您发送设置新密码的链接。',
     emailAddress: '邮箱地址',
     emailPlaceholder: 'your.email@example.com',
     cancel: '取消',
@@ -3752,7 +3752,7 @@ export default {
       groups: '组',
       optional: '可选',
       autoGeneratedPassword: '将自动生成安全密码并通过邮件发送给用户。',
-      passwordManagedByAdvancedAuth: '密码由高级认证管理。使用"重置密码"通过邮件向用户发送新密码。',
+      passwordManagedByAdvancedAuth: '密码由高级认证管理。使用"重置密码"通过邮件向用户发送设置新密码的链接。',
       resetPassword: '重置密码',
       resettingPassword: '重置密码中...',
     },

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

@@ -3406,7 +3406,7 @@ export default {
     },
     forgotPasswordTitle: '忘記密碼',
     forgotPasswordMessage: '如果您忘記了密碼,請聯絡系統管理員進行重設。',
-    forgotPasswordEmailMessage: '輸入您的信箱地址,我們將向您傳送新密碼。',
+    forgotPasswordEmailMessage: '輸入您的信箱地址,我們將向您傳送設定新密碼的連結。',
     emailAddress: '信箱地址',
     emailPlaceholder: 'your.email@example.com',
     cancel: '取消',
@@ -3752,7 +3752,7 @@ export default {
       groups: '群組',
       optional: '可選',
       autoGeneratedPassword: '將自動產生安全密碼並透過郵件傳送給使用者。',
-      passwordManagedByAdvancedAuth: '密碼由進階認證管理。使用"重設密碼"透過郵件向使用者傳送新密碼。',
+      passwordManagedByAdvancedAuth: '密碼由進階認證管理。使用"重設密碼"透過郵件向使用者傳送設定新密碼的連結。',
       resetPassword: '重設密碼',
       resettingPassword: '重設密碼中...',
     },

+ 1 - 1
frontend/src/pages/SettingsPage.tsx

@@ -7592,7 +7592,7 @@ export function SettingsPage() {
                 {advancedAuthStatus?.advanced_auth_enabled && (
                   <div className="bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3 space-y-3">
                     <p className="text-sm text-bambu-gray">
-                      {t('users.form.passwordManagedByAdvancedAuth') || 'Password is managed by Advanced Authentication. Use "Reset Password" to send a new password to the user via email.'}
+                      {t('users.form.passwordManagedByAdvancedAuth') || 'Password is managed by Advanced Authentication. Use "Reset Password" to email the user a link to set a new password.'}
                     </p>
                     <Button
                       variant="secondary"

Plik diff jest za duży
+ 0 - 0
static/assets/AdditionalSection-CeTSMeWd.js


Plik diff jest za duży
+ 0 - 0
static/assets/AmsUnitCard-DHutbYJO.js


Plik diff jest za duży
+ 0 - 0
static/assets/ArchivesPage-DQw0X0PL.js


Plik diff jest za duży
+ 0 - 0
static/assets/AssignToAmsModal-BJqYeXz8.js


Plik diff jest za duży
+ 0 - 0
static/assets/CamWallPage-CODFA_kr.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraDiagnoseModal-t8tftoh-.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraPage-BEjYTbxN.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraWall-5rpCsphb.js


Plik diff jest za duży
+ 0 - 0
static/assets/ColumnConfigModal-dmsyMmMD.js


Plik diff jest za duży
+ 0 - 0
static/assets/ConfigureAmsSlotModal-BqvnXes3.js


Plik diff jest za duży
+ 0 - 0
static/assets/ConnectAuthorizePage-CkHwpEGU.js


Plik diff jest za duży
+ 0 - 0
static/assets/ContextMenu-BCG7gM3u.js


Plik diff jest za duży
+ 0 - 0
static/assets/ExternalLinkPage-BvXQgPBI.js


Plik diff jest za duży
+ 0 - 1
static/assets/FileManagerPage-DQ3zhb2N.js


Plik diff jest za duży
+ 0 - 0
static/assets/FileUploadModal-CJK7HgZE.js


Plik diff jest za duży
+ 0 - 0
static/assets/FinancePage-nWHIbrQB.js


Plik diff jest za duży
+ 0 - 0
static/assets/GCodeViewerPage-5L1DPz-O.js


Plik diff jest za duży
+ 0 - 0
static/assets/GroupEditPage-izOj8Hff.js


Plik diff jest za duży
+ 0 - 0
static/assets/ImagePreviewModal-DWEuxys2.js


Plik diff jest za duży
+ 0 - 0
static/assets/InventoryPage-CADBDMHL.js


Plik diff jest za duży
+ 0 - 0
static/assets/LibraryTrashPage-Ae9IHEr_.js


Plik diff jest za duży
+ 0 - 0
static/assets/LoginPage-BAmfszM6.js


Plik diff jest za duży
+ 0 - 0
static/assets/MaintenancePage-C5sb2mE4.js


Plik diff jest za duży
+ 0 - 0
static/assets/ModelSourcesPage-DliMrCzn.js


Plik diff jest za duży
+ 0 - 0
static/assets/NotificationsPage-H5kvgR_s.js


Plik diff jest za duży
+ 0 - 1
static/assets/PdfPreviewModal-DQV07C8a.js


Plik diff jest za duży
+ 0 - 0
static/assets/PhotoGalleryModal-CwOmpOuI.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrinterLocationsPage-B0KO-CVv.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrintersPage-Z88f_skD.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProfilesPage-BmWDkdcx.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProjectDetailPage-4U_qGgYX.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProjectsPage-BSKXROwO.js


Plik diff jest za duży
+ 0 - 0
static/assets/QueuePage-DAm8R-1W.js


Plik diff jest za duży
+ 0 - 0
static/assets/RichTextEditor-CQppfQ_3.js


Plik diff jest za duży
+ 0 - 0
static/assets/SettingsPage-HSTTX5Rp.js


Plik diff jest za duży
+ 0 - 0
static/assets/SetupPage-rJ3UlSsk.js


Plik diff jest za duży
+ 0 - 0
static/assets/SliceModal-8aXL5RKP.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyAmsPage-C1tG86Uh.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-DgKG0Y5g.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyDashboard-CukGIuUh.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-B7zUWUdQ.js


Plik diff jest za duży
+ 1 - 1
static/assets/SpoolBuddySettingsPage-C85Czllh.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-C-A0iU13.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DBdLL5Ej.js


Plik diff jest za duży
+ 0 - 0
static/assets/StatsPage-uaziMgiP.js


Plik diff jest za duży
+ 0 - 0
static/assets/StreamOverlayPage-BrnOdR4t.js


Plik diff jest za duży
+ 0 - 0
static/assets/SystemInfoPage-D9GmD431.js


Plik diff jest za duży
+ 0 - 0
static/assets/de-D4AlbJ6i.js


Plik diff jest za duży
+ 0 - 0
static/assets/es-DmwdMLZJ.js


Plik diff jest za duży
+ 0 - 0
static/assets/fr-D8dZZp-x.js


Plik diff jest za duży
+ 0 - 0
static/assets/index-BTd06JLS.js


+ 1 - 1
static/assets/inventorySearch-mXpLV720.js → static/assets/inventorySearch-B6J6zY_Q.js

@@ -1 +1 @@
-import{ft as e}from"./index-DUKqgwNl.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{ft as e}from"./index-BTd06JLS.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};

Plik diff jest za duży
+ 0 - 0
static/assets/it-DKgAEfi0.js


Plik diff jest za duży
+ 0 - 0
static/assets/ja-ClREudEY.js


Plik diff jest za duży
+ 0 - 0
static/assets/ko-BRHsjJsU.js


Plik diff jest za duży
+ 0 - 0
static/assets/locationSensorDefaults-DINS4Wfs.js


Plik diff jest za duży
+ 0 - 0
static/assets/nl-BWzViPU7.js


Plik diff jest za duży
+ 0 - 0
static/assets/pdf-Xu8IqaMS.js


Plik diff jest za duży
+ 0 - 0
static/assets/pt-BR-C21SpMRa.js


Plik diff jest za duży
+ 0 - 0
static/assets/ru-Cyv09mOU.js


Plik diff jest za duży
+ 0 - 0
static/assets/sv-TslErSpo.js


Plik diff jest za duży
+ 0 - 0
static/assets/tr-BcgyKEPU.js


Plik diff jest za duży
+ 0 - 0
static/assets/uk-0qZ7497W.js


Plik diff jest za duży
+ 0 - 0
static/assets/utils-hsTfBNbz.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-CN-DeEwk-a9.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-TW-iDqpSfDk.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-DUKqgwNl.js"></script>
+    <script type="module" crossorigin src="/assets/index-BTd06JLS.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DnySrlFv.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-Ce8_sN3w.js">

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików