diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f643e1e..082414b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Added +- **A slim user listing, so an API client can put names to the ids it already gets back (#1894, reported by @MorganMLGman)** — Archives, the queue and the statistics endpoints all report ownership as a numeric `created_by_id`, and statistics accept it as a filter, but there was no way for an API key to find out whose id was whose: the only user listing returns emails, roles, group membership and every account's full permission set, so it is administrative and rejects keys outright. Anyone building against the API was left parsing ids out of somewhere else, or doing without. `GET /users/slim` now answers with `id` and `username` and nothing else. It is covered by the **Read Status** scope, because a key holding that scope could already filter statistics by any `created_by_id` it cared to guess — what was missing was only the ability to address the filter, not permission to use it. The full listing stays admin-only, and for groups there is a matching **List User Names** permission that grants the narrow read without the broad one. If all you need is your own id, `GET /auth/me` now answers that on its own and no user listing is involved. - **Cost centers, budgets and a print ledger, for installs where somebody has to be billed (#1448, contributor @behrinml, requested in #1065)** — A print farm shared by a lab, a makerspace or a department has always been able to see what a print cost, but not to say whose budget it came out of. Bambuddy now has an optional billing layer that answers that. **Settings → Workflow → Billing** turns it on; it is off by default and nothing about an existing install changes until it is switched on. A **cost center** is a budget somebody can print against — a team, a course, a customer, a grant. Every user gets a private one automatically, so a personal install is usable the moment billing is enabled, and shared centers are created and staffed by an administrator with per-member permission to print against them. Each center carries an optional total or monthly budget; a center with no budget is explicitly unlimited rather than blocked. When a print is queued against a budgeted center, its cost is **reserved** rather than merely predicted, so a queue of ten jobs cannot each pass a check the tenth would fail — the reservation is released if the print never happens. The monthly window is configurable: pick the day it resets and the timezone that day is measured in, since a team spread across timezones otherwise disagrees about which month a print landed in. The estimate itself is computed on the server. The figure shown in the print dialog is a display hint, and the browser cannot lower it: budget enforcement uses Bambuddy's own calculation from the file's filament and the printer's rates, whatever the client sends. Completed prints are charged from the archive's measured cost, and a print that is aborted part-way is charged for the filament it actually used rather than the whole job. Every charge, deposit, withdrawal and administrative adjustment lands in a ledger on the new **Finance** page, which appears in the sidebar only while billing is on. Each charge carries a per-dispatch identity that survives restarts and reprints, so a job cannot be billed twice, and a charge that fails is reported through the UI and any configured notification provider rather than quietly not happening. Balances and budgets deliberately mean different things: a balance records what somebody has spent, and only a cost center's budget can stop a print. Personal balances count unassigned charges and the user's own private center; a print billed to a shared center does not touch the personal one. Four permissions gate the whole thing — `cost_centers:read_own`, `read_all`, `modify` and `create` — with the default Administrators group holding all four. Separately switchable, and off by default, is a **printer kill switch** that stops any print that starts on a printer without going through Bambuddy, so a farm that bills its users cannot be bypassed by sending a job straight from Bambu Studio. It stops the print, says so on screen and notifies. It also declines to act whenever it cannot prove ownership — a restart mid-print, a job Bambuddy dispatched but has not finished recording — because stopping a print is irreversible and refusing to act costs only a log line. - **Nest projects under a master project and see the whole programme in one place (#1264, reporter @enjoylifenow)** — A project has always been a flat thing: a build with fifty parts and a build with two got the same single row, and the only way to keep a large job legible was to split it into separate projects that then knew nothing about each other. Projects can now be nested. The project dialog has a **Parent project** picker, so an assembly can sit under the build it belongs to, at whatever depth suits the work; the picker leaves out the project itself and anything already beneath it, because nesting a project inside its own branch is a loop rather than a hierarchy. A project that has sub-projects gains a second card reporting the whole tree at once — print jobs, parts, time, filament and total cost, with progress measured against every target in the tree added together. That card is deliberately separate from the project's own figures, which keep meaning exactly what they meant before: what this project printed, not what its sub-projects did. Each sub-project listed underneath now carries its own branch's totals rather than only a percentage, so the rows add up to the card above them instead of contradicting it. On the Projects page a sub-project is drawn inside its parent's group rather than as another card somewhere in the grid, because two cards that belong together cannot show it while they sit columns apart, however they are captioned; the group is ruled in the parent's own colour and nests as deep as the projects do. A sub-project whose parent is hidden by the status filter stays where it is and says which project it belongs to instead. Two things that only became reachable once the interface could reach them were fixed along the way: a project could be moved under its own sub-project, which the API refused only when a project was made its own direct parent, and a percentage shown against a sub-project was measured differently from the same percentage on the page it linked to. Deleting a project in the middle of a tree now lifts its sub-projects up to its own parent instead of cutting them loose at the top level. - **Restore selected categories from a Git backup commit (#2714, contributor @jmoore-skild, requested in #2656)** — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a long time, and every one of those commits was a restore point that nothing could read back. Recovering from a bad settings change, a rebuilt instance or a lost database meant opening the repository by hand and copying JSON into the right places, if you knew which places those were. **Settings → Backup & Restore → Restore from Git** now picks any of the twenty most recent commits and pulls back the categories you tick — K-profiles, app settings, spool inventory and print history — without touching anything you did not select. The modal previews the commit before anything is written: it shows how many items each category holds and greys out the ones that commit does not contain, so a category you only enabled last week is visibly absent from older commits rather than silently restoring nothing. **Overwrite existing entries** decides what happens when something already exists locally — off, it fills in what is missing and leaves the rest alone; on, it makes the local row match the backup. The result panel reports what actually happened per category as restored, skipped and failed, and those three always add up to the number the preview showed you, so a count that does not match the preview is a bug rather than something to interpret. Restoring never resurrects a credential: the backup carries MQTT, LDAP, Home Assistant and Prometheus secrets so that a repository is a complete record, but the restore refuses every one of them, and refuses along with them any switch that would be left pointing at a service it can no longer authenticate to — an exposed Prometheus endpoint with no token is worse than one that stays off. The keys that decide who can reach the instance at all are refused outright for the same reason — the four authentication-policy switches, and the whole LDAP family alongside them, since those name *which directory server decides who you are* rather than how the instance behaves. Authentication is reconfigured through the auth UI, which has the guards that a JSON file does not. Print archives come back as history only, since a Git backup holds metadata and never the 3MF or thumbnail bytes, and each one is returned to its owner by username rather than by user id — an id means nothing on a rebuilt instance, where it would hand one person's print history to whoever now holds that number. An archive whose owner this instance does not have lands unowned with a note saying so, rather than being attributed to a stranger; one that already exists locally keeps the owner it already has, because an owner the backup cannot name is not an instruction to take one away. K-profiles are the one category that leaves the database: they are sent to the printer over MQTT, which means the printer must be online, and writing a slot is always an overwrite there regardless of the toggle — the modal says so before you click rather than in the summary afterwards. Cloud profiles are backed up but deliberately not restorable, as writing them means writing to a Bambu or Orca account rather than to this instance. **Restoring is permissioned per category**: `github:restore` opens the dialog, and each category additionally requires the permission that owns its rows — `settings:update`, `inventory:update`, `archives:update_all` and `kprofiles:update` — so a role cannot write through a restore what it cannot write through the page that owns it. Administrators hold all of them already; a custom role built around the Backup permissions alone can open the dialog and preview a commit, but needs the owning permission for each category you want it to be able to write. Each category is committed as it completes rather than at the end, so a large restore does not hold the database against the rest of Bambuddy for the length of the run; the trade is that a failure part-way through leaves the categories that already finished in place, which the result panel reports rather than claiming nothing was restored. Translated in all locales; wiki updated. Covered by backend and frontend tests. @@ -24,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. ### Fixed +- **`/auth/me` described API keys as administrators they were never allowed to be (#1894, reported by @MorganMLGman)** — Asked to identify an API key, Bambuddy answered with a synthetic administrator: user id `0`, role `admin`, `is_admin: true`, and every permission in the system. None of that was true. An API key cannot reach an administrative route at all, whatever its scopes and whoever owns it, so a client that built its interface from this answer — which is exactly what a native app does — offered buttons that failed with a permission error the moment anyone pressed one, and still had no way to learn which user id its own prints were filed under. The endpoint now reports the key's **owner** as its identity, `is_admin: false`, and a permission list containing precisely what the key's scopes admit, so what a client is told matches what it will be allowed to do. Keys created before keys had owners have no identity to report and keep the old `id: 0` placeholder, but they no longer claim to be administrators either. Clients that branched on `is_admin` or `role` should branch on `permissions` instead. - **An unacknowledged plate no longer stops AMS drying once a minute, for ever (#2801, reported by @superflyer11)** — With "require plate clear" on, a finished print left unacknowledged and something pending in that printer's queue put the scheduler into a loop: it stopped drying, auto-drying re-armed on the next tick, and it stopped it again — around 2000 state changes over ten days on the reporter's P2S, with no cycle ever running long enough to remove any moisture. Cycles the user had started by hand on other AMS units of the same printer were torn down with it. Two ideas had become tangled. Plate-clear answers "is the bed ready for the next job", which says nothing about whether the AMS may heat — and the gap between a finished print and the acknowledgment is exactly when drying is most useful, since the printer is free and nobody is waiting on it. Leaving the plate unacknowledged is also how people hold the queue by hand, so the hold was costing them the drying it should have enabled. On top of that, the "print takes priority" stop was reached only on the passes where the print was *not* going to start: drying is not one of the things the idle check looks at, so stopping a cycle could never make a blocked printer dispatchable, and the cycle was spent for nothing. Auto-drying no longer consults plate-clear at all; the stop now happens on dispatches that are actually going to proceed, and only where the model cannot dry through a print — hardware that can, and has been allowed to, keeps drying as #2758 established it should. A stop is also confined to cycles Bambuddy itself started, matching a contract the code already documented but did not honour, so a manual dry on another unit is left alone. Two smaller faults went with it: a printer merely waiting on the plate was being classed as mid-print, which silently applied the mid-print spool-protection cap to a printer that was not printing and logged the cycle as `(mid-print)` in `FINISH`; and a humidity reading that dipped to the threshold as the AMS cooled discarded the unit's whole history, including the 30-minute re-arm cooldown added in #2770 — so a reading oscillating a point either side of the threshold reset the very guard meant to ride it out. **One behaviour change to be aware of:** "Block queue while drying" previously had no effect on dispatch at all, and now does what it says — with it on, a queued print waits for a running cycle to finish. It is off by default. - **An H2C could clean and level with one hotend and then print with another, several millimetres above the plate (#2800, reported by @tru3l3gend)** — The reporter's H2C ran its startup clean and bed levelling on the wrong nozzle, switched hotends, and then printed in mid-air; the same job sent from Bambu Studio was fine. The H2C is the only printer that mounts its nozzle from a rack of six, and a print command names that nozzle by its *physical* rack position — the firmware reports those as IDs 16 to 21 — rather than by the extruder index, 0 or 1, that every other dual-nozzle printer uses. Bambuddy only ever had a rack position when a job arrived through the Virtual Printer, which captures Bambu Studio's own pick and replays it untouched (#1780). Anything queued from the library, from an archive, through the webhook or from a slicer pipeline carried none, so the field was left off the command entirely and the firmware chose a nozzle for itself — and its choice does not have to agree with the one the file was sliced for. Bambuddy now reads the per-slot extruder assignment out of the file it is about to dispatch and resolves it against the rack position the printer is reporting at that moment, which is the only place it can be known: the mounted hotend can be swapped from the touchscreen between queueing a job and printing it. Nothing about this is guessed. When the rack position cannot be established — mid-swap, or a connection that has not yet reported one — the field is left off and the firmware picks exactly as it did before, because a wrong physical ID is what puts a print in the air and is far worse than no ID at all. For the same reason a job that prints only from the fixed hotend is still left to the firmware: that nozzle's own physical ID has not yet been confirmed against a known-good Bambu Studio capture, and it will not be invented. Confined to the H2C throughout — the dispatch for every other printer, including the H2D and X2D, is unchanged. Diagnosed on real hardware by the reporter, who compared Bambuddy's dispatch against a working Bambu Studio one, established the rack ID range, and supplied a patch. - **Automatic drying no longer loops when the humidity threshold is set below what a warm AMS reports (#2770, reported by @tchavei)** — A reporter's H2D armed five separate 12-hour drying cycles inside four hours, one of them six seconds after the previous ended, and none of them ran for more than a couple of hours. Two things combine to produce that. The firmware ends a cycle whenever it decides the filament is dry, without reporting a fault: across this printer's history the run length tracks how wet the spools were, from nearly the full 12 hours when the AMS started at 32% down to minutes once it sat at 10-13%. That is the AMS doing its job. The loop is Bambuddy's. An AMS reports *higher* relative humidity while it is warm than once it has cooled — the same unit read 10-13% cold and 15-20% throughout every cycle — so with a threshold of 14% the reading at the moment a cycle ended was always still above it, and the next 30-second pass started another 12-hour cycle. Nothing counted, nothing waited, and it only stopped when the box finally cooled enough to read 13%. Auto-drying now waits half an hour after a cycle ends before it will arm another on the same unit, because the humidity reading means nothing until the AMS has cooled; and after two cycles in a row that bring the reading no lower it stops arming that unit altogether, says so in the log, and sends a notification — a new **Auto-drying suspended** event, on by default, since it reports that Bambuddy has *stopped* doing something and silence there reads as "still drying". Progress is judged against the lowest reading any cycle on that unit has ended at, so a genuinely wet spool in a humid room that is coming down slowly -- 40%, 37%, 35% -- keeps drying however far it still is from the threshold, and the suspension lifts by itself the moment the reading falls below it. Neither guard can ever stop a cycle that is running, and a cycle Bambuddy itself cut short for a print, or that you stopped by hand, is not counted against the unit -- so a farm that dries between queue jobs is unaffected. The threshold field in **Settings → Filament → AMS Display Thresholds** now warns when it is set below 20%, and every drying cycle end — early or normal — logs the unit's temperature and humidity, which is what made this diagnosable at all. diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index e3fd4829a..f894db556 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -21,6 +21,7 @@ from backend.app.core.auth import ( RequirePermissionIfAuthEnabled, _is_token_fresh, _validate_api_key, + apikey_effective_permissions, authenticate_user, authenticate_user_by_email, create_access_token, @@ -30,13 +31,13 @@ from backend.app.core.auth import ( get_user_by_email, get_user_by_username, is_jti_revoked, + resolve_apikey_owner, resolve_session_max_minutes, revoke_jti, security, ) from backend.app.core.database import async_session, get_db from backend.app.core.oidc_env import env_bool -from backend.app.core.permissions import ALL_PERMISSIONS from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType from backend.app.models.group import Group from backend.app.models.settings import Settings @@ -89,17 +90,47 @@ def _user_to_response(user: User) -> UserResponse: ) -def _api_key_to_user_response(api_key) -> UserResponse: - """Create a synthetic admin UserResponse for a valid API key.""" +async def _api_key_to_user_response(db: AsyncSession, api_key) -> UserResponse: + """Describe a valid API key as the identity it actually carries (#1894). + + Until 0.2.5 this returned a synthetic admin: ``id=0``, ``role="admin"``, + ``is_admin=True`` and every permission in the enum. That was wrong in both + directions. A key cannot perform administrative operations at all -- + ``_check_apikey_permissions`` denies every permission that is not in the + scope allowlist -- so a client that builds its UI from this response (which + is exactly what a native client does) rendered admin actions that 403 on + use, and had no way to learn the id its own prints are filed under. + + Now: identity comes from the key's owner, and ``permissions`` is the set the + key can genuinely exercise. ``is_admin`` is always False because no key can + reach an administrative route regardless of who owns it. + + Legacy keys predating per-user ownership (``user_id IS NULL``) have no + identity to report, so they keep ``id=0`` and the ``api-key:`` username -- + but they stop claiming admin. ``created_at`` describes the credential in + both branches, unchanged. + """ + # Same resolution the permission gate uses, so what is reported here and + # what is enforced there cannot drift -- including the 403 when the owner + # has been deactivated, which makes the key dead rather than anonymous. + owner = await resolve_apikey_owner(db, api_key) return UserResponse( - id=0, - username=f"api-key:{api_key.key_prefix}", + id=owner.id if owner else 0, + username=owner.username if owner else f"api-key:{api_key.key_prefix}", + # Withheld on purpose: the owner's email is not needed to resolve + # identity, and this response is reachable by anyone holding the key. email=None, - role="admin", + # Deprecated free-text field; "user" is the existing value meaning + # "not an admin". Inventing an "api_key" role here would put a third + # value into a field callers compare against string literals. + role="user", is_active=True, - is_admin=True, + is_admin=False, + auth_source=getattr(owner, "auth_source", "local") if owner else "local", + # The key is not a group member -- listing the owner's groups would + # imply capabilities the key does not inherit. groups=[], - permissions=sorted(ALL_PERMISSIONS), + permissions=apikey_effective_permissions(api_key, owner), created_at=api_key.created_at.isoformat(), ) @@ -637,8 +668,9 @@ async def get_current_user_info( """Get current user information. Accepts JWT tokens (via Authorization: Bearer header) and API keys - (via X-API-Key header or Authorization: Bearer bb_xxx). - API keys return a synthetic admin user with all permissions. + (via X-API-Key header or Authorization: Bearer bb_xxx). API keys report + their owner's identity and the permissions the key can actually exercise + -- see ``_api_key_to_user_response``. """ import jwt from jwt.exceptions import PyJWTError as JWTError @@ -647,7 +679,7 @@ async def get_current_user_info( if x_api_key: api_key = await _validate_api_key(db, x_api_key) if api_key: - return _api_key_to_user_response(api_key) + return await _api_key_to_user_response(db, api_key) # Check for Bearer token (could be JWT or API key) if credentials is not None: @@ -656,7 +688,7 @@ async def get_current_user_info( if token.startswith("bb_"): api_key = await _validate_api_key(db, token) if api_key: - return _api_key_to_user_response(api_key) + return await _api_key_to_user_response(db, api_key) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key", diff --git a/backend/app/api/routes/groups.py b/backend/app/api/routes/groups.py index da5bb4b20..5820d72d1 100644 --- a/backend/app/api/routes/groups.py +++ b/backend/app/api/routes/groups.py @@ -28,8 +28,19 @@ from backend.app.schemas.group import ( router = APIRouter(prefix="/groups", tags=["groups"]) +# Permissions whose derived label would misdescribe what is being granted. +# The derived form for USERS_READ_SLIM is "Read Slim Users", which reads as a +# property of the users rather than of the response -- and an admin ticking a +# box in the group editor has nothing else to go on (#1894). +_PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = { + Permission.USERS_READ_SLIM: "List User Names (id + username only)", +} + + def _permission_label(perm: Permission) -> str: """Convert permission enum to human-readable label.""" + if perm in _PERMISSION_LABEL_OVERRIDES: + return _PERMISSION_LABEL_OVERRIDES[perm] # e.g., "printers:read" -> "Read Printers" parts = perm.value.split(":") if len(parts) == 2: diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 1eec35380..fdf004071 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -13,6 +13,7 @@ from backend.app.core.auth import ( ALGORITHM, SECRET_KEY, RequireAdminIfAuthEnabled, + RequireAnyPermissionIfAuthEnabled, RequirePermissionIfAuthEnabled, get_current_user_optional, get_password_hash, @@ -34,7 +35,14 @@ from backend.app.models.settings import Settings from backend.app.models.user import User from backend.app.models.user_otp_code import UserOTPCode from backend.app.models.user_totp import UserTOTP -from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate +from backend.app.schemas.auth import ( + ChangePasswordRequest, + GroupBrief, + UserCreate, + UserResponse, + UserSlim, + UserUpdate, +) from backend.app.services.email_service import ( create_welcome_email_from_template, generate_secure_password, @@ -190,6 +198,31 @@ async def create_user( return _user_to_response(new_user) +@router.get("/slim", response_model=list[UserSlim]) +async def list_users_slim( + _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ), + db: AsyncSession = Depends(get_db), +): + """List users as ``{id, username}`` only (#1894). + + Exists so an API key -- or a group that should not see emails, roles and + permission sets -- can turn the ``created_by_id`` values it already gets + back from archives, stats and the queue into names. + + ``USERS_READ`` is accepted alongside ``USERS_READ_SLIM`` because it is + strictly broader; groups that already hold it keep working without a + permission backfill. For API keys only the slim permission resolves (the + full one is unmapped = administrative), so a key reaches this and not the + listing above. + + Declared before ``/{user_id}`` on purpose: FastAPI matches in declaration + order, and the reverse order would parse "slim" as the int path parameter + and answer 422. + """ + result = await db.execute(select(User.id, User.username).order_by(User.username)) + return [UserSlim(id=row.id, username=row.username) for row in result.all()] + + @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: int, diff --git a/backend/app/api/routes/webhook.py b/backend/app/api/routes/webhook.py index 4804de011..da2940843 100644 --- a/backend/app/api/routes/webhook.py +++ b/backend/app/api/routes/webhook.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.core.auth import check_permission, check_printer_access, get_api_key +from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key from backend.app.core.database import get_db from backend.app.models.api_key import APIKey from backend.app.models.archive import PrintArchive @@ -68,7 +68,7 @@ async def webhook_add_to_queue( Requires 'can_queue' permission. """ - check_permission(api_key, "queue") + await check_webhook_permission(db, api_key, "queue") check_printer_access(api_key, data.printer_id) # Verify archive exists @@ -153,7 +153,7 @@ async def webhook_start_print( Requires 'can_control_printer' permission. """ - check_permission(api_key, "control_printer") + await check_webhook_permission(db, api_key, "control_printer") check_printer_access(api_key, printer_id) # Get printer @@ -191,12 +191,13 @@ async def webhook_start_print( async def webhook_stop_print( printer_id: int, api_key: APIKey = Depends(get_api_key), + db: AsyncSession = Depends(get_db), ): """Stop the current print on a printer. Requires 'can_control_printer' permission. """ - check_permission(api_key, "control_printer") + await check_webhook_permission(db, api_key, "control_printer") check_printer_access(api_key, printer_id) status = printer_manager.get_status(printer_id) @@ -222,12 +223,13 @@ async def webhook_stop_print( async def webhook_cancel_print( printer_id: int, api_key: APIKey = Depends(get_api_key), + db: AsyncSession = Depends(get_db), ): """Cancel the current print on a printer. Requires 'can_control_printer' permission. """ - check_permission(api_key, "control_printer") + await check_webhook_permission(db, api_key, "control_printer") check_printer_access(api_key, printer_id) status = printer_manager.get_status(printer_id) @@ -257,7 +259,7 @@ async def webhook_get_printer_status( Requires 'can_read_status' permission. """ - check_permission(api_key, "read_status") + await check_webhook_permission(db, api_key, "read_status") check_printer_access(api_key, printer_id) # Get printer @@ -293,7 +295,7 @@ async def webhook_get_queue_status( Requires 'can_read_status' permission. """ - check_permission(api_key, "read_status") + await check_webhook_permission(db, api_key, "read_status") # Get printers if printer_id: diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index c3d0ef053..cd5d39994 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -49,8 +49,15 @@ logger = logging.getLogger(__name__) # The denylist is retained for documentation / drift-detection only — its # entries also satisfy "not in the allowlist", so they fail closed regardless. # +# #1894 follow-on: the allowlist is a ceiling, not a grant. A key is also +# narrowed to what its owner may do, so a user who can create keys cannot mint +# themselves authority they do not have, and deactivating a user disables their +# keys. Legacy ownerless keys (``user_id IS NULL``) have no owner to narrow +# against and remain governed by the scope flags alone. +# # Mapping rationale (see wiki/features/api-keys.md): # can_read_status → every ``*_READ`` + camera + stats + system + websocket +# + the slim id/username user listing (NOT ``users:read``) # can_queue → queue write ops + archive reprint # can_control_printer → physical printer + smart-plug control # can_manage_library → library upload/own + MakerWorld import (separate @@ -94,6 +101,14 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = { Permission.PRINTER_SENSOR_HISTORY_READ: "can_read_status", Permission.STATS_READ: "can_read_status", Permission.STATS_FILTER_BY_USER: "can_read_status", + # USERS_READ_SLIM grants no data an API key could not already reach (#1894): + # for API-keyed requests the permission deps return None as ``current_user``, + # so ``_validate_user_filter_permission`` in routes/archives.py short-circuits + # and ``?created_by_id=N`` is already honoured for every N. Without a way to + # discover the ids, that filter is only addressable by brute force. The slim + # listing makes it usable; the full USERS_READ listing (emails, roles, group + # membership, permission sets) stays unmapped = admin-only. + Permission.USERS_READ_SLIM: "can_read_status", Permission.SYSTEM_READ: "can_read_status", # SETTINGS_READ stays allowed via read-status so SpoolBuddy kiosks keep # working (they need the UI-language setting via API key). @@ -293,13 +308,89 @@ def _resolve_apikey_scope(perm_string: str) -> str | None: return _APIKEY_SCOPE_BY_PERMISSION.get(perm) -def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, require_any: bool = False) -> None: +def apikey_effective_permissions(api_key: APIKey, owner: User | None = None) -> list[str]: + """Return the permissions ``api_key`` can actually exercise, sorted. + + This is the exact set ``_check_apikey_permissions`` will let through: every + mapped permission whose scope flag is True on the key, further narrowed to + what ``owner`` may do. Unmapped permissions are administrative and never + resolve for a key, so they are absent. + + ``owner=None`` means a legacy ownerless key, where the scope flags are the + whole of the key's authority -- not "skip the owner check". Callers holding + an owned key must pass the owner, or ``/auth/me`` will over-report and drift + from the gate, which is the defect #1894 was about. + """ + return sorted( + perm.value + for perm, scope_attr in _APIKEY_SCOPE_BY_PERMISSION.items() + if getattr(api_key, scope_attr, False) and (owner is None or owner.has_permission(perm.value)) + ) + + +async def resolve_apikey_owner(db: AsyncSession, api_key: APIKey) -> User | None: + """Load the owner of ``api_key`` for an authorization decision. + + Distinct from ``_user_from_api_key``, which answers "who is this, if + anyone" and returns None for both the legacy and the broken case. Here + those two must not be conflated: + + - ``user_id IS NULL`` -- a key predating per-user ownership. There is no + owner to narrow against, so the scope flags stand alone. Returns None. + - ``user_id`` set but the row is missing or deactivated -- the key's + authority came from a user who no longer has any. Raises 403 rather than + returning None, because returning None here would fail open: deactivating + a user would leave their keys working with full scope authority. + + Groups are eager-loaded because ``has_permission`` walks them, and a lazy + load inside the permission check would raise MissingGreenlet. + """ + if api_key.user_id is None: + return None + result = await db.execute(select(User).where(User.id == api_key.user_id).options(selectinload(User.groups))) + owner = result.scalar_one_or_none() + if owner is None or not owner.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="API key owner is deactivated or no longer exists", + ) + return owner + + +async def authorize_api_key( + db: AsyncSession, + api_key: APIKey, + perm_strings: list[str], + *, + require_any: bool = False, +) -> None: + """Resolve the key's owner and run the full permission gate. Raises 403.""" + owner = await resolve_apikey_owner(db, api_key) + _check_apikey_permissions(api_key, perm_strings, owner=owner, require_any=require_any) + + +def _check_apikey_permissions( + api_key: APIKey, + perm_strings: list[str], + *, + owner: User | None = None, + require_any: bool = False, +) -> None: """Raise 403 unless ``api_key`` is allowed to use ``perm_strings``. Allowlist semantics: every requested permission MUST be present in ``_APIKEY_SCOPE_BY_PERMISSION`` AND its scope flag must be True on ``api_key``. Unmapped permissions = administrative = 403. + A key must not out-rank the user it belongs to, so when ``owner`` is given + the permission must additionally be one the owner holds. Scope flags are + chosen at creation time by whoever holds ``api_keys:create``; that is + admin-only in the default groups, but a custom group can grant it, and + without this check such a user could mint themselves a key with + ``can_control_printer`` and act through it beyond their own permissions. + ``owner=None`` is only correct for legacy ownerless keys -- see + ``resolve_apikey_owner``. + By default ALL requested permissions must pass (mirrors ``require_permission`` / ``require_permission_if_auth_enabled``). When ``require_any=True``, only one needs to pass (mirrors @@ -327,6 +418,11 @@ def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, requi status_code=status.HTTP_403_FORBIDDEN, detail=f"API key does not have '{scope_attr}' permission", ) + elif owner is not None and not owner.has_permission(perm_str): + failure = HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"API key owner does not have '{perm_str}' permission", + ) else: failure = None @@ -384,6 +480,12 @@ def require_energy_cost_update(): detail="Invalid API key", headers={"WWW-Authenticate": "Bearer"}, ) + # Fails closed if the owner has been deactivated. The scope + # flag itself is not narrowed against the owner's permissions + # the way the general gate is: this door exists precisely + # because no user permission maps to it (SETTINGS_UPDATE stays + # denied for keys even when the owner is an administrator). + await resolve_apikey_owner(db, api_key) if not api_key.can_update_energy_cost: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -1133,10 +1235,14 @@ async def require_auth_if_enabled( if not auth_enabled: return None - # Check for API key first (X-API-Key header) + # Check for API key first (X-API-Key header). The owner is resolved + # purely for its side effect: a key whose owner has been deactivated + # must be dead everywhere, not just on the permission-gated routes. + # There is no permission to check here -- this dep is auth-only. if x_api_key: api_key = await _validate_api_key(db, x_api_key) if api_key: + await resolve_apikey_owner(db, api_key) return None # API key valid, allow access # Check for Bearer token (could be JWT or API key) @@ -1146,6 +1252,7 @@ async def require_auth_if_enabled( if token.startswith("bb_"): api_key = await _validate_api_key(db, token) if api_key: + await resolve_apikey_owner(db, api_key) return None # API key valid, allow access raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1424,6 +1531,35 @@ def check_permission(api_key: APIKey, permission: str) -> None: ) +# The coarse webhook permission names predate the Permission enum. Each maps to +# the enum member that best represents it, so the owner can be held to the same +# standard here as on the modern routes. +_WEBHOOK_PERMISSION_EQUIVALENT: dict[str, Permission] = { + "queue": Permission.QUEUE_CREATE, + "control_printer": Permission.PRINTERS_CONTROL, + "read_status": Permission.PRINTERS_READ, +} + + +async def check_webhook_permission(db: AsyncSession, api_key: APIKey, permission: str) -> None: + """``check_permission`` plus the owner checks the modern routes apply. + + ``/webhook/*`` reaches its scope flags through ``check_permission`` rather + than ``_check_apikey_permissions``, so it does not pick up the owner + narrowing automatically. Without this it would be the way around the gate: + the same key that is refused printer control on ``/printers/{id}/print/stop`` + could stop the print through ``/webhook/printer/{id}/stop``. + """ + check_permission(api_key, permission) + owner = await resolve_apikey_owner(db, api_key) + equivalent = _WEBHOOK_PERMISSION_EQUIVALENT.get(permission) + if owner is not None and equivalent is not None and not owner.has_permission(equivalent.value): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"API key owner does not have '{equivalent.value}' permission", + ) + + def check_printer_access(api_key: APIKey, printer_id: int) -> None: """Check if API key has access to the specified printer. @@ -1481,7 +1617,7 @@ def require_permission(*permissions: str | Permission): if x_api_key: api_key = await _validate_api_key(db, x_api_key) if api_key: - _check_apikey_permissions(api_key, perm_strings) + await authorize_api_key(db, api_key, perm_strings) return None # API key valid, allow access credentials_exception = HTTPException( @@ -1498,7 +1634,7 @@ def require_permission(*permissions: str | Permission): if token.startswith("bb_"): api_key = await _validate_api_key(db, token) if api_key: - _check_apikey_permissions(api_key, perm_strings) + await authorize_api_key(db, api_key, perm_strings) return None # API key valid, allow access raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1571,7 +1707,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission): if x_api_key: api_key = await _validate_api_key(db, x_api_key) if api_key: - _check_apikey_permissions(api_key, perm_strings) + await authorize_api_key(db, api_key, perm_strings) return None # API key valid, allow access # Check for Bearer token (could be JWT or API key) @@ -1581,7 +1717,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission): if token.startswith("bb_"): api_key = await _validate_api_key(db, token) if api_key: - _check_apikey_permissions(api_key, perm_strings) + await authorize_api_key(db, api_key, perm_strings) return None # API key valid, allow access raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1674,7 +1810,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission): # GHSA-r2qv-8222-hqg3: previously returned None unconditionally, # letting any valid API key satisfy admin "any-of" route # dependencies. require_any → at-least-one must pass the scope check. - _check_apikey_permissions(api_key, perm_strings, require_any=True) + await authorize_api_key(db, api_key, perm_strings, require_any=True) return None if credentials is not None: @@ -1682,7 +1818,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission): if token.startswith("bb_"): api_key = await _validate_api_key(db, token) if api_key: - _check_apikey_permissions(api_key, perm_strings, require_any=True) + await authorize_api_key(db, api_key, perm_strings, require_any=True) return None raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1873,7 +2009,7 @@ def require_ownership_permission( if x_api_key: api_key = await _validate_api_key(db, x_api_key) if api_key: - _check_apikey_permissions(api_key, [all_perm]) + await authorize_api_key(db, api_key, [all_perm]) return None, True # Check for Bearer token (could be JWT or API key) @@ -1883,7 +2019,7 @@ def require_ownership_permission( if token.startswith("bb_"): api_key = await _validate_api_key(db, token) if api_key: - _check_apikey_permissions(api_key, [all_perm]) + await authorize_api_key(db, api_key, [all_perm]) return None, True raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index d1ca32712..edac981f8 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -174,6 +174,11 @@ class Permission(StrEnum): # Users (admin-level) USERS_READ = "users:read" + # Narrow read: id + username only, no emails/roles/groups/permissions (#1894). + # Exists so an id -> name mapping can be resolved without handing out the + # full user objects. Pairs with STATS_FILTER_BY_USER, which is useless + # without a way to discover the ids it filters on. + USERS_READ_SLIM = "users:read_slim" USERS_CREATE = "users:create" USERS_UPDATE = "users:update" USERS_DELETE = "users:delete" @@ -346,6 +351,7 @@ PERMISSION_CATEGORIES = { ], "User Management": [ Permission.USERS_READ, + Permission.USERS_READ_SLIM, Permission.USERS_CREATE, Permission.USERS_UPDATE, Permission.USERS_DELETE, diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index d9feb8773..20e017bf8 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -93,6 +93,22 @@ class UserResponse(BaseModel): from_attributes = True +class UserSlim(BaseModel): + """Just enough to resolve a user id to a display name (#1894). + + Deliberately narrower than ``UserResponse``: no email, role, auth source, + group membership or permission set. Adding a field here widens what every + ``can_read_status`` API key can read about every account, so treat this + shape as the contract rather than a starting point. + """ + + id: int + username: str + + class Config: + from_attributes = True + + class LDAPSearchResultResponse(BaseModel): """One match from GET /auth/ldap/search — surfaced in the admin UI.""" diff --git a/backend/tests/integration/test_api_key_owner_authority_1894.py b/backend/tests/integration/test_api_key_owner_authority_1894.py new file mode 100644 index 000000000..d09774513 --- /dev/null +++ b/backend/tests/integration/test_api_key_owner_authority_1894.py @@ -0,0 +1,240 @@ +"""An API key must not out-rank the user it belongs to (#1894 follow-on). + +``_check_apikey_permissions`` gated purely on the scope flags stored on the key +row and never looked at the owner. Scope flags are chosen at creation time by +whoever holds ``api_keys:create`` -- admin-only in the default groups, but a +custom group can grant it -- so a user with, say, queue permissions could mint +themselves a key with ``can_control_printer`` and stop other people's prints +through it. Deactivating that user did not help either: their keys kept working +with full scope authority, because nothing re-checked the owner. + +The gate now narrows the scope flags to what the owner may do. Two cases must +NOT be conflated, and each has a test below: + +- ``user_id IS NULL`` -- legacy key from before per-user ownership. No owner + exists to narrow against, so the flags stand alone and the key keeps working. +- ``user_id`` set but the row is gone or deactivated -- the key's authority came + from a user who has none. Fails closed. +""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from backend.app.core.auth import generate_api_key, get_password_hash +from backend.app.models.api_key import APIKey +from backend.app.models.group import Group +from backend.app.models.user import User + +# A route gated on PRINTERS_READ (can_read_status) and one gated on +# PRINTERS_CONTROL (can_control_printer). Both scope flags are set on every key +# built below, so any denial comes from the owner check rather than the flags. +READ_ROUTE = "/api/v1/printers/" +CONTROL_ROUTE = "/api/v1/printers/1/print/stop" + + +async def _setup(async_client: AsyncClient) -> None: + await async_client.post( + "/api/v1/auth/setup", + json={"auth_enabled": True, "admin_username": "owneradmin", "admin_password": "OwnerPass1!"}, + ) + + +async def _key_for(db_session, owner: User | None, **scopes) -> str: + defaults = {"can_read_status": True, "can_control_printer": True, "can_queue": True} + defaults.update(scopes) + full_key, key_hash, key_prefix = generate_api_key() + db_session.add( + APIKey( + name=f"key-{owner.username if owner else 'legacy'}", + key_hash=key_hash, + key_prefix=key_prefix, + enabled=True, + user_id=owner.id if owner else None, + **defaults, + ) + ) + await db_session.commit() + return full_key + + +async def _user(db_session, username: str, permissions: list[str], *, is_active: bool = True) -> User: + group = Group(name=f"grp-{username}", description="t", permissions=permissions, is_system=False) + db_session.add(group) + await db_session.flush() + user = User( + username=username, + password_hash=get_password_hash("Whatever1!"), # noqa: S106 + role="user", + is_active=is_active, + groups=[group], + ) + db_session.add(user) + await db_session.commit() + return user + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_admin_owned_key_keeps_full_scope_authority(async_client: AsyncClient, db_session): + """The common case must not regress -- almost every key is admin-owned.""" + await _setup(async_client) + admin = (await db_session.execute(select(User).where(User.username == "owneradmin"))).scalar_one() + key = await _key_for(db_session, admin) + + response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key}) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_legacy_ownerless_key_still_works(async_client: AsyncClient, db_session): + """No owner to narrow against is not the same as a failed owner lookup.""" + await _setup(async_client) + key = await _key_for(db_session, None) + + response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key}) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_key_cannot_exceed_its_owners_permissions(async_client: AsyncClient, db_session): + """The escalation: control flags ticked, owner who may not control.""" + await _setup(async_client) + owner = await _user(db_session, "readonly", ["printers:read"]) + key = await _key_for(db_session, owner) + + allowed = await async_client.get(READ_ROUTE, headers={"X-API-Key": key}) + denied = await async_client.post(CONTROL_ROUTE, headers={"X-API-Key": key}) + + assert allowed.status_code == 200 + assert denied.status_code == 403 + assert "owner does not have" in denied.json()["detail"] + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_deactivating_the_owner_disables_the_key(async_client: AsyncClient, db_session): + """Previously the key kept working -- nothing re-checked the owner.""" + await _setup(async_client) + owner = await _user(db_session, "gone", ["printers:read"], is_active=False) + key = await _key_for(db_session, owner) + + response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key}) + + assert response.status_code == 403 + assert "deactivated" in response.json()["detail"] + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_a_deleted_owner_does_not_fall_back_to_anonymous(async_client: AsyncClient, db_session): + """The dangling-row case fails closed rather than reverting to flags-only. + + CASCADE should prevent this, but "should" is not a gate -- if the row is + ever orphaned the key must not silently regain full scope authority. + """ + await _setup(async_client) + owner = await _user(db_session, "doomed", ["printers:read"]) + key = await _key_for(db_session, owner) + api_key = (await db_session.execute(select(APIKey).where(APIKey.user_id == owner.id))).scalar_one() + api_key.user_id = 999999 # owner row that does not exist + await db_session.commit() + + response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key}) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_bearer_path_is_gated_the_same_as_the_header(async_client: AsyncClient, db_session): + """Both credential paths run the same gate; only one was ever tested.""" + await _setup(async_client) + owner = await _user(db_session, "bearer-readonly", ["printers:read"]) + key = await _key_for(db_session, owner) + + denied = await async_client.post(CONTROL_ROUTE, headers={"Authorization": f"Bearer {key}"}) + + assert denied.status_code == 403 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_webhook_routes_are_not_a_way_around_the_owner_check(async_client: AsyncClient, db_session): + """/webhook/* reaches its scope flags by a different route than the rest. + + It gates on ``check_permission``, not ``_check_apikey_permissions``, so it + does not inherit the owner narrowing for free. If it is missed, the same key + that is refused on /printers/{id}/print/stop simply stops the print here + instead, and the whole gate is decorative. + """ + await _setup(async_client) + owner = await _user(db_session, "webhook-readonly", ["printers:read"]) + key = await _key_for(db_session, owner) + + denied = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key}) + + assert denied.status_code == 403 + assert "owner does not have" in denied.json()["detail"] + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_webhook_still_works_for_a_permitted_owner(async_client: AsyncClient, db_session): + """The narrowing must not simply break every webhook caller.""" + await _setup(async_client) + owner = await _user(db_session, "webhook-operator", ["printers:read", "printers:control"]) + key = await _key_for(db_session, owner) + + response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key}) + + # There is no connected printer 1, so the handler itself fails. What + # matters is that the request got that far: neither gate rejected it. + assert response.status_code != 403 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_webhook_rejects_a_deactivated_owner(async_client: AsyncClient, db_session): + """Fail-closed applies on this path too.""" + await _setup(async_client) + owner = await _user(db_session, "webhook-gone", ["printers:read", "printers:control"], is_active=False) + key = await _key_for(db_session, owner) + + response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key}) + + assert response.status_code == 403 + assert "deactivated" in response.json()["detail"] + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_me_reports_the_narrowed_set(async_client: AsyncClient, db_session): + """/auth/me and the gate must agree, including about the owner.""" + await _setup(async_client) + owner = await _user(db_session, "narrow", ["printers:read"]) + key = await _key_for(db_session, owner) + + result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key})).json() + + assert result["id"] == owner.id + assert "printers:read" in result["permissions"] + # can_control_printer is ticked on the key, but the owner cannot control. + assert "printers:control" not in result["permissions"] + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_me_is_rejected_once_the_owner_is_deactivated(async_client: AsyncClient, db_session): + """A dead key identifies as nothing, rather than as an anonymous key.""" + await _setup(async_client) + owner = await _user(db_session, "me-gone", ["printers:read"], is_active=False) + key = await _key_for(db_session, owner) + + response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key}) + + assert response.status_code == 403 diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py index 442f3635c..c856e2c33 100644 --- a/backend/tests/integration/test_auth_api.py +++ b/backend/tests/integration/test_auth_api.py @@ -232,8 +232,8 @@ class TestAuthMeAPI: @pytest.mark.asyncio @pytest.mark.integration - async def test_me_with_api_key_bearer(self, async_client: AsyncClient, db_session): - """Verify /me returns synthetic admin user when using API key via Bearer token.""" + async def test_me_with_ownerless_api_key_bearer(self, async_client: AsyncClient, db_session): + """A legacy key has no identity to report, but no longer claims admin (#1894).""" from backend.app.core.auth import generate_api_key from backend.app.models.api_key import APIKey @@ -253,15 +253,18 @@ class TestAuthMeAPI: result = response.json() assert result["id"] == 0 assert result["username"].startswith("api-key:") - assert result["role"] == "admin" - assert result["is_admin"] is True + assert result["role"] != "admin" + assert result["is_admin"] is False assert result["is_active"] is True + # can_read_status defaults True, so the scope-derived set is non-empty + # -- but it is a set, not "every permission there is". assert len(result["permissions"]) > 0 + assert "users:create" not in result["permissions"] @pytest.mark.asyncio @pytest.mark.integration - async def test_me_with_api_key_header(self, async_client: AsyncClient, db_session): - """Verify /me returns synthetic admin user when using X-API-Key header.""" + async def test_me_with_ownerless_api_key_header(self, async_client: AsyncClient, db_session): + """Same as above via the X-API-Key header rather than Bearer.""" from backend.app.core.auth import generate_api_key from backend.app.models.api_key import APIKey @@ -279,7 +282,7 @@ class TestAuthMeAPI: result = response.json() assert result["id"] == 0 assert result["username"].startswith("api-key:") - assert result["is_admin"] is True + assert result["is_admin"] is False @pytest.mark.asyncio @pytest.mark.integration @@ -292,6 +295,127 @@ class TestAuthMeAPI: assert response.status_code == 401 + async def _owned_key(self, async_client: AsyncClient, db_session, **scopes): + """Set up auth and return (owner, full_key) for a key with ``scopes``. + + The owner is given an email and a group explicitly rather than relying + on what /auth/setup happens to seed, so the assertions about what /me + withholds cannot pass vacuously. + """ + from sqlalchemy import select + from sqlalchemy.orm import selectinload + + from backend.app.core.auth import generate_api_key + from backend.app.models.api_key import APIKey + from backend.app.models.group import Group + from backend.app.models.user import User + + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "keyowner", + "admin_password": "KeyPass1!", + }, + ) + owner = ( + await db_session.execute(select(User).where(User.username == "keyowner").options(selectinload(User.groups))) + ).scalar_one() + owner.email = "keyowner@example.invalid" + group = Group(name="key-owner-group", description="t", permissions=["printers:read"], is_system=False) + db_session.add(group) + await db_session.flush() + owner.groups.append(group) + + full_key, key_hash, key_prefix = generate_api_key() + db_session.add( + APIKey(name="owned", key_hash=key_hash, key_prefix=key_prefix, enabled=True, user_id=owner.id, **scopes) + ) + await db_session.commit() + return owner, full_key + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_me_reports_the_key_owner_not_a_synthetic_admin(self, async_client: AsyncClient, db_session): + """The id is the point of #1894 -- it is what created_by_id filters on.""" + owner, full_key = await self._owned_key(async_client, db_session) + + response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key}) + + assert response.status_code == 200 + result = response.json() + assert result["id"] == owner.id + assert result["username"] == "keyowner" + # The owner is an admin; the key still is not, because no key reaches + # an administrative route regardless of who owns it. + assert result["is_admin"] is False + assert result["role"] != "admin" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_me_withholds_owner_email_and_groups(self, async_client: AsyncClient, db_session): + """Identity, not the owner's profile -- anyone holding the key sees this.""" + owner, full_key = await self._owned_key(async_client, db_session) + assert owner.email is not None and owner.groups # the helper made both non-empty + + result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json() + + assert result["email"] is None + assert result["groups"] == [] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_me_permissions_track_the_key_scopes_not_the_owner(self, async_client: AsyncClient, db_session): + """A key owned by an admin still reports only what its flags allow.""" + _, full_key = await self._owned_key( + async_client, + db_session, + can_read_status=True, + can_control_printer=False, + can_queue=False, + ) + + perms = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json()["permissions"] + + assert "printers:read" in perms # can_read_status + assert "printers:control" not in perms # can_control_printer is off + assert "queue:create" not in perms # can_queue is off + assert "users:create" not in perms # administrative: unmapped for keys + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_me_permissions_are_exactly_what_the_gate_admits(self, async_client: AsyncClient, db_session): + """/me must not drift from _check_apikey_permissions. + + The whole defect in #1894 was a /me response that described a different + credential than the one the gate enforces, so pin them to each other + rather than to a hand-written list that can rot. The owner is threaded + through both sides for the same reason -- the gate narrows to the + owner's permissions, so a check that skipped the owner would stop + catching drift the moment the owner is not an administrator. + """ + from fastapi import HTTPException + from sqlalchemy import select + + from backend.app.core.auth import _check_apikey_permissions, resolve_apikey_owner + from backend.app.core.permissions import ALL_PERMISSIONS + from backend.app.models.api_key import APIKey + + _, full_key = await self._owned_key(async_client, db_session, can_read_status=True, can_control_printer=False) + + response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key}) + reported = set(response.json()["permissions"]) + + key = (await db_session.execute(select(APIKey).where(APIKey.name == "owned"))).scalar_one() + owner = await resolve_apikey_owner(db_session, key) + for perm in ALL_PERMISSIONS: + try: + _check_apikey_permissions(key, [perm], owner=owner) + except HTTPException: + assert perm not in reported, f"/me reports '{perm}' but the gate denies it" + else: + assert perm in reported, f"the gate admits '{perm}' but /me omits it" + class TestUsersAPI: """Integration tests for /api/v1/users/ endpoints.""" diff --git a/backend/tests/integration/test_queue_creation_attribution.py b/backend/tests/integration/test_queue_creation_attribution.py index 8dab62d0e..531b95540 100644 --- a/backend/tests/integration/test_queue_creation_attribution.py +++ b/backend/tests/integration/test_queue_creation_attribution.py @@ -30,6 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from backend.app.core.auth import generate_api_key from backend.app.core.config import settings as app_settings from backend.app.models.api_key import APIKey +from backend.app.models.group import Group from backend.app.models.print_queue import PrintQueueItem from backend.app.models.user import User @@ -173,7 +174,14 @@ class TestWebhookQueueAddAttribution: async def test_credits_the_key_owner(self, async_client: AsyncClient, db_session, test_engine, printer_and_archive): printer, archive = printer_and_archive - owner = User(username="keyowner", password_hash="x", is_active=True) + # The owner needs queue:create in their own right: a key is capped by + # its owner's permissions (#1894), so a bare account with no groups + # cannot queue through a key however its scope flags are set. This test + # is about who the row is credited to, not about the gate. + group = Group(name="queue-writers", description="t", permissions=["queue:create"], is_system=False) + db_session.add(group) + await db_session.flush() + owner = User(username="keyowner", password_hash="x", is_active=True, groups=[group]) db_session.add(owner) await db_session.commit() await db_session.refresh(owner) diff --git a/backend/tests/integration/test_users_slim_1894.py b/backend/tests/integration/test_users_slim_1894.py new file mode 100644 index 000000000..7dfb1f9fb --- /dev/null +++ b/backend/tests/integration/test_users_slim_1894.py @@ -0,0 +1,180 @@ +"""GET /api/v1/users/slim -- the id -> username mapping for API clients (#1894). + +An API key could already read global archive stats and filter them by +``created_by_id`` (for API-keyed requests the permission deps return None as +``current_user``, so the ``stats:filter_by_user`` guard short-circuits), but +had no way to discover which id belonged to whom: the full listing is gated on +``users:read``, which is unmapped in the API-key scope allowlist and therefore +administrative. + +The slim listing closes that gap without handing keys the full user objects. +These tests pin both halves: that it answers for a key, and that it stays +narrow while the full listing stays admin-only. +""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from backend.app.core.auth import generate_api_key +from backend.app.models.api_key import APIKey +from backend.app.models.group import Group +from backend.app.models.user import User + + +async def _setup_and_login(async_client: AsyncClient) -> str: + await async_client.post( + "/api/v1/auth/setup", + json={"auth_enabled": True, "admin_username": "slimadmin", "admin_password": "SlimPass1!"}, + ) + login = await async_client.post( + "/api/v1/auth/login", + json={"username": "slimadmin", "password": "SlimPass1!"}, + ) + return login.json()["access_token"] + + +async def _add_key(db_session, *, user_id: int | None = None, **scopes) -> str: + full_key, key_hash, key_prefix = generate_api_key() + db_session.add( + APIKey(name="slim-test", key_hash=key_hash, key_prefix=key_prefix, enabled=True, user_id=user_id, **scopes) + ) + await db_session.commit() + return full_key + + +async def _add_user(db_session, username: str, **kwargs) -> User: + from backend.app.core.auth import get_password_hash + + user = User( + username=username, + password_hash=get_password_hash("Whatever1!"), + email=f"{username}@example.invalid", + role="user", + is_active=True, + **kwargs, + ) + db_session.add(user) + await db_session.commit() + return user + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_slim_returns_only_id_and_username(async_client: AsyncClient, db_session): + """The response shape is the contract -- no emails, roles, or permissions.""" + token = await _setup_and_login(async_client) + await _add_user(db_session, "bob") + + response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + rows = response.json() + assert rows, "expected at least the admin created by setup" + for row in rows: + assert set(row) == {"id", "username"} + assert "bob" in [row["username"] for row in rows] + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_slim_is_reachable_with_an_api_key(async_client: AsyncClient, db_session): + """The point of the issue: a key can resolve the ids it already filters on.""" + await _setup_and_login(async_client) + owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one() + full_key = await _add_key(db_session, user_id=owner.id, can_read_status=True) + + header = await async_client.get("/api/v1/users/slim", headers={"X-API-Key": full_key}) + bearer = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {full_key}"}) + + assert header.status_code == 200 + assert bearer.status_code == 200 + assert {row["username"] for row in header.json()} == {"slimadmin"} + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_slim_needs_can_read_status(async_client: AsyncClient, db_session): + """A key without the read scope gets nothing, same as any other read route.""" + await _setup_and_login(async_client) + owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one() + full_key = await _add_key(db_session, user_id=owner.id, can_read_status=False) + + response = await async_client.get("/api/v1/users/slim", headers={"X-API-Key": full_key}) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_full_listing_stays_admin_only_for_api_keys(async_client: AsyncClient, db_session): + """Regression guard: widening the slim route must not widen the full one. + + ``users:read`` returns emails, group membership and the complete permission + set for every account. It has to stay unmapped in the scope allowlist. + """ + await _setup_and_login(async_client) + owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one() + full_key = await _add_key(db_session, user_id=owner.id, can_read_status=True) + + response = await async_client.get("/api/v1/users", headers={"X-API-Key": full_key}) + + assert response.status_code == 403 + assert "administrative" in response.json()["detail"] + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_slim_is_not_parsed_as_a_user_id(async_client: AsyncClient, db_session): + """Route ordering. Declared after /{user_id}, "slim" would 422 as an int.""" + token = await _setup_and_login(async_client) + + response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code != 422 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_a_group_with_only_users_read_still_reaches_slim(async_client: AsyncClient, db_session): + """``users:read`` is strictly broader, so it must pass the any-of gate. + + Without this, every existing custom group holding ``users:read`` would need + a permission backfill before the frontend could ever move to this route. + """ + await _setup_and_login(async_client) + group = Group(name="readers", description="t", permissions=["users:read"], is_system=False) + db_session.add(group) + await db_session.flush() + await _add_user(db_session, "reader", groups=[group]) + + login = await async_client.post( + "/api/v1/auth/login", + json={"username": "reader", "password": "Whatever1!"}, + ) + token = login.json()["access_token"] + + response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_a_group_with_only_slim_cannot_read_the_full_listing(async_client: AsyncClient, db_session): + """The narrow grant has to actually be narrower for JWT users too.""" + await _setup_and_login(async_client) + group = Group(name="slim-only", description="t", permissions=["users:read_slim"], is_system=False) + db_session.add(group) + await db_session.flush() + await _add_user(db_session, "slimonly", groups=[group]) + + login = await async_client.post( + "/api/v1/auth/login", + json={"username": "slimonly", "password": "Whatever1!"}, + ) + token = login.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + assert (await async_client.get("/api/v1/users/slim", headers=headers)).status_code == 200 + assert (await async_client.get("/api/v1/users", headers=headers)).status_code == 403 diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 01babb0ec..8acedbe6c 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -484,6 +484,7 @@ export const handlers = [ http.get('/api/v1/spoolman/spools/linked', () => HttpResponse.json([])), http.get('/api/v1/spoolman/spools/unlinked', () => HttpResponse.json([])), http.get('/api/v1/users/', () => HttpResponse.json([])), + http.get('/api/v1/users/slim', () => HttpResponse.json([])), // Status / object endpoints → minimal disabled-state responses http.get('/api/v1/archives/purge/settings', () => diff --git a/frontend/src/__tests__/pages/StatsPageUserFilter1894.test.tsx b/frontend/src/__tests__/pages/StatsPageUserFilter1894.test.tsx new file mode 100644 index 000000000..cff2856cc --- /dev/null +++ b/frontend/src/__tests__/pages/StatsPageUserFilter1894.test.tsx @@ -0,0 +1,73 @@ +/** + * The Stats filter-by-user dropdown sources names from the slim listing (#1894). + * + * `stats:filter_by_user` is a permission an operator can be granted on its own, + * but the dropdown used to be populated from the admin-level `users:read` + * listing. An operator who had been granted the filter therefore saw an empty + * control -- the filter renders only when the user list is non-empty -- and had + * no way to tell whether that meant "no users" or "not allowed to look". + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { render } from '../utils'; +import { server } from '../mocks/server'; +import { StatsPage } from '../../pages/StatsPage'; +import { setAuthToken } from '../../api/client'; + +function signInAs(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: 1, username: 'operator', is_admin: false, permissions }), + ), + ); +} + +afterEach(() => { + setAuthToken(null); +}); + +describe('stats filter-by-user (#1894)', () => { + it('populates from /users/slim without the admin-level users:read', async () => { + signInAs(['stats:read', 'stats:filter_by_user']); + server.use( + // The admin listing is exactly what such an operator cannot call. + http.get('*/api/v1/users/', () => new HttpResponse(null, { status: 403 })), + http.get('*/api/v1/users/slim', () => + HttpResponse.json([ + { id: 1, username: 'operator' }, + { id: 2, username: 'colleague' }, + ]), + ), + ); + + render(); + + // The control only renders once names have arrived, so its presence is + // the assertion -- an empty list leaves it out of the tree entirely. + await waitFor(() => { + expect(screen.getByText('All Users')).toBeInTheDocument(); + }); + }); + + it('stays hidden when the user has no filter permission', async () => { + signInAs(['stats:read']); + server.use( + http.get('*/api/v1/users/slim', () => + HttpResponse.json([{ id: 1, username: 'operator' }]), + ), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Quick Stats')).toBeInTheDocument(); + }); + expect(screen.queryByText('All Users')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1b7b7e1a4..4b8b64351 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3783,7 +3783,7 @@ export type Permission = | 'cloud:auth' | 'orca_cloud:auth' | 'makerworld:view' | 'makerworld:import' | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete' - | 'users:read' | 'users:create' | 'users:update' | 'users:delete' + | 'users:read' | 'users:read_slim' | 'users:create' | 'users:update' | 'users:delete' | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete' | 'pipelines:read' | 'pipelines:write' | 'pipelines:run' | 'websocket:connect'; @@ -3873,6 +3873,17 @@ export interface UserResponse { created_at: string; } +/** + * Just enough to label an owner id (#1894). Backed by GET /users/slim, which + * is readable with `users:read_slim` as well as the admin-level `users:read` + * -- use it anywhere a screen only needs to turn a `created_by_id` into a + * name, so operators are not forced into the full listing to get one. + */ +export interface UserSlim { + id: number; + username: string; +} + export interface UserCreate { username: string; password?: string; // Optional when advanced auth is enabled @@ -4264,6 +4275,7 @@ export const api = { // Users getUsers: () => request('/users/'), + getUsersSlim: () => request('/users/slim'), getUser: (id: number) => request(`/users/${id}`), createUser: (data: UserCreate) => request('/users/', { diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 3f72570f8..c81e0025b 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -2930,9 +2930,10 @@ export function ArchivesPage() { queryFn: api.getSettings, }); + // Print Log user filter -- names only, so the slim listing is enough (#1894). const { data: users } = useQuery({ - queryKey: ['users'], - queryFn: api.getUsers, + queryKey: ['users', 'slim'], + queryFn: api.getUsersSlim, enabled: viewMode === 'log', }); diff --git a/frontend/src/pages/CameraTokensPage.tsx b/frontend/src/pages/CameraTokensPage.tsx index 8ebf84382..e6987a41f 100644 --- a/frontend/src/pages/CameraTokensPage.tsx +++ b/frontend/src/pages/CameraTokensPage.tsx @@ -472,8 +472,8 @@ export function CameraTokensSection() { // (e.g. permission missing for some reason), the table still renders // with the numeric user_id as fallback. try { - const users = await api.getUsers(); - setUserIdToName(new Map(users.map((u: { id: number; username: string }) => [u.id, u.username]))); + const users = await api.getUsersSlim(); + setUserIdToName(new Map(users.map((u) => [u.id, u.username]))); } catch { setUserIdToName(new Map()); } diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 1fa82151a..98481c2db 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -1305,10 +1305,10 @@ export function FileManagerPage() { queryFn: () => api.getLibraryStats(), }); - // Get users for the username filter autocomplete + // Get users for the username filter autocomplete -- names only (#1894) const { data: users } = useQuery({ - queryKey: ['users'], - queryFn: () => api.getUsers(), + queryKey: ['users', 'slim'], + queryFn: () => api.getUsersSlim(), }); // Get unique file types for filter dropdown diff --git a/frontend/src/pages/FinancePage.tsx b/frontend/src/pages/FinancePage.tsx index 0ef1722af..c7548fa2d 100644 --- a/frontend/src/pages/FinancePage.tsx +++ b/frontend/src/pages/FinancePage.tsx @@ -105,7 +105,10 @@ export function FinancePage() { const canUpdateBudgets = hasPermission('cost_centers:modify'); const canAssignCostCenterUsers = hasPermission('cost_centers:modify'); const canAdjustWallet = hasPermission('cost_centers:modify'); - const canReadUsers = hasPermission('users:read'); + // Finance only ever labels a user or picks one to assign, so the slim + // listing suffices -- and a billing operator should not need the admin-level + // users:read (emails, roles, permission sets) to staff a cost center (#1894). + const canReadUsers = hasPermission('users:read_slim') || hasPermission('users:read'); const canAccessAllCostCenters = canReadAllFinance || @@ -202,8 +205,8 @@ export function FinancePage() { }); const { data: users } = useQuery({ - queryKey: ['users'], - queryFn: api.getUsers, + queryKey: ['users', 'slim'], + queryFn: api.getUsersSlim, enabled: canReadUsers && (canViewMyCostCenters || canAdjustWallet || canAssignCostCenterUsers), }); diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 82604baa0..d4777ce34 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -1053,9 +1053,12 @@ export function StatsPage() { queryFn: api.getSettings, }); + // Slim listing (#1894): the filter only needs id + username, and gating it + // on the admin-level users:read left the dropdown empty for exactly the + // operators who were granted stats:filter_by_user. const { data: users } = useQuery({ - queryKey: ['users'], - queryFn: api.getUsers, + queryKey: ['users', 'slim'], + queryFn: api.getUsersSlim, enabled: canFilterByUser, }); diff --git a/static/assets/index-Bh7umAlT.js b/static/assets/index-DjNRlhiN.js similarity index 91% rename from static/assets/index-Bh7umAlT.js rename to static/assets/index-DjNRlhiN.js index 3c3a62403..5c28e13b0 100644 --- a/static/assets/index-Bh7umAlT.js +++ b/static/assets/index-DjNRlhiN.js @@ -47,7 +47,7 @@ ON değeri: "ON" anlamına gelen tam string. Otomatik algılama için boş bıra Для перетворення Wh→kWh використовуйте множник 0.001, а для MWh→kWh — 1000.`,mqttStateHint:`Шлях JSON витягує значення з корисного навантаження JSON. Залиште поле порожнім для безпосередньо переданих значень. Значення «увімкнено»: точний рядок, що означає стан «увімкнено». Залиште поле порожнім для автоматичного визначення (ON, true, 1).`,restControl:`Керування`,restOnUrl:`Увімкнути URL`,restOffUrl:`Вимкнути URL`,restOnBody:`Тіло запиту ввімкнення`,restOffBody:`Тіло запиту вимкнення`,restMethod:`Метод HTTP`,restHeaders:`Спеціальні заголовки (JSON)`,restStatusUrl:`URL стану`,restStatusPath:`Шлях JSON до стану`,restStatusOnValue:`Значення «увімкнено»`,restPowerUrl:`URL даних про потужність`,restPowerPath:`Шлях JSON до потужності`,restPowerMultiplier:`Множник потужності`,restEnergyUrl:`URL даних про енергію`,restEnergyPath:`Шлях JSON до споживання за сьогодні`,restEnergyTotalPath:`Шлях JSON до загального споживання`,restEnergyTotalMultiplier:`Множник загального споживання`,restEnergyTotalPathHint:`наприклад, aenergy.total`,restEnergyTotalHint:`Багато розеток, зокрема всі моделі Shelly, повідомляють лише загальний лічильник споживання, який ніколи не скидається. Його слід указувати саме тут, а не в полі вище: якщо трактувати це значення як споживання за сьогодні, воно не скинеться опівночі, а показники «Учора» та «Усього» залишатимуться порожніми. Bambuddy сам обчислює показники за сьогодні й учора на основі цього лічильника; для їх появи потрібні один-два дні даних. Shelly передає значення у ват-годинах, тому використовуйте множник 0.001.`,restEnergyMultiplier:`Множник енергії`,restUrlRequired:`Для REST-розетки потрібно вказати принаймні один URL — для ввімкнення або вимкнення.`,restHeadersHint:`наприклад, {"Authorization": "Bearer your-token"}`,restBodyHint:`напр. ON, {"state": "on"}`,restStatusHint:`URL для опитування поточного стану`,restPathHint:`наприклад, state або data.power.status`,restPowerUrlHint:`Окремий URL для даних про потужність; якщо поле порожнє, використовується URL стану`,restEnergyUrlHint:`Окремий URL для даних про енергію; якщо поле порожнє, використовується URL стану`,restEnergyHint:`Для кожного значення можна використовувати окремий URL або URL стану як резервний. Для перетворення одиниць використовуйте множники, наприклад 0.001 для перетворення Wh у kWh.`,testConnection:`Тестове підключення`,connectionSuccess:`Підключення успішне`,noSwitchesInSwitchbar:`На панелі перемикачів немає розеток`,enableSwitchbarHint:`Увімкніть «Показувати на панелі перемикачів» у розділі «Налаштування → Розумні розетки».`},notifications:{providerTypes:{callmebot:`CallMeBot/WhatsApp`,ntfy:`ntfy`,pushover:`Pushover`,telegram:`Telegram`,email:`Електронна пошта`,discord:`Discord`,webhook:`Webhook`,homeassistant:`Home Assistant`,bark:`Bark`},providerDescriptions:{email:`Сповіщення електронною поштою SMTP`,telegram:`Сповіщення через бота Telegram.`,discord:`Надіслати на канал Discord через webhook`,ntfy:`Безкоштовні push-сповіщення з можливістю самостійного розгортання`,pushover:`Прості та надійні push-повідомлення`,callmebot:`Безкоштовні сповіщення WhatsApp через CallMeBot`,webhook:`Універсальний HTTP POST-запит на будь-який URL`,homeassistant:`Постійні сповіщення на інформаційній панелі Home Assistant.`,bark:`Push-сповіщення iOS через Bark із можливістю самостійного розгортання`},lastSuccess:`Останнє успішне надсилання: {{date}}`,error:`Помилка`,printer:`Принтер:`,allPrinters:`Всі принтери`,sendTestNotification:`Надіслати тестове сповіщення`,eventSettings:`Налаштування події`,enabled:`Увімкнено`,sendFromProvider:`Надсилати сповіщення від цього постачальника`,printEvents:`Події друку`,printerStatus:`Стан принтера`,amsAlarms:`Попередження AMS`,amsHtAlarms:`Попередження AMS-HT`,printQueue:`Черга друку`,start:`Початок`,plateCheck:`Перевірка пластини`,complete:`Завершено`,failed:`Помилка`,stopped:`Зупинено`,progress:`Прогрес`,offline:`Не в мережі`,lowFilament:`Закінчується філамент`,maintenance:`Технічне обслуговування`,amsHumidity:`AMS Вологість`,amsTemp:`AMS Темп`,amsDryingSuspended:`Сушіння призупинено`,amsHtHumidity:`AMS-HT Вологість`,amsHtTemp:`Температура AMS-HT`,bedCooled:`Стіл охолов`,firstLayer:`Перший шар`,quiet:`Тихо`,digest:`Дайджест {{time}}`,printStarted:`Друк розпочато`,plateNotEmpty:`Друкарська пластина не порожня`,plateNotEmptyDescription:`Перед друком виявлено об’єкти`,plateClearRequired:`Потрібно очистити пластину`,plateClearRequiredDescription:`Друк завершено, черга очікує підтвердження очищення пластини`,plateClear:`Очищення пластини`,printCompleted:`Друк завершено`,bedCooledLabel:`Стіл охолов`,bedCooledDescription:`Після друку стіл охолов нижче заданого порога`,firstLayerCompleteLabel:`Перший шар завершено`,firstLayerCompleteDescription:`Сповістити зі знімком після завершення першого шару`,missingSpoolAssignmentLabel:`Відсутнє призначення котушки`,billingChargeFailedLabel:`Помилка списання`,billingChargeFailedDescription:`Сповіщати, якщо не вдалося облікувати вартість друку`,missingSpoolAssignmentDescription:`Сповіщати, коли починається друк і для необхідних лотків не призначено котушку`,printFailed:`Помилка друку`,printStopped:`Друк зупинено`,progressMilestones:`Віхи прогресу`,progressMilestonesDescription:`Сповіщати на 25%, 50%, 75%`,printerOffline:`Принтер не в мережі`,printerError:`Помилка принтера`,aiFailureDetection:`Виявлення помилок за допомогою ШІ`,aiFailureDetectionDescription:`Сповіщати, коли ШІ Obico виявляє можливу помилку друку`,lowFilamentLabel:`Закінчується філамент`,maintenanceDue:`Настав час технічного обслуговування`,maintenanceDueDescription:`Повідомити, коли потрібне обслуговування`,amsHumidityHigh:`AMS Висока вологість`,amsHumidityHighDescription:`Звичайна AMS вологість перевищує порогове значення`,amsTemperatureHigh:`AMS Висока температура`,amsTemperatureHighDescription:`Звичайна AMS температура перевищує порогове значення`,amsDryingSuspendedTitle:`Автоматичне сушіння призупинено`,amsDryingSuspendedDescription:`Автоматичне сушіння блока AMS припинено: вологість так і не опустилася нижче порога`,amsHtHumidityHigh:`AMS-HT Висока вологість`,amsHtHumidityHighDescription:`AMS-HT вологість перевищує порогове значення`,amsHtTemperatureHigh:`Висока температура AMS-HT`,amsHtTemperatureHighDescription:`Температура AMS-HT перевищує порогове значення`,inventoryAlerts:`Сповіщення про запаси`,stockReorderAlert:`Сповіщення про необхідність поповнення`,stockReorderAlertDescription:`Запас SKU досяг точки повторного замовлення`,stockBreakAlert:`Сповіщення про ризик вичерпання запасу`,stockBreakAlertDescription:`Запас закінчиться до того, як надійде поповнення`,jobAdded:`Завдання додано`,jobAddedDescription:`Завдання додано до черги`,jobAssigned:`Завдання призначено`,jobAssignedDescription:`Завдання на основі моделі, призначене принтеру`,jobStarted:`Завдання розпочато`,jobStartedDescription:`Розпочато друк завдання черги`,jobWaiting:`Завдання очікує`,jobWaitingDescription:`Завдання очікує на філамент або принтер`,jobSkipped:`Завдання пропущено`,jobSkippedDescription:`Завдання пропущено (попереднє не вдалося)`,jobFailed:`Помилка завдання`,jobFailedDescription:`Не вдалося розпочати завдання`,queueComplete:`Черга завершена`,queueCompleteDescription:`Усі завдання черги завершено`,quietHours:`Тихі години`,noNotificationsDuring:`Жодних сповіщень у ці години`,editProviderToChangeQuietHours:`Змініть постачальника, щоб змінити години тиші`,dailyDigest:`Щоденний дайджест`,batchNotifications:`Групуйте сповіщення в єдиний щоденний підсумок`,sendAt:`Надсилати о {{time}}`,editProviderToChangeDigestTime:`Відредагуйте постачальника, щоб змінити час дайджесту`,edit:`Редагувати`,deleteProvider:`Видалити постачальника сповіщень`,deleteConfirm:`Видалити «{{name}}»? Цю дію неможливо скасувати.`,delete:`Видалити`,addTitle:`Додати постачальника сповіщень`,editTitle:`Редагувати постачальника сповіщень`,nameLabel:`Назва *`,namePlaceholder:`Мої сповіщення`,providerTypeLabel:`Тип постачальника *`,configuration:`Конфігурація`,testConfiguration:`Перевірити конфігурацію`,printerFilter:`Фільтр принтера`,onlyFromPrinter:`Надсилати лише сповіщення про події з цього принтера`,quietHoursDnd:`Тихі години (не турбувати)`,quietStart:`Початок`,quietEnd:`Кінець`,dailyDigestLabel:`Щоденний дайджест`,sendDigestAt:`Надсилати дайджест о`,digestCollected:`Події буде зібрано й надіслано в цей час одним зведенням`,notificationEvents:`Події сповіщень`,progressPercent:`(25%, 50%, 75%)`,bedCooledAfterPrint:`(після завершення друку)`,haSensorAlert:`Сповіщення датчика`,haSensorAlertDescription:`(пов'язаний датчик Home Assistant потребує уваги)`,eventPriority:{sectionTitle:`Пріоритет ntfy`,helpNtfy:`Виберіть пріоритет для кожної ввімкненої події. ntfy використовує їх для ескалації сповіщень (звук, видимість, поведінка push). Для рівнів, які тут не встановлено, використовується сервер ntfy за замовчуванням.`,min:`Мін`,low:`Низький`,default:`За замовчуванням`,high:`Високий`,urgent:`Терміново`},cancel:`Скасувати`,save:`зберегти`,add:`додати`,nameRequired:`Необхідно вказати ім’я`,fieldRequired:`{{field}} — обов’язкове поле.`,haDataInvalid:`Поле «Дані» має містити дійсний об’єкт JSON, наприклад {"priority": "high", "ttl": 0}`,phoneNumber:`Номер телефону`,apiKey:`Ключ API`,serverUrl:`Сервер URL`,topic:`Тема`,authToken:`Токен авторизації`,userKey:`Ключ користувача`,appToken:`Токен застосунку`,priority:`Пріоритет`,pushoverRetry:`Інтервал повторення екстреного сповіщення (с)`,pushoverExpire:`Термін дії екстреного сповіщення (с)`,botToken:`Токен бота`,chatId:`Ідентифікатор чату`,telegramThreadId:`ID теми форуму`,telegramThreadIdHelp:`Необов'язково. Надсилає в конкретну тему форум-групи — останнє число у посиланні на тему (t.me/c/.../25). Залиште порожнім для теми General.`,telegramThreadIdInvalid:`ID теми форуму має бути числом.`,smtpServer:`Сервер SMTP`,smtpPort:`Порт SMTP`,security:`Безпека`,authentication:`Автентифікація`,username:`Ім’я користувача`,password:`Пароль`,fromEmail:`Адреса відправника`,toEmail:`Адреса одержувача`,webhookUrl:`Webhook URL`,payloadFormat:`Формат корисного навантаження`,authorization:`Авторизація`,titleFieldName:`Назва поля заголовка`,messageFieldName:`Назва поля повідомлення`,editTemplate:`Редагувати шаблон: {{name}}`,titleLabel:`Заголовок`,bodyLabel:`Текст`,titlePlaceholder:`Назва сповіщення...`,bodyPlaceholder:`Текст сповіщення...`,availableVariables:`Доступні змінні`,clickToInsert:`Натисніть, щоб вставити в текст у позиції курсора`,livePreview:`Попередній перегляд`,hide:`Сховати`,show:`Показати`,loadingPreview:`Завантаження попереднього перегляду...`,enterTemplateContent:`Введіть вміст шаблону, щоб побачити попередній перегляд`,titlePreview:`Назва:`,bodyPreview:`Текст:`,resetToDefault:`Відновити типові значення`,titleRequired:`Необхідно вказати назву`,bodyRequired:`Текст є обов’язковим`,notificationLog:`Журнал повідомлень`,showFailedOnly:`Лише невдалі`,last24Hours:`Останні 24 години`,last7Days:`Останні 7 днів`,last30Days:`Останні 30 днів`,last90Days:`Останні 90 днів`,justNow:`Щойно`,noFailedNotifications:`Немає невдалих сповіщень`,noNotificationsLogged:`Сповіщення не зареєстровано`,unknownProvider:`Невідомий постачальник`,logTitle:`Назва`,logMessage:`повідомлення`,logError:`Помилка`,logProvider:`Постачальник: {{type}}`,logTime:`Час: {{time}}`,refresh:`Оновити`,clearOld:`Очистити старі`,statsSummary:`За останні {{days}} дн.:`,statsNotifications:`сповіщення`,statsSent:`{{count}} надіслано`,statsFailed:`не вдалося надіслати: {{count}}`,eventTypes:{print_start:`Друк розпочато`,print_complete:`Друк завершено`,print_failed:`Помилка друку`,print_stopped:`Друк зупинено`,print_progress:`Прогрес`,printer_offline:`Принтер не в мережі`,printer_error:`Помилка принтера`,filament_low:`Закінчується філамент`,maintenance_due:`Технічне обслуговування`,test:`Тест`},userEmail:{title:`Сповіщення`,emailNotifications:`Сповіщення електронною поштою`,emailNotificationsDesc:`Отримуйте сповіщення електронною поштою про власні завдання друку. Електронні листи надсилаються за допомогою системних параметрів SMTP, налаштованих у розширеній автентифікації.`,sendingTo:`Сповіщення будуть надіслані на адресу`,noEmailWarning:`У вашому обліковому записі немає адреси електронної пошти. Зверніться до адміністратора, щоб її додати.`,printJobNotifications:`Повідомлення про завдання друку`,printJobNotificationsDesc:`Виберіть, які події викликають сповіщення електронною поштою про завдання друку, які ви надсилаєте.`,printJobStarts:`Початок завдання друку`,printJobStartsDesc:`Отримуйте сповіщення, коли розпочнеться ваше завдання друку.`,printJobFinishes:`Завершення завдання друку`,printJobFinishesDesc:`Отримуйте сповіщення, коли ваше завдання друку буде успішно завершено.`,printErrors:`Помилки друку`,printErrorsDesc:`Отримуйте сповіщення, якщо завдання друку завершується невдало або під час нього виникає помилка.`,printJobStops:`Зупинення завдання друку`,printJobStopsDesc:`Отримуйте сповіщення, коли ваше завдання друку скасовано або зупинено.`,saveSuccess:`Параметри сповіщень збережено.`,saveError:`Не вдалося зберегти налаштування сповіщень.`}},richTextEditor:{bold:`жирний`,italic:`Курсив`,underline:`Підкреслити`,bulletList:`Список маркерів`,numberedList:`Нумерований список`,alignLeft:`Вирівняти по лівому краю`,alignCenter:`Вирівняти по центру`,alignRight:`Вирівняти праворуч`,addLink:`Додати посилання`,removeLink:`Видалити посилання`},externalLinks:{title:`Посилання на бічній панелі`,sidebarLayout:`Бічна панель`,sidebarLayoutDescription:`Показуйте або приховуйте вбудовані сторінки, додавайте зовнішні посилання та перетягуйте елементи, щоб змінити порядок навігації на бічній панелі.`,systemPages:`Bambuddy сторінок`,externalLinks:`Зовнішні посилання`,visibleInSidebar:`Видно на бічній панелі`,hiddenFromSidebar:`Приховано від бічної панелі`,requiredInSidebar:`Обов’язково на бічній панелі`,hidePage:`Приховати сторінку`,showPage:`Показати сторінку`,settingsCannotBeHidden:`Налаштування не можна приховати`,noLinksConfigured:`Зовнішні посилання не налаштовано`,deleteLink:`Видалити посилання`,removeCustomIcon:`Видалити спеціальний значок`,openInNewTab:`Відкрити в новій вкладці`,placeholders:{linkName:`Моє посилання`}},keyboardShortcuts:{title:`Комбінації клавіш`,navigation:`Навігація`,archivesSection:`Архіви`,kProfilesSection:`K-профілі`,generalSection:`Загальний`,shortcuts:{goToPrinters:`Перейдіть до Принтери`,goToArchives:`Перейдіть до архіву`,goToQueue:`Перейдіть до черги`,goToStats:`Перейти до статистики`,goToProfiles:`Перейдіть до Cloud Profiles`,goToSettings:`Перейдіть до Налаштувань`,focusSearch:`Пошук по фокусу`,openUploadModal:`Відкрити вікно вивантаження`,clearSelection:`Очистити виділення / розмити введення`,contextMenu:`Контекстне меню на картках`,refreshProfiles:`Оновити профілі`,newProfile:`Новий профіль`,exitSelectionMode:`Вийти з режиму вибору`,showHelp:`Покажіть цю довідку`},footer:`Натисніть Esc або клацніть зовні, щоб закрити`},notificationLog:{title:`Журнал повідомлень`,events:{printStarted:`Друк розпочато`,printComplete:`Друк завершено`,printFailed:`Помилка друку`,printStopped:`Друк зупинено`,progress:`Прогрес`,printerOffline:`Принтер не в мережі`,printerError:`Помилка принтера`,lowFilament:`Закінчується філамент`,maintenanceDue:`Технічне обслуговування`,test:`Тест`},timeAgo:{justNow:`Просто зараз`,minutesAgo:`{{minutes}}хв тому`,hoursAgo:`{{hours}} год тому`}},restoreBackup:{title:`Відновити резервну копію`,restoring:`Відновлення...`,restoreComplete:`Відновлення завершено`,restoreFailed:`Не вдалося відновити`,importSettings:`Імпорт налаштувань із файлу резервної копії`,pleaseWait:`Зачекайте, поки ваші дані відновляться`,clickToSelect:`Натисніть, щоб вибрати файл резервної копії (.json або .zip)`,howDuplicateHandling:`Як працює обробка дублікатів:`,categories:{printers:`Принтери`,smartPlugs:`Розумні розетки`,notificationProviders:`Постачальники сповіщень`,filaments:`філаменти`,archives:`Архіви`,pendingUploads:`Вивантаження, що очікують`,settingsTemplates:`Налаштування та шаблони`},matchingInfo:{printers:`відповідає серійному номеру`,smartPlugs:`збігається з адресою IP.`,notificationProviders:`відповідність за назвою`,filaments:`відповідність за назвою + типом + брендом`,archives:`відповідає хешу вмісту`,pendingUploads:`відповідає імені файлу`,settingsTemplates:`завжди перезаписується`},replaceExisting:`Замінити існуючі дані`,keepExisting:`Зберегти наявні дані`,replaceDescription:`Замінити елементи, які вже існують, резервними копіями`,keepDescription:`Відновлюйте лише елементи, яких ще не існує`,caution:`Увага:`,cautionText:`Під час перезапису поточні конфігурації буде замінено даними з резервної копії. З міркувань безпеки коди доступу до принтерів ніколи не перезаписуються.`,itemsRestored:`Елементів відновлено`,itemsSkipped:`Елементи пропущені`,restored:`Відновлено`,skipped:`Пропущено (вже існує)`,filesLabel:`Файли (3MF, мініатюри тощо)`,newApiKeysGenerated:`Створено нові API ключі`,newApiKeysWarning:`Ці ключі буде показано лише один раз. Скопіюйте їх зараз!`,processingBackup:`Обробка файлу резервної копії...`,noDataFound:`У файлі резервної копії не знайдено даних для відновлення.`,failedToRestore:`Не вдалося відновити резервну копію. Будь ласка, перевірте формат файлу.`},backupExport:{title:`Експорт резервної копії`,selectData:`Вибрати дані для включення`,selectAll:`Вибрати усі`,selectNone:`Вибрати Немає`,categoryDescriptions:{settings:`Мова, тема, параметри оновлення`,notifications:`ntfy, Pushover, Discord тощо.`,templates:`Користувацькі шаблони повідомлень`,smartPlugs:`Конфігурації розетки Tasmota.`,externalLinks:`Посилання на бічній панелі на зовнішні служби`,printers:`Інформація про принтер (без кодів доступу)`,plateDetection:`Еталонні зображення порожньої пластини`,filaments:`Види та вартість філаменти`,maintenance:`Спеціальні графіки технічного обслуговування`,archives:`Усі дані для друку + файли (3MF, мініатюри, фотографії)`,projects:`Проєкти, елементи специфікації та вкладення`,pendingUploads:`Вивантаження на віртуальний принтер очікують на розгляд`,apiKeys:`Ключі Webhook API (нові ключі створюються під час імпорту)`},requiresPrinters:`Необхідно вибрати принтери`,zipFileWarning:`Буде створено файл ZIP.`,zipFileDescription:`До архіву ввійдуть усі файли 3MF, мініатюри, таймлапси та фотографії. Створення може тривати певний час, а отриманий файл може мати великий розмір.`,includeAccessCodes:`Додати коди доступу`,includeAccessCodesDescription:`Для перенесення на іншу машину`,includeAccessCodesWarning:`Коди доступу буде збережено у відкритому вигляді. Надійно захистіть файл резервної копії!`,categoriesSelected:`Вибрано категорії {{selectedCount}}.`},pendingUploads:{placeholders:{notes:`Додайте примітки щодо цього друку...`},discardUpload:`Відхилити вивантаження`,archiveAllUploads:`Архівувати всі вивантаження`,discardAllUploads:`Відхилити всі вивантаження`,archive:`Архів`,timeAgo:{justNow:`Просто зараз`,minutesAgo:`{{minutes}}хв тому`,hoursAgo:`{{hours}} год тому`,daysAgo:`{{days}} дн. тому`}},apiBrowser:{placeholders:{requestBody:`JSON тіло запиту...`,searchEndpoints:`Пошук кінцевих точок...`}},configureAmsSlot:{title:`Налаштувати слот AMS.`,slotConfigured:`Слот налаштовано!`,configuringSlot:`Налаштування слота:`,slotLabel:`{{ams}} Слот {{slot}}`,searchPresets:`Шукати профілі...`,colorPlaceholder:`Назва або шістнадцяткова назва кольору (наприклад, коричневий, FF8800)`,clearCustomColor:`Очистити спеціальний колір`,noCloudPresets:`Хмарних профілів немає. Увійдіть у Bambu Cloud для синхронізації.`,noPresetsAvailable:`Профілів немає. Увійдіть у Bambu Cloud або імпортуйте локальні профілі.`,noMatchingPresets:`Відповідних стилів не знайдено.`,custom:`Власний`,builtin:`Вбудований`,orcaCloud:`Orca Cloud`,bambuCloud:`Бамбукова хмара`,settingsSentToPrinter:`Налаштування надіслано на принтер`,filamentProfile:`Профіль філаменту`,kProfileLabel:`Профіль K (випередження тиску)`,filteringFor:`Фільтрування за: {{material}}`,noKProfile:`Немає профілю K (використовуйте значення за замовчуванням 0,020)`,noMatchingKProfiles:`Не знайдено відповідних K профілів. Використовуватиметься K=0,020 за замовчуванням.`,otherKProfiles:`Інші K-профілі на цьому принтері`,selectFilamentFirst:`Спочатку виберіть профіль філаменту`,kFromCalibration:`K={{value}} від калібрування принтера`,customColorLabel:`Власний колір (необов’язково)`,presetColors:`{{name}} кольори:`,showLessColors:`Показувати менше кольорів`,showMoreColors:`Показати більше кольорів`,clear:`Очистити`,hexLabel:`Шістнадцятковий: #{{hex}}`,resetting:`Скидання...`,resetSlot:`Скинути слот`,cancel:`Скасувати`,configuring:`Налаштування...`,configureSlot:`Налаштувати слот`},githubBackup:{title:`Резервне копіювання Git`,history:`історія`,downloadBackup:`Завантажити резервну копію`,restoreBackup:`Відновити резервну копію`,noBackupsYet:`Резервних копій ще немає`},emailSettings:{placeholders:{fromName:`BamBuddy`}},tagManagement:{searchTags:`Пошук тегів...`,renameTag:`Перейменувати тег`,deleteTag:`Видалити тег`},notificationTemplates:{placeholders:{title:`Назва сповіщення...`,body:`Текст сповіщення...`}},batchTag:{placeholders:{newTag:`Введіть новий тег...`}},photoGallery:{deletePhoto:`Видалити фото`},filamentHoverCard:{copySpoolUuid:`Копіювати UUID буфера`},kProfilesView:{hasNote:`Має примітку`,copyProfile:`Копіювати профіль`},layout:{openMenu:`Відкрити меню`,noPermissionSystemInfo:`У вас немає дозволу на перегляд системної інформації`},dashboard:{dragToReorder:`Перетягніть, щоб змінити порядок`,hideWidget:`Сховати віджет`},notificationProviderCard:{deleteNotificationProvider:`Видалити постачальника сповіщень`},fileManagerModal:{closeFileManager:`Закрити файловий менеджер`,sortFiles:`Сортування файлів`,goToParentFolder:`Перейти до батьківської папки`,threeView:`3D перегляд`},embeddedCameraViewer:{refreshStream:`Оновити потік`,close:`Закрити`,zoomOut:`Зменшити масштаб`,resetZoom:`Скинути масштаб`,zoomIn:`Збільшити`,dragToResize:`Перетягніть, щоб змінити розмір`},timelapseViewer:{skipBack5s:`Перейти на 5 секунд назад`,skipForward5s:`Перейти вперед на 5 с`},notificationProviders:{descriptions:{email:`Сповіщення електронною поштою SMTP`,telegram:`Сповіщення через бота Telegram.`,discord:`Надіслати на канал Discord через webhook`,ntfy:`Безкоштовні push-сповіщення з можливістю самостійного розгортання`,pushover:`Прості та надійні push-повідомлення`,callmebot:`Безкоштовні сповіщення WhatsApp через CallMeBot`,webhook:`Універсальний HTTP POST-запит на будь-який URL`}},logViewer:{searchPlaceholder:`Шукати повідомлення або назву реєстратора...`,noLogEntries:`Записів у журналі не знайдено`},switchbarPopover:{noSwitchesInSwitchbar:`На панелі перемикачів немає`},projectPageModal:{placeholders:{title:`Назва`,designer:`Дизайнер`,license:`Ліцензія`,description:`Введіть опис...`,profileTitle:`Назва профілю`,profileDescription:`Опис профілю...`}},spoolmanSettings:{},time:{unknown:`-`,waiting:`Очікування`,justNow:`Просто зараз`,now:`Зараз`,minsAgo:`{{count}}хв тому`,inMins:`в {{count}}м`,hoursAgo:`{{count}} год тому`,inHours:`в {{count}} год`,daysAgo:`{{count}} дн. тому`,inDays:`в {{count}}d`},spoolbuddy:{nav:{dashboard:`Приладова панель`,ams:`AMS`,inventory:`Інвентар`,writeTag:`Записати`,settings:`Налаштування`},status:{nfcReady:`NFC Готовий`,nfcOff:`NFC Вимк`,offline:`Не в мережі`,online:`Онлайн`,noPrinters:`Немає принтерів`,deviceOffline:`Пристрій не в мережі`,waitingConnection:`Очікування підключення пристрою...`,systemReady:`Система готова`,status:`Статус`},dashboard:{readyToScan:`Готовий до сканування`,idleMessage:`Покладіть котушку на ваги, щоб ідентифікувати її`,nfcHint:`Тег NFC буде прочитано автоматично`,device:`пристрій`,syncWeight:`Синхронізувати вагу`,weightSynced:`Синхронізовано!`,unknownTag:`Невідомий тег`,newTag:`Виявлено новий тег`,onScale:`на вагах`,linkSpool:`Прив’язати до котушки`,linkTagTitle:`Прив’язати тег до котушки`,linkTag:`Прив’язати тег`,selectSpool:`Виберіть котушку, до якої потрібно прив’язати цей тег:`,noUntagged:`Не знайдено котушок без тегів`,tagDetected:`Тег виявлено`,noTag:`Без тегу`,tagId:`Тег`,grossWeight:`Вага брутто`,spoolSize:`Розмір котушки`,close:`Закрити`,currentSpool:`Поточна котушка`,plateReady:`Пластина готова: {{name}}`,plateReadyLabel:`Пластини готові до очищення`,plateClearAction:`Очистити`,plateClearedToast:`Друкарська пластина позначена як очищена`,plateClearFailed:`Не вдалося позначити пластину як очищену`},modal:{spoolDetected:`Виявлено котушку`,assignToAms:`Призначити AMS`,syncWeight:`Синхронізувати вагу`,weightSynced:`Синхронізовано!`,syncing:`Синхронізація...`,newTagDetected:`Виявлено новий тег`,addToInventory:`Додати в інвентар`,assignToAmsTitle:`Призначити AMS`,selectSlot:`Вибрати слот`,assign:`Призначити`,assigning:`Призначення...`,assignSuccess:`Призначено!`,assignPendingInsert:`Призначений. Слот буде налаштовано, коли ви вставите котушку.`,assignError:`Не вдалося призначити котушку. Спробуйте ще раз.`,noPrinterSelected:`Виберіть принтер...`,noAmsDetected:`AMS не виявлено на цьому принтері`,slot:`Слот`},weight:{noReading:`Немає показань`,stable:`Стабільно`,measuring:`Вимірювання…`,tare:`Тарувати`,calibrate:`Відкалібрувати`},spool:{remaining:`Залишилося`,material:`матеріал`,brand:`Бренд`,color:`Колір`,coreWeight:`Сердечник`,labelWeight:`Етикетка`,scaleWeight:`Показання ваг`,netWeight:`Нетто`,lastUsed:`Останнє використання`},ams:{noData:`AMS не виявлено`,connectAms:`Підключіть AMS, щоб побачити слоти для філаментів`,noPrinter:`Принтер не вибрано`,selectPrinter:`Виберіть принтер у верхній панелі`,printerDisconnected:`Принтер відключений`,humidity:`Вологість`,level:`Рівень`,active:`Активний`,slot:`Слот`,empty:`Порожній`},inventory:{search:`Пошук котушок...`,empty:`Немає котушок в інвентарі`,noResults:`Немає відповідних котушок`,spools:`котушки`,addSpool:`Додати котушку`},settings:{tabDevice:`пристрій`,tabDisplay:`Дисплей`,tabScale:`Ваги`,tabUpdates:`Оновлення`,nfcReader:`NFC Читач`,type:`Тип`,connection:`Підключення`,notConnected:`Н/Д`,deviceInfo:`Інформація про пристрій`,hostname:`Хост`,uptime:`Час роботи`,systemConfig:`Серверна частина та автентифікація`,backendUrl:`Bambuddy Сервер URL`,apiToken:`API Токен`,apiTokenPlaceholder:`Введіть API-токен.`,saveConfig:`Зберегти конфігурацію`,systemQueued:`Конфігурацію в черзі.`,nfcDiagnostic:`NFC Діагностика`,scaleDiagnostic:`Діагностика ваг`,readTagDiagnostic:`Прочитайте діагностику тегів`,testNfc:`Тестовий читач`,testScale:`Перевірити точність`,testReadTag:`Прочитати тег`,systemFieldsRequired:`Потрібен бекенд URL.`,brightness:`Яскравість`,saved:`Збережено`,noBacklight:`Підсвічування DSI не виявлено. Для керування яскравістю потрібен дисплей DSI.`,screenBlank:`Час очікування порожнього екрана`,screenBlankDesc:`Екран вимикається після бездіяльності. Торкніться, щоб розбудити.`,displayNote:`Яскравість використовується як програмний фільтр.`,scaleCalibration:`Калібрування ваг`,currentWeight:`Поточна вага`,tareOffset:`Зміщення тари`,calFactor:`Коефіцієнт калібрування`,knownWeight:`Відома маса`,calStep1:`Приберіть усі предмети з ваг і натисніть «Установити нуль».`,calStep2:`Покладіть на ваги предмет відомої маси.`,setZero:`Встановити нуль`,calibrateNow:`Відкалібрувати`,calibrated:`Відкалібровано`,tareSet:`Надіслано команду тарування. Очікування пристрою...`,tareComplete:`Тарування завершено!`,tareTimedOut:`Час очікування тарування минув — чи запущено демон SpoolBuddy?`,tareFailed:`Не вдалося надіслати команду тарування`,zeroSet:`Нульову точку встановлено. Покладіть на ваги предмет відомої маси.`,calibrationDone:`Калібрування завершено!`,calibrationFailed:`Помилка калібрування`,lastCalibrated:`Останнє калібрування`,stable:`Стабільно`,settling:`Стабілізація…`,firmware:`Прошивка`,scale:`Ваги`,noDevice:`Пристрій SpoolBuddy не знайдено`,daemonVersion:`Демон версія`,currentVersion:`поточний`,versionPending:`В очікуванні демона...`,checking:`Перевірка...`,checkUpdates:`Перевірте наявність оновлень`,updateAvailable:`Доступне оновлення`,updateInstructions:`Оновлення через SSH: запустіть сценарій встановлення SpoolBuddy для оновлення.`,upToDate:`В актуальному стані`,includeBeta:`Включати бета-версії`},writeTag:{tabExisting:`Наявна котушка`,tabNew:`Нова котушка`,tabReplace:`Замінити тег`,searchPlaceholder:`Пошук за матеріалом, кольором, брендом...`,noUntaggedSpools:`Немає котушок без тегів`,noTaggedSpools:`Немає котушок із тегами`,selectSpool:`Виберіть котушку, а потім помістіть порожній NTAG на зчитувач`,placeTag:`Помістіть NTAG на зчитувач`,tagReady:`Тег виявлено — готовий до запису`,writeTag:`Записати тег`,replaceTag:`Замінити тег`,writing:`Записування тегу…`,waiting:`Очікування SpoolBuddy...`,writeSuccess:`Тег успішно записано!`,writeFailed:`Помилка запису`,queueFailed:`Не вдалося поставити команду запису в чергу`,tryAgain:`Спробуйте знову`,cancel:`Скасувати`,replaceWarning:`Старий тег буде від’єднано від котушки, а замість нього буде прив’язано новий.`,deviceOffline:`SpoolBuddy не в мережі`,material:`матеріал`,colorName:`Назва кольору`,color:`Колір`,brand:`Бренд`,weight:`Вага (г)`,createSpool:`Створити котушку`,creating:`Створення...`,spoolCreated:`Котушку створено! Можна записувати тег.`,createFailed:`Не вдалося створити котушку`,incompleteDataWarning:`Тег записано з неповними даними Spoolman.`},quickMenu:{printerPower:`Живлення принтера`,systemControls:`система`,restartDaemon:`Перезапустити демон`,restartBrowser:`Перезапустити браузер`,reboot:`Перезавантажити`,shutdown:`Вимкнути`,swipeToClose:`Проведіть пальцем вниз, щоб закрити`,confirmTitle:`Підтвердити`,confirmShutdown:`Вимкнути SpoolBuddy? Щоб увімкнути його знову, знадобиться фізичний доступ до пристрою.`,confirmReboot:`Перезавантажити SpoolBuddy?`,confirmRestartDaemon:`Перезапустити демон SpoolBuddy? NFC і ваги будуть тимчасово недоступні.`,confirmRestartBrowser:`Перезапустити веб-переглядач кіоску? Дисплей на короткий час погасне.`,confirm:`Підтвердити`,confirmPlugOn:`Увімкнути {{name}}?`,confirmPlugOff:`Вимкнути {{name}}?`,turnOn:`Увімкнути`,turnOff:`Вимкнути`}},diagnostic:{modalTitle:`Діагностика підключення — {{name}}`,running:`Виконується діагностика...`,runningElapsed:`Виконується діагностика… ({{elapsed}} с)`,waitingForReportHint:`Очікуємо, доки принтер опублікує звіт про стан — це може тривати до {{max}} с.`,runFailed:`Не вдалося запустити діагностику: {{error}}`,retry:`Запустити знову`,runButton:`Запустити діагностику`,sectionTitle:`Діагностика підключення`,sectionDescription:`Перевірте, чому принтер не підключається або не друкує — доступність порту, режим розробника LAN, режим мережі Docker та облікові дані.`,noPrinters:`Принтери не налаштовані.`,overall:{ok:`Проблем не виявлено — з’єднання принтера справне.`,warnings:`Принтер має працювати, але деякі параметри потребують уваги.`,problems:`Знайдено проблеми, які пояснюють, чому принтер не підключається або не друкує.`},check:{port_mqtt:{title:`Порт керування (MQTT 8883)`,pass:`Доступний — принтер приймає керівні з’єднання.`,fail:`Порт 8883 недоступний. Принтер може бути вимкнений, мати іншу IP-адресу або бути заблокований брандмауером. Перевірте IP-адресу принтера й переконайтеся, що порт 8883 не заблоковано.`},port_ftps:{title:`Порт передачі файлів (FTPS 990)`,pass:`Доступний — файли друку можна буде надсилати.`,warn:`Порт 990 недоступний. Моніторинг може й надалі працювати, але надсилання завдань друку на принтер завершуватиметься помилкою. Переконайтеся, що порт 990 не заблоковано.`,warn_no_tls:`Порт 990 відкритий, але файлова служба принтера не завершує рукостискання TLS. Файли друку, обкладинки та таймлапси отримати неможливо, тому архіви залишаються порожніми. Перезавантажте принтер — розблокування порту не допоможе.`},external_storage:{title:`Зберігання надісланих файлів у зовнішньому сховищі (крок установлення 4)`,pass:`Принтер повідомляє, що цю опцію ввімкнено — надіслані файли зберігатимуться на картці SD, а архіви матимуть мініатюри та метадані слайсера.`,fail:`Принтер повідомляє, що цей параметр вимкнено. Увімкніть «Зберігати надіслані файли в зовнішньому сховищі». У новіших версіях прошивки (P2S 01.02 / Bambu Studio 2.6+) перемикач розташований у налаштуваннях друку на принтері; у старіших — на вкладці «Пристрій» у Bambu Studio або OrcaSlicer. Без цього параметра в кожному архівованому друці будуть відсутні мініатюра та метадані слайсера.`,skip:`Не перевірено — потрібне активне MQTT-з’єднання. У старіших слайсерах, де цей параметр існує лише в самому слайсері, принтер його не повідомляє. Тому перевірка може бути успішною навіть за вимкненого параметра — перевірте крок установлення 4 вручну.`,skip_unsupported_model:`Ця модель має слот для SD-картки, але не дає змоги ввімкнути цей параметр: поточна прошивка принтерів серії P1 не показує перемикач у Bambu Studio, а сам принтер не має екрана. Виправляти нічого не потрібно; доки Bambu Lab не додасть підтримку в прошивці, в архівованих друках можуть бути відсутні мініатюри та метадані слайсера.`},port_rtsps:{title:`Порт камери ({{protocol}} {{port}})`,pass:`Доступно — потік камери працюватиме.`,warn:`Порт {{port}} недоступний. Перегляд з камери в реальному часі не працюватиме. Це не впливає на друк.`},network_mode:{title:`Мережевий режим Docker`,pass:`Працює в режимі хост-мережі.`,warn:`Bambuddy працює в мостовому мережевому режимі Docker. Для виявлення принтерів і роботи віртуального принтера потрібен режим мережі хоста — повторно створіть контейнер із параметром "network_mode: host".`,skip:`Не працює в Docker — не застосовується.`},subnet:{title:`Мережева підмережа`,pass:`Принтер і Bambuddy знаходяться в одній підмережі.`,warn:`Принтер ({{printer_ip}}) і Bambuddy ({{host_ip}}) перебувають у різних підмережах. Вони можуть не мати зв’язку між собою, якщо маршрутизацію між підмережами не налаштовано.`,skip:`Не вдалося визначити підмережу — пропущено.`},mqtt_auth:{title:`Облікові дані принтера`,pass:`Принтер прийняв підключення.`,fail:`Принтер доступний, але Bambuddy до нього не під’єднано. Найімовірніше, указано неправильний код доступу або серійний номер — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника, тож знову скопіюйте його з екрана принтера. Так само виглядає принтер, який перезавантажується або вже вичерпав ліміт одночасних з’єднань.`,fail_auth_rejected:`Принтер відхилив облікові дані Bambuddy. Код доступу або серійний номер неправильний — код доступу змінюється після кожного перемикання режиму «Лише LAN» або режиму розробника. Знову скопіюйте його з екрана принтера та збережіть у налаштуваннях принтера.`,skip:`Не позначено — принтер недоступний.`},developer_mode:{title:`Режим розробника LAN`,pass:`Режим розробника ввімкнено.`,fail:`Режим розробника на принтері ВИМКНЕНО. Увімкніть його в налаштуваннях LAN принтера й підтвердьте вибір кнопкою OK. Без цього друк не запускатиметься.`,skip:`Неможливо перевірити — потрібне живе підключення до принтера.`},printer_publishing:{title:`Принтер публікує дані про стан`,pass:`Принтер публікує оновлення статусу — AMS, філаменти та K-профілі правильно відображатимуться на слайсері.`,fail:`MQTT-брокер прийняв з’єднання, але принтер не опублікував жодного звіту про стан. Майже завжди причиною є неправильний серійний номер або неправильний регістр символів: тема device//report чутлива до регістру. Звірте серійний номер у налаштуваннях Bambuddy із номером на екрані принтера.`,skip:`Неможливо перевірити — потрібне живе підключення до принтера.`}}},systemHealth:{sectionTitle:`Стан системи`,sectionDescription:`Сканує останні журнали на наявність відомих проблем, які зазвичай можна вирішити самостійно, перш ніж вони перетворяться на запит у службу підтримки.`,rescan:`Пересканувати`,clean:`В останніх записах журналу {{times}} не знайдено відомих проблем.`,logUnavailable:`Журналування файлів вимкнено, тому журнали неможливо сканувати. Увімкніть журналювання файлів, щоб використовувати цю перевірку.`,learnMore:`Як виправити`,fixLabel:`Виправити:`,occurrences:`Виявлено {{times}} разів — востаннє о {{lastSeen}}`,category:{layer8:`Ви можете це виправити`,environment:`Середовище виконання`,bug:`Будь ласка, повідомте про це`},signature:{"ftp-auth-rejected":{name:`Принтер відхилив код доступу`,cause:`Принтер відмовився від входу для передавання файлів. Код доступу неправильний або він змінився після перемикання режиму розробника.`,fix:`Повторно скопіюйте код доступу з екрана принтера (налаштування LAN) і оновіть його в налаштуваннях принтера в Bambuddy.`},"ftp-connection-timeout":{name:`Час очікування з’єднання для передачі файлів минув`,cause:`Bambuddy не вдалося отримати доступ до порту передачі файлів принтера (FTPS 990). Порт заблоковано, або принтер вимкнено або знаходиться в іншій підмережі.`,fix:`Переконайтеся, що ніщо не блокує порт 990 між Bambuddy і принтером, і що обидва знаходяться в одній мережі.`},"ftp-ssl-error":{name:`Помилка захищеного з’єднання для передавання файлів`,cause:`Файлова служба принтера відповіла на порту 990 без TLS. Її файловий сервер завис — це несправність самого принтера, а не брандмауера чи прошивки.`,fix:`Перезапустіть принтер. До того часу файли друку, обкладинки та таймлапси отримати неможливо, але на сам друк це не впливає.`},"bambu-cloud-captcha":{name:`Bambu Cloud вимагає пройти CAPTCHA`,cause:`Захист Bambu від зловживань перевіряє цю мережу, тож завершити вхід у Bambu Cloud неможливо. Це пов'язано з публічною IP-адресою, а не з вашим обліковим записом чи цією інсталяцією.`,fix:`Зачекайте: зазвичай перевірка зникає за кілька годин, а повторні спроби входу її подовжують. Тим часом увійдіть за допомогою токена доступу із сеансу браузера.`},"mqtt-connection-flapping":{name:`Підключення до принтера постійно падає`,cause:`Керуюче з’єднання (MQTT 8883) неодноразово роз’єднується та знову під’єднується — зазвичай через слабкий мережевий шлях або частково заблокований порт.`,fix:`Перевірте сигнал Wi-Fi на принтері, віддайте перевагу дротовому підключенню та переконайтеся, що порт 8883 надійно доступний.`},"camera-connection-refused":{name:`Потік з камери недоступний`,cause:`Не вдається отримати доступ до камери в прямому ефірі через порт RTSPS 322. Порт заблоковано, або на принтері вимкнено камеру або LAN liveview.`,fix:`Увімкніть камеру та LAN liveview на принтері та переконайтеся, що порт 322 не заблоковано. Це не впливає на друк.`},"database-locked":{name:`Конфлікт запису до бази даних`,cause:`Під навантаженням SQLite повертає помилки "database is locked". Це часто трапляється, коли одночасно працюють кілька принтерів.`,fix:`Переключіть Bambuddy на зовнішню базу даних PostgreSQL. Див. посібник PostgreSQL у документації.`}}},vpDiagnostic:{title:`Перевірка налаштувань — {{name}}`,runButton:`Запустити перевірку налаштувань`,running:`Виконується перевірка налаштувань...`,runFailed:`Не вдалося виконати перевірку налаштувань: {{error}}`,retry:`Запустити знову`,overall:{ok:`Усі перевірки пройшли — цей віртуальний принтер налаштовано правильно.`,warnings:`Віртуальний принтер має працювати, але деякі параметри потребують уваги.`,problems:`Знайдено проблеми, які пояснюють, чому слайсер не бачить або не використовує цей віртуальний принтер.`},check:{enabled:{title:`Віртуальний принтер увімкнено`,fail:`Цей віртуальний принтер вимкнено. Увімкніть його, щоб зробити його видимим.`},running:{title:`Служби запущені`,fail:`Віртуальний принтер увімкнено, але його служби не працюють. Перевірте журнал Bambuddy — зазвичай запуску заважає конфлікт IP-адреси прив’язки або помилка дозволів.`},bind_interface:{title:`Прив’язка мережевого інтерфейсу`,fail:`Інтерфейс прив’язки не встановлено або більше не існує на цьому хості. Виберіть поточний інтерфейс у спадному списку «Інтерфейс прив’язки».`},access_code:{title:`Код доступу встановлено`,fail:`Код доступу не встановлено. У слайсері потрібно вказати той самий 8-символьний код доступу, який задано тут.`},target_printer:{title:`Цільовий принтер`,fail:`Цільовий принтер не вибрано. У режимі проксі потрібен фізичний принтер, якому пересилатимуться завдання.`,warn:`Цільовий принтер наразі не в мережі — проксі-сервер відновиться після повторного підключення.`},port_ftps:{title:`Служба вивантаження файлів (порт {{port}})`,fail:`На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може вивантажити файли. Зазвичай причиною є конфлікт порту на цьому інтерфейсі.`},port_mqtt:{title:`Служба керування (порт {{port}})`,fail:`На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може підключитися або показати стан.`},port_bind:{title:`Служба виявлення (порт {{port}})`,fail:`На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може завершити процедуру виявлення.`},privileged_ports:{title:`Прив’язка до привілейованих портів`,fail:`Порт {{port}} нижче 1024, і цій службі не дозволено його займати — тому вище ніщо не слухає. Додайте AmbientCapabilities=CAP_NET_BIND_SERVICE до /etc/systemd/system/bambuddy.service і перезапустіть або виконайте "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))". У Docker додайте cap_add: [NET_BIND_SERVICE].`},certificate:{title:`Сертифікат TLS`,pass:`Сертифікат готовий. Переконайтеся, що наведений вище сертифікат центру сертифікації Bambuddy імпортовано до сховища довірених сертифікатів слайсера.`,fail:`Сертифікат TLS для цього віртуального принтера відсутній. Перевірте, чи доступний для запису каталог даних Bambuddy.`}}},bugReport:{title:`Повідомити про помилку`,description:`опис`,descriptionPlaceholder:`Що пішло не так? Будь ласка, опишіть проблему...`,email:`Електронна адреса (необов’язково)`,emailPlaceholder:`your@email.com`,emailPrivacy:`Якщо надано, вашу електронну адресу буде включено до згорнутого розділу проблеми GitHub, щоб спеціаліст із супроводу міг відслідковувати.`,screenshot:`Скріншот`,uploadOrPaste:`Вивантажити, вставити або перетягнути зображення`,dataCollectedSummary:`Які дані містяться у звіті?`,dataIncluded:`У комплекті:`,dataIncludedList:`Версія додатка, OS, архітектура, версія Python, статистика бази даних (лише для підрахунку), моделі принтерів, кількість сопел, версії прошивки, статус підключення, статус інтеграції (Spoolman, MQTT, HA), неконфіденційні налаштування, кількість мережевих інтерфейсів, Docker деталі, версії залежностей.`,dataNeverIncluded:`Ніколи не додаються:`,dataNeverIncludedList:`Назви принтерів, серійні номери, коди доступу, паролі, IP-адреси, адреси електронної пошти, ключі API, токени, URL вебхуків, імена хостів або користувачів.`,submit:`Надіслати`,startLogging:`Розпочати журнал налагодження`,stepEnableLogging:`Журнал налагодження ввімкнено`,stepReproduce:`Відтворіть проблему зараз`,stepStopLogging:`Зупинити та подати звіт`,stopAndSubmit:`Зупинити та надіслати`,maxDuration:`Автоматична зупинка через {{minutes}} хв`,stoppingLogs:`Збір журналів і надсилання...`,submitting:`Надсилання звіту про помилку...`,submittingStepConnection:`Запуск перевірки підключення принтера`,submittingStepVirtualPrinters:`Виконання перевірок налаштування віртуального принтера`,submittingStepLogScan:`Сканування останніх журналів на наявність відомих проблем`,submittingStepSubmit:`Надсилання звіту GitHub`,submitSuccess:`Звіт про помилку успішно надіслано!`,submitFailed:`Не вдалося надіслати звіт про помилку`,diagnosticChecking:`Перевірка підключень принтера...`,diagnosticHealthy:`Перевірку підключення пройдено — проблем із принтерами не виявлено.`,diagnosticSummary:`Проблеми з підключенням мають {{problems}} із {{total}} принтерів`,diagnosticIntro:`Один або кілька принтерів мають проблему з підключенням, яка може спричиняти описану несправність. Розгорніть запис принтера нижче, щоб переглянути спосіб виправлення. Усунення цієї проблеми може допомогти без створення звіту про помилку, але за потреби звіт усе одно можна надіслати нижче.`,logHealthSummary:`У ваших журналах знайдено відомі проблеми`,logHealthIntro:`Останні журнали відповідають відомим проблемам. Перегляньте наведені нижче виправлення — їх вирішення може вирішити вашу проблему без повідомлення про помилку. Ви все ще можете надіслати звіт нижче.`,thankYou:`дякую!`,submitted:`Ваш звіт про помилку надіслано.`,viewIssue:`Переглянути випуск`,unexpectedError:`Сталася неочікувана помилка`},failureDetection:{title:`Виявлення помилок за допомогою ШІ`,description:`Відстежуйте друк за допомогою самостійно розгорнутого Obico ML API та автоматично реагуйте на виявлені помилки.`,mlUrl:`URL Obico ML API`,mlUrlHint:`База URL вашого контейнера ml_api, розміщеного на власному хості Obico (наприклад, http://192.168.1.10:3333).`,mlToken:`Токен ML API (необов'язково)`,mlTokenPlaceholder:`Залиште порожнім, якщо сервер працює без ML_API_TOKEN`,mlTokenHint:`Має збігатися зі змінною середовища ML_API_TOKEN вашого контейнера Obico ml_api. Залиште порожнім, якщо контейнер працює без токена.`,test:`Тест`,testSuccess:`ML API доступний і працює належним чином.`,testSuccessTokenUnknown:`ML API доступний і працює належним чином. Не вдалося перевірити токен.`,testFailed:`Не вдалося підключитися до ML API.`,sensitivity:`Чутливість`,sensitivityLow:`Низький (менше помилкових спрацьовувань)`,sensitivityMedium:`Середній (збалансований)`,sensitivityHigh:`Високий (виявляти рано, більше помилкових спрацьовувань)`,sensitivityHint:`Налаштовує пороги впевненості, за яких формуються попередження та фіксуються помилки.`,action:`Дія в разі виявлення помилки`,actionNotify:`Лише сповіщати`,actionPause:`Призупинити друк`,actionPauseOff:`Призупинити друк і вимкнути живлення`,pollInterval:`Інтервал опитування (секунди)`,pollIntervalHint:`Як часто перевіряти кожен принтер під час друку. Мінімум 5 с, максимум 120 с.`,externalUrlMissing:`Зовнішній URL не встановлено.`,externalUrlHint:`ML API отримує знімок із камери за URL. Укажіть зовнішній URL у загальних налаштуваннях, щоб контейнер ML API міг підключитися до Bambuddy.`,perPrinterTitle:`Принтери під спостереженням`,perPrinterHint:`Виберіть, які принтери перевіряє служба виявлення.`,monitorAll:`Відстежувати всі підключені принтери`,statusTitle:`Статус`,serviceRunning:`Служба працює`,thresholds:`Нижній / верхній поріг`,activePrinters:`Активні завдання друку`,noActivePrints:`Зараз немає роздруківок.`,historyTitle:`Останні виявлення`,noHistory:`Виявлень ще немає.`},makerworld:{title:`MakerWorld`,description:`Вставте модель MakerWorld URL, щоб імпортувати та роздрукувати її безпосередньо з Bambuddy — не виходячи з програми Bambu Handy.`,pasteUrlHeader:`Імпортувати з MakerWorld`,pasteUrlPlaceholder:`https://makerworld.com/en/models/… або вставте будь-яке посилання MakerWorld.`,resolveButton:`Розв’язати`,signInRequiredTitle:`Для завантаження потрібно увійти в Bambu Cloud`,signInRequiredBody:`Ви можете переглядати деталі моделі анонімно, але для MakerWorld потрібен обліковий запис Bambu Cloud для завантаження файлів 3MF.`,signInExpiredTitle:`Термін входу в Bambu Cloud минув`,signInExpiredBody:`Ви все ще ввійшли в Bambuddy, але Bambu Lab більше не приймає збережений токен, тому завантаження не вдасться. Знову ввійдіть у Bambu Cloud.`,openCloudSettings:`Відкрити налаштування Cloud`,untitledModel:`Модель без назви`,byCreator:`від {{name}}`,downloadsCount:`{{count}} завантажень`,licensePrefix:`Ліцензія`,alreadyImported:`Вже в бібліотеці`,openOnMakerworld:`Відкрити MakerWorld`,alreadyInLibrary:`Ця модель уже є у вашій бібліотеці — знайдіть її в Менеджері файлів → MakerWorld`,importSuccess:`Імпортовано {{filename}} — збережено в менеджері файлів → MakerWorld`,platesHeader:`Пластини ({{count}})`,plateDefaultName:`Пластина {{n}}`,materialCount:`Філаментів: {{count}}`,amsRequired:`Потрібен AMS`,slicedFor:`Нарізано для {{printer}}`,alsoCompatible:`Також позначено як сумісний: {{printers}}`,importToLibrary:`Зберегти`,sliceIn:`Зберегти й нарізати в {{slicer}}`,disclaimer:`Інтеграція MakerWorld використовує кінцеві точки API, задокументовані спільнотою. Bambuddy не є філією та не підтримується MakerWorld або Bambu Lab.`,lastImportSuccess:`Імпортовано до вашої бібліотеки`,lastImportAlreadyInLibrary:`Уже у вашій бібліотеці`,viewInLibrary:`Переглянути в менеджері файлів`,openInBambuStudio:`Відкрити в Bambu Studio`,openInOrcaSlicer:`Відкрити в OrcaSlicer`,importTo:`Імпорт до файлового менеджера`,recentImportsHeader:`Недавній імпорт`,phaseResolving:`Розв’язування`,phaseDownloading:`Завантаження`,folderAuto:`MakerWorld (за замовчуванням)`,importAll:`Імпортувати все`,importAllProgress:`Імпорт {{current}}/{{total}}`,openGallery:`Відкрити галерею зображень`,galleryPrev:`Попереднє зображення`,galleryNext:`Наступне зображення`,deleteImport:`Видалити з бібліотеки`,importDeleting:`Видалення…`,importDeleted:`Видалено з бібліотеки`,confirmDelete:`Видалити {{filename}} з бібліотеки? Локальний файл буде видалено, але цю друкарську пластину можна буде повторно імпортувати з MakerWorld.`,errors:{resolveFailed:`Не вдалося вирішити MakerWorld URL.`,downloadFailed:`Помилка завантаження. Спробуйте ще раз.`,deleteFailed:`Не вдалося видалити файл із бібліотеки.`}},gcodeViewer:{filamentSlot:`Філамент {{n}}`,loading:`Читання траєкторії…`,loadFailed:`Не вдалося завантажити G-код цього файлу.`,showTravel:`Холості переміщення`,topLayer:`Верхній шар`,bottomLayer:`Нижній шар`,noSource:`Файл для перегляду не вказано.`,view:{filament:`Філамент`,feature:`Тип`,height:`Висота`,width:`Ширина`},feature:{wall:`Стінки`,sparseInfill:`Розріджене заповнення`,solidInfill:`Суцільне заповнення`,bridge:`Міст / навис`,support:`Підтримка`,skirt:`Спідниця / облямівка`,gapFill:`Заповнення проміжків`,ironing:`Розгладжування`,primeTower:`Вежа очищення`},back:`Назад`,backToArchives:`Назад до друку архівів`,backToFiles:`Назад до файлового менеджера`},libraryTrash:{title:`Кошик`,headerButton:`Кошик`,headerTooltip:`Переглянути файли, переміщені в кошик`,backToFiles:`Назад до файлового менеджера`,subtitleAdmin:`Видалені файли залишаються тут протягом {{days}} днів, а потім автоматично видаляються. У цьому режимі перегляду відображаються файли, видалені в кошик для всіх користувачів.`,subtitleUser:`Видалені файли залишаються тут протягом {{days}} днів, а потім автоматично видаляються.`,loading:`Завантаження кошика…`,loadError:`Не вдалося завантажити кошик.`,empty:`Кошик порожній.`,summary:`Файлів: {{count}} · {{size}}`,emptyTrash:`Очистити кошик`,restore:`Відновити`,purgeNow:`Видалити зараз`,autoPurgeIn:`Автоматичне видалення через {{when}}`,days:`днів`,retentionLabel:`Автоматичне видалення після`,selectAll:`Вибрати все`,selectOne:`Вибрати {{filename}}`,selectionCount:`Вибрано {{count}}.`,bulkRestore:`Відновити вибране`,bulkPurge:`Видалити вибране`,col:{filename:`Файл`,folder:`Папка`,size:`Розмір`,deleted:`Переміщено в кошик`,autoPurge:`Автоматичне видалення`,owner:`Власник`,actions:`Дії`},confirm:{purgeTitle:`Видалити остаточно?`,purgeBody:`{{filename}} буде видалено з диска та не підлягає відновленню.`,emptyTitle:`Очистити кошик?`,emptyBody:`З диска буде остаточно видалено всі файли ({{count}}). Цю дію неможливо скасувати.`,bulkPurgeTitle:`Видалити вибрані файли назавжди?`,bulkPurgeBody:`Вибрані файли ({{count}}) буде видалено з диска без можливості відновлення.`,cta:`Видалити остаточно`},toast:{restored:`Файл відновлено.`,restoreFailed:`Не вдалося відновити файл.`,purged:`Файл видалено остаточно.`,purgeFailed:`Не вдалося видалити файл.`,emptied:`Файлів остаточно видалено з кошика: {{count}}.`,emptyFailed:`Не вдалося очистити кошик.`,retentionSaved:`Для автоматичного видалення встановлено {{days}} днів.`,retentionFailed:`Не вдалося зберегти налаштування збереження.`,bulkRestored:`Відновлено файлів: {{count}}`,bulkPurged:`Файлів остаточно видалено: {{count}}.`}},libraryPurge:{title:`Очистити старі файли`,headerButton:`Очищення старого`,headerTooltip:`Масове переміщення старих файлів у кошик`,description:`Однією дією перемістіть старі файли з бібліотеки до кошика. Вік файлів з історією друку визначається за датою останнього друку, а файлів, які ще не друкувалися, — за датою вивантаження.`,ageLabel:`Перемістити файли, старші за`,days:`днів`,includeNeverPrinted:`Включати файли, які ніколи не друкувались`,effectsTitle:`Що відбудеться після натискання «Очистити»`,effect1:`Відповідні файли переміщуються в кошик — вони ще не видалені з диска.`,effect2:`Ви можете будь-коли відновити їх із кошика, доки не закінчиться період зберігання.`,effect3:`Після завершення строку зберігання служба очищення кошика остаточно видалить файли з диска.`,effect4:`Файли у зовнішніх (зв’язаних) папках пропускаються — Bambuddy ніколи не видаляє байти, які йому не належать.`,previewLoading:`Перевірка кількості файлів…`,previewFailed:`Не вдалося попередньо переглянути очищення.`,previewSummary:`Файли {{count}} · {{size}} буде переміщено до кошика`,andMore:`…і {{count}} більше`,warning:`Файли в кошику й надалі займають місце, доки не завершиться строк зберігання. Щоб звільнити місце на диску негайно, після цього очистьте кошик.`,confirmCta:`Перемістити до кошика ({{count}})`,purging:`Переміщення в кошик…`,toast:{success:`Переміщено до кошика файлів: {{count}}`,failed:`Не вдалося очистити файли.`}},libraryAutoPurge:{enableLabel:`Автоматичне очищення старих файлів`,enableDescription:`Запускає очищення адміністратора один раз на день. Файли спочатку потрапляють у кошик — вони не видаляються одразу.`,ageLabel:`Автоматичне очищення файлів, старших за`,ageDescription:`Мінімум 7 днів, максимум 10 років. Використовує те саме вікове правило, що й ручна кнопка очищення.`,days:`днів`,includeNeverPrinted:`Включати файли, які ніколи не друкувались`,saveFailed:`Не вдалося зберегти налаштування автоматичного очищення.`},archivePurge:{headerButton:`Очищення старого`,headerTooltip:`Масове видалення старих архівів`,title:`Очистити старі архіви`,description:`Очистити стару історію друку. Кожен архів старіє відповідно до останнього завершення друку — повторний друк архіву оновлює його вік, тому активна робота ніколи не очищається.`,ageLabel:`Видалити архіви, не надруковані в останньому`,days:`днів`,effectsTitle:`Що відбудеться після натискання «Очистити»`,effect1:`Кожен відповідний архів приховано зі списків, а його файли видалено з диска (3MF, ескіз, покадрова зйомка, вихідний 3MF, F3D файл дизайну, фотографії).`,effect2:`Запис архіву залишиться в базі даних, тому «Швидка статистика» й надалі враховуватиме філамент, час, вартість та енергію — як і за типового видалення окремого архіву.`,effect3:`Позначте нижче «Також видалити зі статистики», щоб вилучити внесок цих архівів зі «Швидкої статистики» — так само, як у відповідній опції видалення окремого архіву. Цю дію неможливо скасувати.`,effect4:`Повторний друк архіву оновлює його віковий годинник, тому архіви, якими ви все ще користуєтеся, залишаються в безпеці.`,purgeStatsLabel:`Також видалити зі статистики`,purgeStatsHint:`Вилучає внесок відповідних архівів зі «Швидкої статистики» — філамент, час, вартість та енергію. Якщо параметр вимкнено, статистичні дані зберігаються, а з диска видаляються лише файли.`,previewLoading:`Перевірка кількості архівів, що збігаються…`,previewFailed:`Не вдалося попередньо переглянути очищення.`,previewSummary:`Архіви {{count}} · {{size}} буде видалено`,andMore:`…і {{count}} більше`,warning:`Файли видаляються з диска і не можуть бути відновлені. Перш ніж продовжити, завантажте або додайте до вибраного все, що хочете зберегти.`,confirmCta:`Видалити архіви ({{count}})`,purging:`Видалення…`,toast:{success:`Видалено архівів: {{count}}`,failed:`Не вдалося очистити архіви.`}},archiveAutoPurge:{enableLabel:`Автоматичне очищення старих архівів`,enableDescription:`Один раз на день приховує архіви зі списків і видаляє їх файли з диска, якщо вони не були надруковані в межах порогу. Повторний друк архіву скидає годинник.`,ageLabel:`Автоматичне видалення архівів, не надрукованих в останньому`,ageDescription:`Мінімум 7 днів, максимум 10 років. На основі останнього завершення друку — повторний друк архіву оновлює його вік. Видаляє 3MF, мініатюру, покадрову зйомку, джерело 3MF, F3D і фотографії.`,days:`днів`,purgeStatsLabel:`Також видалити зі статистики`,purgeStatsDescription:`Якщо параметр увімкнено, щоденне очищення також вилучатиме внесок кожного видаленого архіву зі «Швидкої статистики» — філамент, час, вартість та енергію. Типово параметр вимкнено: статистичні дані зберігаються, а з диска видаляються лише файли.`,runNow:`Очистити архіви зараз`,saveFailed:`Не вдалося зберегти налаштування автоматичного очищення.`},cameraTokens:{scope:{camera_stream:`Потік камери`,camwall:`Панель камер`,overlay:`Потокове накладання`},title:`API-токени камери`,navTitle:`API-токени камери`,description:`Довгострокові токени для вбудовування потоку камери в Home Assistant, Frigate, кіоски або інші засоби, яким потрібен постійний URL. Під час створення виберіть область доступу; токен можна відкликати будь-коли.`,loading:`Завантаження…`,confirmRevoke:{title:`Відкликати цей токен?`,body:`Усі пристрої, що використовують «{{name}}», негайно втратять доступ. Цю дію неможливо скасувати.`,cancel:`Скасувати`,confirm:`Відкликати`},create:{scopeLabel:`Область застосування`,hintCameraStream:`Токен потоку камери дає змогу отримувати лише потоки з камер і знімки. Використовуйте його для Home Assistant, Frigate або іншого засобу, що вбудовує одну камеру.`,hintCamWall:`Токен Cam Wall відкриває /camwall на екрані без входу. Він може бачити назву та стан кожного принтера, а також потоки з камери. Він не може бачити імена файлів, адреси чи коди доступу.`,hintOverlay:`Токен потокового накладання відкриває /overlay/{printerId} на екрані без входу — для OBS або будь-якої прямої трансляції. Він дає змогу переглядати потік із камери одного принтера та стан друку в реальному часі, зокрема назву файлу, показану на екрані. Він не надає доступу до адрес чи кодів доступу.`,title:`Створити новий токен`,nameLabel:`Назва токена`,namePlaceholder:`напр. Home Assistant`,daysLabel:`Днів до закінчення терміну дії`,submit:`Створити`,hint:`Максимальний строк дії — 365 днів. Значення токена буде показано лише один раз під час створення — скопіюйте його зараз.`},created:{camWallUrlTitle:`Cam Wall URL для цього дисплея`,camWallUrlHint:`Відкрийте цей URL на потрібному екрані. Будь-хто, хто отримає URL, зможе переглядати панель камер, тому захищайте його як ключ доступу. Щоб негайно припинити доступ із цього екрана, відкличте токен.`,overlayUrlTitle:`Накладання URL для OBS`,overlayUrlHint:`Додайте цей URL в OBS як джерело браузера. Замініть число в /overlay/1 номером принтера з його URL на сторінці «Принтери». Будь-хто, хто отримає URL, зможе переглядати трансляцію, тому захищайте його як ключ доступу. Щоб негайно припинити доступ, відкличте токен.`,title:`Токен створено — скопіюйте його зараз`,warning:`Це єдиний раз, коли токен буде показано. Після закриття цього діалогового вікна переглянути його знову буде неможливо.`,copy:`Копіювати`,dismiss:`Я зберіг токен`},list:{scope:`Область застосування`,myTitle:`Мої токени`,allTitle:`Усі користувачі (перегляд адміністратора)`,empty:`Токенів ще немає.`,name:`Назва`,owner:`Власник`,prefix:`Префікс`,created:`Створено`,expires:`Термін дії закінчується`,lastUsed:`Останнє використання`,revoke:`Відкликати`,expired:`Термін дії минув`},toast:{created:`Токен створено`,createFailed:`Не вдалося створити токен`,revoked:`Токен відкликано`,revokeFailed:`Не вдалося відкликати токен`,loadFailed:`Не вдалося завантажити токени`,copied:`Скопійовано в буфер обміну`,copyFailed:`Не вдалося копіювати — виберіть і скопіюйте вручну`}},forecast:{title:`Прогноз`,noSpools:`Не знайдено активних котушок. Додайте котушки до свого інвентарю, щоб переглянути прогнозні дані.`,noUsageData:`Немає даних про використання — неможливо спроєктувати хронологію запасів.`,sku:`SKU`,material:`матеріал`,stock:`Запас`,dailyRate:`Добове споживання`,daysLeft:`Залишилося днів`,emptyBy:`Вичерпається до`,reorderBy:`Замовити до`,actions:`Дії`,trend:`Тренд`,estimated:`Приблизно`,noData:`Немає даних`,timeframe:`Період`,chartTitle:`Прогнозований запас — топ-5 матеріалів`,dashedLinesROP:`Пунктирні лінії — точки повторного замовлення`,stockLevel:`Рівень запасів`,reorderPoint:`Точка повторного замовлення`,safetyMargin:`Запас безпеки`,trendLegend:`Тенденція (на основі історії, рівень обслуговування 95%)`,estimatedLegend:`Розрахункова (дельта ваги)`,noDataLegend:`Немає даних`,ropLabel:`ROP`,ssLabel:`SS`,safetyStockLegend:`Страховий запас`,stockArrivalLegend:`Надходження запасів`,stockoutLegend:`Дефіцит`,alertCount_one:`Сповіщень: {{count}}`,alertCount_other:`Сповіщень: {{count}}`,order:`Замовлення`,save:`Зберегти`,cancel:`Скасувати`,settingsSaved:`Налаштування збережено`,failedSaveSettings:`Не вдалося зберегти налаштування`,globalLeadTimeSaved:`Загальний строк постачання збережено`,globalLeadTime:`Загальний строк постачання`,globalLeadTimeHint:`Мінімальний строк постачання для розрахунку точки повторного замовлення всіх SKU`,skuLeadTimeOverride:`Інший строк постачання`,skuLeadTimeHint:`0 — використовувати загальний строк. Установіть значення понад 0, щоб перевизначити його для цього SKU.`,safetyMarginLabel:`Запас безпеки`,effectiveLeadTime:`Розрахунковий строк постачання`,effectiveLeadTimeHint:`max(глобальний {{global}}d, SKU {{sku}}d)`,reorderPointHint:`d̄ × LT + страховий запас — замовляйте, коли запас досягне цього рівня`,safetyMarginHint:`Статистичний страховий запас (z=1,65 × σ × √LT) + визначений користувачем буфер`,safetyMarginHintDays:`Буфер додано поверх статистичного страхового запасу.{{approx}}`,safetyMarginHintDaysApprox:` ≈ {{g}} г за поточного рівня споживання.`,safetyMarginHintG:`Буфер фіксованої ваги, доданий поверх статистичного страхового запасу.{{approx}}`,safetyMarginHintGApprox:` ≈ {{days}} дн. за поточного рівня споживання.`,individualSpools:`Окремі котушки`,labelWeight:`Вага за етикеткою`,spoolCount_one:`Котушок: {{count}}`,spoolCount_other:`Котушок: {{count}}`,stockBreakRisk:`Ризик вичерпання запасу`,stockBreakDetail:`Залишилося {{days}} дн.; строк постачання — {{lt}} дн.`,stockBreakBefore:`Запас вичерпається до поповнення`,reorderNow:`Замовити зараз`,reorderTriggerPassed:`Дата замовлення {{date}} минула.`,shoppingList:`Список покупок`,shoppingListItems_one:`(позицій: {{count}})`,shoppingListItems_other:`(позицій: {{count}})`,shoppingListEmpty:`Список покупок порожній. Натисніть значок кошика в будь-якому рядку, щоб додати позиції.`,addToCart:`Додати до списку покупок`,alertsSnoozed:`Вимкнути сповіщення для цього SKU`,alertsEnabled:`Увімкнути сповіщення для цього SKU`,addedToCart:`Додано до списку покупок`,failedAddItem:`Не вдалося додати елемент`,listView:`Список`,logisticsView:`Логістика`,qty:`Кількість`,weight:`Вага`,leadTime:`Строк постачання`,expectedRestock:`Очікуване поповнення запасів`,status:`Статус`,note:`Примітка`,pending:`В очікуванні`,purchased:`Придбано`,received:`Отримано`,markPurchased:`Позначити як придбане`,markReceived:`Позначити як отримане — додає котушки до запасу`,resetToPending:`Скинути до очікування`,remove:`Видалити`,clearAll:`Очистити все`,downloadCsv:`CSV`,addToCartTitle:`Додати до списку покупок`,byQuantity:`За кількістю`,byDuration:`За тривалістю`,numberOfSpools:`Кількість котушок`,lastHowManyDays:`Скільки днів має тривати?`,noUsageQty:`Немає даних про використання — кількість встановлено на 1.`,noteOptional:`Примітка (необов’язково)`,notePlaceholder:`напр. для проєкту X, терміново…`,addNSpools_one:`Додати котушки ({{count}})`,addNSpools_other:`Додати котушки ({{count}})`,onArrival:`Після прибуття`,stockBreakIn:`Запас вичерпається через {{days}} дн.`,stockRunsOutBefore:`Запас закінчиться раніше, ніж мине строк постачання {{lt}} дн.`,atRate:`За споживання {{rate}} г/день потрібно`,moreSpools_one:`Додаткових котушок: {{count}}`,moreSpools_other:`Додаткових котушок: {{count}}`,bridgeGap:`щоб покрити нестачу.`,noReadAccess:`Ви не маєте дозволу переглядати прогнози запасів.`,noWriteAccess:`Ви не маєте дозволу змінювати налаштування прогнозу.`}}}},Et=[`en`,`de`,`es`,`fr`,`ja`,`it`,`ko`,`pt-BR`,`ru`,`tr`,`uk`,`zh-CN`,`zh-TW`],Dt=`bambuddy_appliance_locale_consumed`;Oe.use(wt).use(qe).init({resources:Tt,fallbackLng:`en`,supportedLngs:Et,detection:{order:[`localStorage`,`navigator`,`htmlTag`],lookupLocalStorage:`bambutrack_language`,caches:[`localStorage`]},interpolation:{escapeValue:!1},react:{useSuspense:!1}});function Ot(){if(typeof window>`u`||!window.localStorage)return;let e=window.localStorage;typeof e.getItem!=`function`||typeof e.setItem!=`function`||e.getItem(Dt)||fetch(`/api/v1/system/appliance`).then(e=>e.ok?e.json():null).then(t=>{!t||typeof t.locale!=`string`||Et.includes(t.locale)&&(Oe.changeLanguage(t.locale),e.setItem(Dt,`1`))}).catch(()=>{})}Ot();var kt=[{code:`en`,name:`English`,nativeName:`English`},{code:`de`,name:`German`,nativeName:`Deutsch`},{code:`es`,name:`Spanish`,nativeName:`Español`},{code:`fr`,name:`French`,nativeName:`Français`},{code:`ja`,name:`Japanese`,nativeName:`日本語`},{code:`it`,name:`Italian`,nativeName:`Italiano`},{code:`ko`,name:`Korean`,nativeName:`한국어`},{code:`pt-BR`,name:`Portuguese (Brazil)`,nativeName:`Português (Brasil)`},{code:`zh-CN`,name:`Chinese (Simplified)`,nativeName:`简体中文`},{code:`zh-TW`,name:`Chinese (Traditional)`,nativeName:`繁體中文`},{code:`tr`,name:`Turkish`,nativeName:`Türkçe`},{code:`ru`,name:`Russian`,nativeName:`Русский`},{code:`uk`,name:`Ukrainian`,nativeName:`Українська`}],At=l(g()),jt=`modulepreload`,Mt=function(e){return`/`+e},Nt={},Pt=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=Mt(t,n),t in Nt)return;Nt[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:jt,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},Ft=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,It=/^[\\/]{2}/;function Lt(e,t){return t+e.replace(/\\/g,`/`)}var Rt=`popstate`;function zt(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function Bt(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return Wt(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:Gt(t)}return qt(t,n,null,e)}function Vt(e,t){if(e===!1||e==null)throw Error(t)}function W(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function Ht(){return Math.random().toString(36).substring(2,10)}function Ut(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function Wt(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?Kt(t):t,state:n,key:t&&t.key||r||Ht(),mask:i}}function Gt({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function Kt(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function qt(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=zt(e)?e:Wt(h.location,e,t);n&&n(r,e),l=u()+1;let d=Ut(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=zt(e)?e:Wt(h.location,e,t);n&&n(r,e),l=u();let i=Ut(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return Jt(i,e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(Rt,d),c=e,()=>{i.removeEventListener(Rt,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function Jt(e,t,n=!1){let r=`http://localhost`;e&&(r=e.location.origin===`null`?e.location.href:e.location.origin),Vt(r,`No window.location.(origin|href) available to create URL`);let i=typeof t==`string`?t:Gt(t);return i=i.replace(/ $/,`%20`),!n&&It.test(i)&&(i=r+i),new URL(i,r)}function Yt(e,t,n=`/`){return Xt(e,t,n,!1)}function Xt(e,t,n,r,i){let a=_n((typeof t==`string`?Kt(t):t).pathname||`/`,n);if(a==null)return null;let o=i??Qt(e),s=null,c=gn(a);for(let e=0;s==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;Vt(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=Tn([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(Vt(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),$t(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:un(l,e.index),routesMeta:u.map((e,t)=>{let[n,r]=hn(e.relativePath,e.caseSensitive,t===u.length-1);return{...e,matcher:n,compiledParams:r}})})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of en(e.path))a(e,t,!0,n)}),t}function en(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=en(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function tn(e){e.sort((e,t)=>e.score===t.score?dn(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var nn=/^:[\w-]+$/,rn=3,an=2,on=1,sn=10,cn=-2,ln=e=>e===`*`;function un(e,t){let n=e.split(`/`),r=n.length;return n.some(ln)&&(r+=cn),t&&(r+=an),n.filter(e=>!ln(e)).reduce((e,t)=>e+(nn.test(t)?rn:t===``?on:sn),r)}function dn(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function fn(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function hn(e,t=!1,n=!0){W(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function gn(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return W(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function _n(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}function vn(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?Kt(e):e,a;return n?(n=wn(n),a=n.startsWith(`/`)?yn(n.substring(1),`/`):yn(n,t)):a=t,{pathname:a,search:On(r),hash:kn(i)}}function yn(e,t){let n=En(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function bn(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function xn(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function Sn(e){let t=xn(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function Cn(e,t,n,r=!1){let i;typeof e==`string`?i=Kt(e):(i={...e},Vt(!i.pathname||!i.pathname.includes(`?`),bn(`?`,`pathname`,`search`,i)),Vt(!i.pathname||!i.pathname.includes(`#`),bn(`#`,`pathname`,`hash`,i)),Vt(!i.search||!i.search.includes(`#`),bn(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=vn(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var wn=e=>e.replace(/[\\/]{2,}/g,`/`),Tn=e=>wn(e.join(`/`)),En=e=>e.replace(/\/+$/,``),Dn=e=>En(e).replace(/^\/*/,`/`),On=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,kn=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,An=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function jn(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Mn(e){return Tn(e.map(e=>e.route.path).filter(Boolean))||`/`}var Nn=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function Pn(e,t){let n=e;if(typeof n!=`string`||!Ft.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(Nn)try{let e=new URL(window.location.href),r=It.test(n)?new URL(Lt(n,e.protocol)):new URL(n),a=_n(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{W(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var Fn=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(Fn);var In=[`GET`,...Fn];new Set(In);var Ln=[`about:`,`blob:`,`chrome:`,`chrome-untrusted:`,`content:`,`data:`,`devtools:`,`file:`,`filesystem:`,`javascript:`];function Rn(e){try{return Ln.includes(new URL(e).protocol)}catch{return!1}}var zn=y.createContext(null);zn.displayName=`DataRouter`;var Bn=y.createContext(null);Bn.displayName=`DataRouterState`;var Vn=y.createContext(!1);function Hn(){return y.useContext(Vn)}var Un=y.createContext({isTransitioning:!1});Un.displayName=`ViewTransition`;var Wn=y.createContext(new Map);Wn.displayName=`Fetchers`;var Gn=y.createContext(null);Gn.displayName=`Await`;var Kn=y.createContext(null);Kn.displayName=`Navigation`;var qn=y.createContext(null);qn.displayName=`Location`;var Jn=y.createContext({outlet:null,matches:[],isDataRoute:!1});Jn.displayName=`Route`;var Yn=y.createContext(null);Yn.displayName=`RouteError`;var Xn=`REACT_ROUTER_ERROR`,Zn=`REDIRECT`,Qn=`ROUTE_ERROR_RESPONSE`;function $n(e){if(e.startsWith(`${Xn}:${Zn}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function er(e){if(e.startsWith(`${Xn}:${Qn}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new An(t.status,t.statusText,t.data)}catch{}}function tr(e,{relative:t}={}){Vt(nr(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=y.useContext(Kn),{hash:i,pathname:a,search:o}=fr(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:Tn([n,a])),r.createHref({pathname:s,search:o,hash:i})}function nr(){return y.useContext(qn)!=null}function rr(){return Vt(nr(),`useLocation() may be used only in the context of a component.`),y.useContext(qn).location}var ir=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function ar(e){y.useContext(Kn).static||y.useLayoutEffect(e)}function or(){let{isDataRoute:e}=y.useContext(Jn);return e?jr():sr()}function sr(){Vt(nr(),`useNavigate() may be used only in the context of a component.`);let e=y.useContext(zn),{basename:t,navigator:n}=y.useContext(Kn),{matches:r}=y.useContext(Jn),{pathname:i}=rr(),a=JSON.stringify(Sn(r)),o=y.useRef(!1);return ar(()=>{o.current=!0}),y.useCallback((r,s={})=>{if(W(o.current,ir),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=Cn(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:Tn([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}var cr=y.createContext(null);function lr(){return y.useContext(cr)}function ur(e){let t=y.useContext(Jn).outlet;return y.useMemo(()=>t&&y.createElement(cr.Provider,{value:e},t),[t,e])}function dr(){let{matches:e}=y.useContext(Jn);return e[e.length-1]?.params??{}}function fr(e,{relative:t}={}){let{matches:n}=y.useContext(Jn),{pathname:r}=rr(),i=JSON.stringify(Sn(n));return y.useMemo(()=>Cn(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function pr(e,t){return mr(e,t)}function mr(e,t,n){Vt(nr(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=y.useContext(Kn),{matches:i}=y.useContext(Jn),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;Nr(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. -Please change the parent to .`)}let u=rr(),d;if(t){let e=typeof t==`string`?Kt(t):t;Vt(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):Yt(e,{pathname:p});W(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),W(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=xr(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:Tn([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:Tn([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?y.createElement(qn.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function hr(){let e=Ar(),t=jn(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=y.createElement(y.Fragment,null,y.createElement(`p`,null,`💿 Hey developer 👋`),y.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,y.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,y.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),y.createElement(y.Fragment,null,y.createElement(`h2`,null,`Unexpected Application Error!`),y.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?y.createElement(`pre`,{style:i},n):null,o)}var gr=y.createElement(hr,null),_r=class extends y.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=er(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:y.createElement(Jn.Provider,{value:this.props.routeContext},y.createElement(Yn.Provider,{value:e,children:this.props.component}));return this.context?y.createElement(yr,{error:e},t):t}};_r.contextType=Vn;var vr=new WeakMap;function yr({children:e,error:t}){let{basename:n}=y.useContext(Kn);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=$n(t.digest);if(e){let r=vr.get(t);if(r)throw r;let i=Pn(e.location,n),a=i.absoluteURL||i.to;if(Rn(a))throw Error(`Invalid redirect location`);if(Nn&&!vr.get(t))if(i.isExternal||e.reloadDocument)window.location.href=a;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw vr.set(t,n),n}return y.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${a}`})}}return e}function br({routeContext:e,match:t,children:n}){let r=y.useContext(zn);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),y.createElement(Jn.Provider,{value:e},n)}function xr(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);Vt(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:Mn(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||gr,o&&(s<0&&c===0?(Nr(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?y.createElement(n.route.Component,null):n.route.element?n.route.element:e,y.createElement(br,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?y.createElement(_r,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function Sr(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Cr(e){let t=y.useContext(zn);return Vt(t,Sr(e)),t}function wr(e){let t=y.useContext(Bn);return Vt(t,Sr(e)),t}function Tr(e){let t=y.useContext(Jn);return Vt(t,Sr(e)),t}function Er(e){let t=Tr(e),n=t.matches[t.matches.length-1];return Vt(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function Dr(){return Er(`useRouteId`)}function Or(){let e=wr(`useNavigation`);return y.useMemo(()=>{let{matches:t,historyAction:n,...r}=e.navigation;return r},[e.navigation])}function kr(){let{matches:e,loaderData:t}=wr(`useMatches`);return y.useMemo(()=>e.map(e=>Zt(e,t)),[e,t])}function Ar(){let e=y.useContext(Yn),t=wr(`useRouteError`),n=Er(`useRouteError`);return e===void 0?t.errors?.[n]:e}function jr(){let{router:e}=Cr(`useNavigate`),t=Er(`useNavigate`),n=y.useRef(!1);return ar(()=>{n.current=!0}),y.useCallback(async(r,i={})=>{W(n.current,ir),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var Mr={};function Nr(e,t,n){!t&&!Mr[e]&&(Mr[e]=!0,W(!1,n))}y.memo(Pr);function Pr({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return mr(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Fr({to:e,replace:t,state:n,relative:r}){Vt(nr(),` may be used only in the context of a component.`);let{static:i}=y.useContext(Kn);W(!i,` must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.`);let{matches:a}=y.useContext(Jn),{pathname:o}=rr(),s=or(),c=Cn(e,Sn(a),o,r===`path`),l=JSON.stringify(c);return y.useEffect(()=>{s(JSON.parse(l),{replace:t,state:n,relative:r})},[s,l,r,t,n]),null}function Ir(e){return ur(e.context)}function Lr(e){Vt(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function Rr({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){Vt(!nr(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=y.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=Kt(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=y.useMemo(()=>{let e=_n(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return W(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:y.createElement(Kn.Provider,{value:c},y.createElement(qn.Provider,{children:t,value:h}))}function zr({children:e,location:t}){return pr(Br(e),t)}y.Component;function Br(e,t=[]){let n=[];return y.Children.forEach(e,(e,r)=>{if(!y.isValidElement(e))return;let i=[...t,r];if(e.type===y.Fragment){n.push.apply(n,Br(e.props.children,i));return}Vt(e.type===Lr,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),Vt(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=Br(e.props.children,i)),n.push(a)}),n}var Vr=`get`,Hr=`application/x-www-form-urlencoded`;function Ur(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Wr(e){return Ur(e)&&e.tagName.toLowerCase()===`button`}function Gr(e){return Ur(e)&&e.tagName.toLowerCase()===`form`}function Kr(e){return Ur(e)&&e.tagName.toLowerCase()===`input`}function qr(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function Jr(e,t){return e.button===0&&(!t||t===`_self`)&&!qr(e)}function Yr(e=``){return new URLSearchParams(typeof e==`string`||Array.isArray(e)||e instanceof URLSearchParams?e:Object.keys(e).reduce((t,n)=>{let r=e[n];return t.concat(Array.isArray(r)?r.map(e=>[n,e]):[[n,r]])},[]))}function Xr(e,t){let n=Yr(e);return t&&t.forEach((e,r)=>{n.has(r)||t.getAll(r).forEach(e=>{n.append(r,e)})}),n}var Zr=null;function Qr(){if(Zr===null)try{new FormData(document.createElement(`form`),0),Zr=!1}catch{Zr=!0}return Zr}var $r=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function ei(e){return e!=null&&!$r.has(e)?(W(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Hr}"`),null):e}function eee(e,t){let n,r,i,a,o;if(Gr(e)){let o=e.getAttribute(`action`);r=o?_n(o,t):null,n=e.getAttribute(`method`)||Vr,i=ei(e.getAttribute(`enctype`))||Hr,a=new FormData(e)}else if(Wr(e)||Kr(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a