commit 3c9a9fc060b900529d7876b3d44f13e8487c3c85 Author: Nils Schneider Date: Wed Dec 17 13:37:31 2025 +0100 Initial commit diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..cee57d7 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,146 @@ +name: ci + +on: + push: + branches: + - main + - staging + - dev + tags: + - '*' + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - service: frontend + context: frontend + dockerfile: frontend/Dockerfile + - service: backend + context: backend + dockerfile: backend/Dockerfile + + steps: + - name: Checkout with submodules + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Derive repository metadata + id: repo_meta + run: | + repo="${GITHUB_REPOSITORY}" + owner="${repo%%/*}" + name="${repo##*/}" + echo "repo_owner=$owner" >> "$GITHUB_OUTPUT" + echo "repo_name=$name" >> "$GITHUB_OUTPUT" + + - name: Compute base tag + id: compute_tag + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_REF_TYPE: ${{ github.ref_type }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: | + sha="${GITHUB_SHA}" + ref_type="${GITHUB_REF_TYPE}" + ref_name="${GITHUB_REF_NAME}" + + short="${sha:0:7}" + tag="$short" + + if [ "$ref_type" = "tag" ]; then + tag="$ref_name" + elif [ "$ref_name" = "dev" ]; then + tag="${tag}-dev" + elif [ "$ref_name" = "staging" ]; then + tag="${tag}-staging" + fi + + echo "base_tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Login to local registry + if: ${{ vars.REGISTRY_URL != '' }} + uses: docker/login-action@v2 + with: + registry: ${{ vars.REGISTRY_URL }} + username: ${{ vars.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Determine branch alias tag + id: branch_alias + env: + REF_NAME: ${{ github.ref_name }} + run: | + alias="" + case "${REF_NAME}" in + dev) alias="latest-dev" ;; + staging) alias="latest-staging" ;; + main) alias="latest" ;; + esac + echo "alias=$alias" >> "$GITHUB_OUTPUT" + + - name: Assemble image tags + id: tag_list + env: + REGISTRY_URL: ${{ vars.REGISTRY_URL }} + REPO_NAME: ${{ steps.repo_meta.outputs.repo_name }} + SERVICE: ${{ matrix.service }} + BASE_TAG: ${{ steps.compute_tag.outputs.base_tag }} + GIT_SHA: ${{ github.sha }} + BRANCH_ALIAS: ${{ steps.branch_alias.outputs.alias }} + shell: bash + run: | + tags="" + + if [ -n "${REGISTRY_URL}" ]; then + repo_tag="${REGISTRY_URL}/${REPO_NAME}-${SERVICE}" + tags="${tags}${repo_tag}:${BASE_TAG}\n${repo_tag}:${GIT_SHA}" + fi + + ghcr_tag="ghcr.io/papercrate-dms/${REPO_NAME}-${SERVICE}" + if [ -n "${tags}" ]; then + tags="${tags}\n" + fi + tags="${tags}${ghcr_tag}:${BASE_TAG}\n${ghcr_tag}:${GIT_SHA}" + + if [ -n "${BRANCH_ALIAS}" ]; then + if [ -n "${REGISTRY_URL}" ]; then + tags="${tags}\n${repo_tag}:${BRANCH_ALIAS}" + fi + tags="${tags}\n${ghcr_tag}:${BRANCH_ALIAS}" + fi + + export TAGS="${tags}" + + python -c 'import os; tags=[t.strip() for t in os.environ["TAGS"].split("\\n") if t.strip()]; print("tags=" + ",".join(tags))' | tee -a "$GITHUB_OUTPUT" + + - name: Build and Push ${{ matrix.service }} Image + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + tags: ${{ steps.tag_list.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54f87dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# macOS +.DS_Store + +# Rust +/backend/target/ +/backend/.env +/backend/.env.* +/backend/.cargo/ +backend/libpdfium.* + +# Node/Frontend +/frontend/node_modules/ +/frontend/dist/ +/frontend/.env +/frontend/.env.* + +# Logs and temp +*.log +*.tmp +*.swp + +# Docker artifacts +*.pid + +# Environment overrides +.env +.env.local diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..874afbe --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,107 @@ +# Development + +This document collects runtime assumptions and workflows for local development, +integration testing, and infrastructure automation. + +## Local Development + +Use the provided `papercrate.tmux` to spin up the full stack in one tmux session: + +```bash +tmux -f papercrate.tmux attach +``` + +This creates windows for the compose stack, frontend dev server, backend API, and +background worker using the repository-relative paths defined in the tmux file. +Detach with `Ctrl+b d` and reattach later with the same command. + +The development Postgres container now seeds two database roles: + +- `papercrate_app_login` (password `papercrate_app`) is used by the backend and + is subject to row-level security policies. +- `papercrate` remains the owner role for running Diesel migrations or other + maintenance tasks. + +When connecting manually to inspect RLS behaviour, switch to the application +role with `SET ROLE papercrate_app_login;` before querying tenant tables. + +## Backend Integration Tests + +Integration tests require a running Postgres instance (and, optionally, Quickwit +for OCR indexing). The repository includes a lightweight compose file for local +runs: + +```bash +docker compose -f docker-compose.test.yml up -d +export TEST_DATABASE_URL=postgres://papercrate:papercrate_test@localhost:5433/papercrate_test +# optional, enables Quickwit indexing jobs +export QUICKWIT_ENDPOINT=http://localhost:7280 +export QUICKWIT_INDEX=documents +cargo test +``` + +Stop the database when you are done: + +```bash +docker compose -f docker-compose.test.yml down +``` + +The compose service uses tmpfs storage, giving each test run a clean database. + +## Runtime Dependencies + +- `ocrmypdf` (optional but recommended): Used by the OCR worker to extract text + from PDFs when no embedded text layer is available. Ensure it is installed and + available on the worker hosts if OCR is desired. +- Quickwit (optional): The Quickwit indexer is used to ingest extracted text for + search. Set `QUICKWIT_ENDPOINT` and `QUICKWIT_INDEX` in the environment when + running workers if you want indexing jobs to run. The local compose file starts + a Quickwit instance on `http://localhost:7280` and seeds the `documents` index + automatically. + +## Configuration + +The backend reads its settings from environment variables. In particular: + +- `DATABASE_URL` – connection string for the primary Postgres database (required). +- `DATABASE_MAX_POOL_SIZE` – optional override for the r2d2 connection pool size. + Defaults to `2`; increase it in staging/production to match expected concurrency. +- `PROXY_DOWNLOADS` – set to `true` when the object store is only reachable from + the backend network. When enabled, `/api/download/{token}` and asset-object fetches + stream bytes through the API instead of redirecting clients to S3/Hetzner. + +On startup each binary logs the effective configuration with secrets redacted +(for example, the database password is masked). This makes it easier to confirm +runtime settings in staging without exposing credentials. + +## Running Migrations in Kubernetes + +The backend container image ships the `diesel` CLI, so schema migrations can be +executed as a short-lived Job (or Helm hook) before rolling out new pods. Example +manifest: + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: papercrate-migrate +spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: migrate + image: ghcr.io/example/papercrate-backend: + command: ["/usr/local/bin/diesel", "migration", "run"] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: papercrate-db + key: DATABASE_URL +``` + +Run the Job manually (`kubectl apply -f migrate-job.yaml`) or configure it as a +Helm pre-install/pre-upgrade hook so migrations run automatically on each +deployment. Once the Job succeeds, deploy/update the backend `Deployment` as +usual. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..537e08e --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Papercrate + +![Papercrate document workspace](docs/screenshot.png) + +## Single-Host Deployment (Docker Compose) + +For self-hosting (for example on a Raspberry Pi), use the production-oriented +`docker-compose.yml`. Define the required secrets in a `.env` file alongside the +compose file before starting the stack: + +```bash +cat <<'EOF' > .env +POSTGRES_PASSWORD=change-me +MINIO_ROOT_PASSWORD=change-me-too +JWT_SECRET=generate-a-long-random-string +WEBAUTHN_RP_ID=papercrate.local +WEBAUTHN_ORIGIN=http://papercrate.local:8080 +# Optional overrides +# CORS_ALLOWED_ORIGIN=http://papercrate.local:8080 +# REFRESH_COOKIE_SECURE=true +EOF + +docker compose build +docker compose up -d +``` + +**Important:** the WebAuthn settings must match the public URL clients will use. +The relying party (RP) identifier is the bare host name, while the origin must +include scheme and port. Adjust the values above if you serve Papercrate from a +different host, domain, or HTTPS endpoint—otherwise passkey registration and +login will fail. + +The compose file builds the backend and frontend images locally, then launches +Postgres, MinIO, Quickwit, the API, background worker, WebDAV endpoint, and the +SPA frontend. Once the containers report healthy, visit `http://:8080` +and use the passkey signup flow to provision the first tenant/user. Upgrades are +as simple as `git pull` followed by `docker compose up -d`. + +## Screenshots + +![Document detail view](docs/detailview.png) +![Desk overview](docs/deskview.png) +![Document view](docs/documentview.png) + +--- + +For development workflows (local stack, integration tests, migrations, and +configuration details) see [DEVELOPMENT.md](./DEVELOPMENT.md). diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..06b92ce --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,4449 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "native-tls", + "rustls", + "serde", + "serde_json", + "url", + "webpki-roots", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-creds" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13804829a843b3f26e151c97acbb315ee1177a2724690edfcd28f1894146200" +dependencies = [ + "attohttpc", + "home", + "log", + "quick-xml", + "rust-ini", + "serde", + "thiserror 2.0.17", + "time", + "url", +] + +[[package]] +name = "aws-lc-rs" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879b6c89592deb404ba4dc0ae6b58ffd1795c78991cbb5b8bc441c48a070440d" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "107a4e9d9cab9963e04e84bb8dee0e25f2a987f9a8bad5ed054abd439caa8f8c" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-region" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5532f65342f789f9c1b7078ea9c9cd9293cd62dcc284fa99adc4a1c9ba43469c" +dependencies = [ + "thiserror 2.0.17", +] + +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5136e6c5e7e7978fe23e9876fb924af2c0f84c72127ac6ac17e7c46f457d362c" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-core", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "base64urlsafedata" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "215ee31f8a88f588c349ce2d20108b2ed96089b96b9c2b03775dc35dd72938e8" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.109", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" +dependencies = [ + "log", + "web-sys", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "diesel" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7624a3bb9fffd82fff016be9a7f163d20e5a89eb8d28f9daaa6b30fff37500" +dependencies = [ + "bitflags", + "byteorder", + "chrono", + "diesel_derives", + "downcast-rs", + "itoa", + "pq-sys", + "r2d2", + "serde_json", + "uuid", +] + +[[package]] +name = "diesel_derives" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9daac6489a36e42570da165a10c424f3edcefdff70c5fd55e1847c23f3dd7562" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee060f709c3e3b1cadd83fcd0f61711f7a8cf493348f758d3a1c1147d70b3c97" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.16", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minidom" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e394a0e3c7ccc2daea3dffabe82f09857b6b510cb25af87d54bf3e910ac1642d" +dependencies = [ + "rxml", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "papercrate" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "async-trait", + "axum", + "axum-extra", + "base64 0.22.1", + "bytes", + "chrono", + "chrono-tz", + "clap", + "diesel", + "diesel_migrations", + "dotenv", + "envy", + "futures-util", + "hex", + "hmac", + "http-body-util", + "hyper", + "image", + "infer", + "jsonwebtoken", + "mime_guess", + "once_cell", + "pdfium-render", + "percent-encoding", + "quick-xml", + "rand 0.9.2", + "regex", + "reqwest", + "rust-s3", + "serde", + "serde-aux", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "serde_yaml", + "sha2", + "tempfile", + "thiserror 2.0.17", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "utoipa", + "uuid", + "webauthn-rs", + "webauthn-rs-core", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pdfium-render" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8bfacaecc7ff9bcc2f23fed64336830f823b13548a20083f3d0b0164537d2c" +dependencies = [ + "bitflags", + "bytemuck", + "bytes", + "chrono", + "console_error_panic_hook", + "console_log", + "image", + "itertools 0.14.0", + "js-sys", + "libloading 0.9.0", + "log", + "maybe-owned", + "once_cell", + "utf16string", + "vecmath", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piston-float" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pq-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.109", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust-s3" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f9b973bd4097f5bb47e5827dcb9fb5dc17e93879e46badc27d2a4e9a4e5588" +dependencies = [ + "async-trait", + "aws-creds", + "aws-region", + "base64 0.22.1", + "bytes", + "cfg-if", + "futures-util", + "hex", + "hmac", + "http", + "log", + "maybe-async", + "md5", + "minidom", + "percent-encoding", + "quick-xml", + "reqwest", + "serde", + "serde_derive", + "serde_json", + "sha2", + "sysinfo", + "thiserror 2.0.17", + "time", + "tokio", + "tokio-stream", + "url", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rxml" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc94b580d0f5a6b7a2d604e597513d3c673154b52ddeccd1d5c32360d945ee" +dependencies = [ + "bytes", + "rxml_validation", +] + +[[package]] +name = "rxml_validation" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826e80413b9a35e9d33217b3dcac04cf95f6559d15944b93887a08be5496c4a4" +dependencies = [ + "compact_str", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-aux" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a" +dependencies = [ + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf16string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216" +dependencies = [ + "byteorder", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.109", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vecmath" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a" +dependencies = [ + "piston-float", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.109", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77a2892ec44032e6c48dad9aad1b05fada09c346ada11d8d32db119b4b4f205" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7c3a2f9c8bddd524e47bbd427bcf3a28aa074de55d74470b42a91a41937b8e" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f1d80f3146382529fe70a3ab5d0feb2413a015204ed7843f9377cd39357fc4" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.8.5", + "rand_chacha 0.3.1", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e786894f89facb9aaf1c5f6559670236723c98382e045521c76f3d5ca5047bd" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..e02976b --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,99 @@ +[package] +name = "papercrate" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Web framework +axum = { version = "0.8", features = ["multipart"] } +tokio = { version = "1.48", features = ["full"] } +tower = { version = "0.5", features = ["make", "util"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } +axum-extra = { version = "0.12", features = ["typed-header"] } + +# Database +diesel = { version = "2.3.3", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] } +diesel_migrations = "2.1" +uuid = { version = "1.6", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } + +# S3 +rust-s3 = { version = "0.37", features = ["with-tokio", "tokio-rustls-tls"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +envy = "0.4" +serde-aux = "4.4" + +# Utilities +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dotenv = "0.15" +sha2 = "0.10" +hex = "0.4" +hmac = "0.12" +bytes = "1.5" +async-trait = "0.1" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +pdfium-render = "0.8.36" +mime_guess = "2.0" +tempfile = "3.10" +reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "stream"] } +percent-encoding = "2.3" +base64 = "0.22" +quick-xml = "0.38" +futures-util = "0.3" +url = "2.5" +once_cell = "1.19" +regex = "1.11" +infer = "0.19" +utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] } +clap = { version = "4.5", features = ["derive"] } + +# Error handling +thiserror = "2.0" +anyhow = "1.0" + +# Authentication & security +argon2 = "0.5" +jsonwebtoken = { version = "10", features = ["rust_crypto"] } +webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } +serde_bytes = "0.11" +serde_cbor_2 = "0.13" + +# Misc +rand = "0.9" +hyper = "1.2" +http-body-util = "0.1" +chrono-tz = "0.8" + +[dev-dependencies] +once_cell = "1.19" +webauthn-rs-core = "0.5" +serde_yaml = "0.9" + +[build-dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +serde_json = "1.0" +regex = "1.11" + +[[bin]] +name = "backend" +path = "src/main.rs" +[[bin]] +name = "worker" +path = "src/bin/worker.rs" + +[[bin]] +name = "webdav" +path = "src/bin/webdav.rs" + +[[bin]] +name = "admin" +path = "src/bin/admin.rs" + +[[bin]] +name = "openapi-dump" +path = "src/bin/openapi_dump.rs" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ffce2ca --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,143 @@ +# syntax=docker/dockerfile:1.7 + +ARG RUST_VERSION=1 +FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim AS builder +ARG TARGETARCH +ENV TARGETARCH=${TARGETARCH} +WORKDIR /app + +RUN cat <<'SCRIPT' >/usr/local/bin/resolve-target.sh +#!/bin/sh +set -e +case "$1" in + amd64) + echo x86_64-unknown-linux-gnu + ;; + arm64) + echo aarch64-unknown-linux-gnu + ;; + *) + echo "Unsupported TARGETARCH: $1" >&2 + exit 1 + ;; +esac +SCRIPT +RUN chmod +x /usr/local/bin/resolve-target.sh + +ENV PKG_CONFIG_ALLOW_CROSS=1 \ + CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ + PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig \ + PKG_CONFIG_PATH_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig \ + PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu=/usr/aarch64-linux-gnu \ + OPENSSL_DIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu \ + OPENSSL_LIB_DIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu \ + OPENSSL_INCLUDE_DIR_aarch64_unknown_linux_gnu=/usr/include/aarch64-linux-gnu \ + CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \ + PKG_CONFIG_LIBDIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu/pkgconfig \ + PKG_CONFIG_PATH_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu/pkgconfig \ + OPENSSL_DIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu \ + OPENSSL_LIB_DIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu \ + OPENSSL_INCLUDE_DIR_x86_64_unknown_linux_gnu=/usr/include/x86_64-linux-gnu + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) TARGET_DEB_ARCH=amd64 ;; \ + arm64) TARGET_DEB_ARCH=arm64 ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + BUILD_DEB_ARCH="$(dpkg --print-architecture)"; \ + if [ "${TARGET_DEB_ARCH}" != "${BUILD_DEB_ARCH}" ]; then \ + dpkg --add-architecture "${TARGET_DEB_ARCH}"; \ + fi; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + curl \ + libssl-dev \ + libpq-dev \ + libjpeg-dev \ + libpng-dev \ + zlib1g-dev; \ + if [ "${TARGET_DEB_ARCH}" != "${BUILD_DEB_ARCH}" ]; then \ + case "${TARGETARCH}" in \ + arm64) CROSS_GCC=gcc-aarch64-linux-gnu ;; \ + amd64) CROSS_GCC=gcc-x86-64-linux-gnu ;; \ + esac; \ + apt-get install -y --no-install-recommends \ + "${CROSS_GCC}" \ + "libc6-dev:${TARGET_DEB_ARCH}" \ + "libssl-dev:${TARGET_DEB_ARCH}" \ + "libpq-dev:${TARGET_DEB_ARCH}" \ + "libjpeg-dev:${TARGET_DEB_ARCH}" \ + "libpng-dev:${TARGET_DEB_ARCH}" \ + "zlib1g-dev:${TARGET_DEB_ARCH}"; \ + fi; \ + rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock build.rs ./ +COPY src ./src +COPY migrations ./migrations +COPY tests ./tests +COPY resources ./resources +COPY diesel.toml ./ + +RUN set -eux; \ + TARGET="$(/usr/local/bin/resolve-target.sh "${TARGETARCH}")"; \ + rustup target add "${TARGET}"; \ + cargo build --release --target "${TARGET}" --bin backend --bin worker --bin webdav --bin admin; \ + mkdir -p /artifacts; \ + for bin in backend worker webdav admin; do \ + cp "target/${TARGET}/release/${bin}" "/artifacts/${bin}"; \ + done + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) pdfium_package=pdfium-linux-x64.tgz ;; \ + arm64) pdfium_package=pdfium-linux-arm64.tgz ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/${pdfium_package}" -o /tmp/pdfium.tgz; \ + mkdir -p /tmp/pdfium; \ + tar -xzf /tmp/pdfium.tgz -C /tmp/pdfium --strip-components=1; \ + pdfium_so="$(find /tmp/pdfium -name libpdfium.so -type f | head -n1)"; \ + [ -n "${pdfium_so}" ]; \ + cp "${pdfium_so}" /artifacts/libpdfium.so; \ + rm -rf /tmp/pdfium.tgz /tmp/pdfium + +FROM --platform=$TARGETPLATFORM debian:trixie-slim AS runtime +ARG TARGETARCH +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + libssl3 \ + libpq5 \ + libjpeg62-turbo \ + libpng16-16 \ + ocrmypdf \ + tesseract-ocr \ + ghostscript \ + qpdf \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /usr/local/lib \ + && useradd --system --create-home --uid 10001 appuser + +COPY --from=builder /artifacts/backend /usr/local/bin/papercrate-backend +COPY --from=builder /artifacts/worker /usr/local/bin/papercrate-worker +COPY --from=builder /artifacts/webdav /usr/local/bin/papercrate-webdav +COPY --from=builder /artifacts/admin /usr/local/bin/papercrate-admin +COPY --from=builder /artifacts/libpdfium.so /usr/local/lib/libpdfium.so +RUN ldconfig +COPY migrations ./migrations + +ENV RUST_LOG=info +USER appuser +EXPOSE 3000 + +ENTRYPOINT ["/usr/local/bin/papercrate-backend"] diff --git a/backend/build.rs b/backend/build.rs new file mode 100644 index 0000000..e040bf0 --- /dev/null +++ b/backend/build.rs @@ -0,0 +1,123 @@ +use std::env; +use std::fs; +use std::path::PathBuf; + +use regex::escape; +use serde::Deserialize; + +#[derive(Deserialize)] +struct CaseSuite { + cases: Vec, +} + +#[derive(Deserialize)] +struct CaseName { + name: String, +} + +#[derive(Deserialize)] +struct MonthSuite { + months: Vec, +} + +#[derive(Deserialize)] +struct MonthDefinition { + name: String, + month: u32, + #[serde(default)] + locales: Vec, +} + +fn sanitize(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch); + } else { + out.push('_'); + } + } + if out.is_empty() { + "case".to_string() + } else if out.chars().next().unwrap().is_ascii_digit() { + format!("_{}", out) + } else { + out + } +} + +fn quote(value: &str) -> String { + serde_json::to_string(value).expect("string literal") +} + +fn generate_tests() -> Result> { + let yaml_path = PathBuf::from("tests/data/issued_at_cases.yaml"); + let contents = fs::read_to_string(&yaml_path)?; + let suite: CaseSuite = serde_yaml::from_str(&contents)?; + + let mut output = + String::from("#[cfg(test)]\npub mod issued_at_generated_tests {\n use super::*;\n"); + + for case in suite.cases { + let ident = sanitize(&case.name); + output.push_str(&format!( + " #[test]\n fn {}() {{\n run_named_case(\"{}\");\n }}\n", + ident, case.name + )); + } + + output.push_str("}\n"); + Ok(output) +} + +fn generate_months() -> Result> { + let yaml_path = PathBuf::from("resources/issued_at_months.yaml"); + let contents = fs::read_to_string(&yaml_path)?; + let suite: MonthSuite = serde_yaml::from_str(&contents)?; + + let mut pattern_parts = Vec::with_capacity(suite.months.len()); + let mut entries = String::new(); + for entry in &suite.months { + pattern_parts.push(escape(&entry.name)); + let locales_literal = if entry.locales.is_empty() { + "&[]".to_string() + } else { + let joined = entry + .locales + .iter() + .map(|loc| quote(loc)) + .collect::>() + .join(", "); + format!("&[{}]", joined) + }; + entries.push_str(&format!( + " MonthVariant {{ name: {}, month: {}, locales: {} }},\n", + quote(&entry.name), + entry.month, + locales_literal + )); + } + + let pattern_literal = quote(&pattern_parts.join("|")); + let output = format!( + "pub(super) static MONTH_VARIANTS: &[MonthVariant] = &[\n{entries}];\n\n", + entries = entries + ) + &format!( + "pub(super) const MONTH_PATTERN: &str = {};\n", + pattern_literal + ); + Ok(output) +} + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=tests/data/issued_at_cases.yaml"); + println!("cargo:rerun-if-changed=resources/issued_at_months.yaml"); + + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + fs::write( + out_dir.join("issued_at_generated_tests.rs"), + generate_tests()?, + )?; + fs::write(out_dir.join("issued_at_months.rs"), generate_months()?)?; + Ok(()) +} diff --git a/backend/diesel.toml b/backend/diesel.toml new file mode 100644 index 0000000..c3b66ee --- /dev/null +++ b/backend/diesel.toml @@ -0,0 +1,6 @@ +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] + +[migrations_directory] +dir = "migrations" \ No newline at end of file diff --git a/backend/migrations/202510300000_initial_schema/down.sql b/backend/migrations/202510300000_initial_schema/down.sql new file mode 100644 index 0000000..bf79af8 --- /dev/null +++ b/backend/migrations/202510300000_initial_schema/down.sql @@ -0,0 +1,25 @@ +DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs; +DROP FUNCTION IF EXISTS touch_jobs_updated_at(); + +DROP TABLE IF EXISTS webauthn_challenges; +DROP TABLE IF EXISTS user_passkeys; +DROP TABLE IF EXISTS webdav_tokens; +ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk; +DROP TABLE IF EXISTS document_asset_objects; +DROP TABLE IF EXISTS document_assets; +DROP TABLE IF EXISTS document_versions; +DROP TABLE IF EXISTS document_tags; +DROP TABLE IF EXISTS document_correspondents; +DROP TABLE IF EXISTS correspondents; +DROP TABLE IF EXISTS documents; +DROP TABLE IF EXISTS folders; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS jobs; +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS user_memberships; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS tenants; + +DROP TYPE IF EXISTS tenant_status; + +DROP EXTENSION IF EXISTS "pgcrypto"; diff --git a/backend/migrations/202510300000_initial_schema/up.sql b/backend/migrations/202510300000_initial_schema/up.sql new file mode 100644 index 0000000..c6d4296 --- /dev/null +++ b/backend/migrations/202510300000_initial_schema/up.sql @@ -0,0 +1,295 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error'); + +CREATE TABLE tenants ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + storage_root TEXT, + quickwit_index TEXT, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status tenant_status NOT NULL, + created_by UUID +); + +CREATE UNIQUE INDEX tenants_storage_root_unique + ON tenants (storage_root) + WHERE storage_root IS NOT NULL; + +CREATE UNIQUE INDEX tenants_quickwit_index_unique + ON tenants (quickwit_index) + WHERE quickwit_index IS NOT NULL; + +CREATE TABLE users ( + id UUID PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE user_memberships ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, tenant_id) +); + +CREATE INDEX user_memberships_tenant_id_idx ON user_memberships(tenant_id); +CREATE INDEX user_memberships_user_id_idx ON user_memberships(user_id); + +CREATE TABLE folders ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + parent_id UUID REFERENCES folders(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE INDEX idx_folders_parent ON folders(parent_id); +CREATE INDEX folders_tenant_id_idx ON folders(tenant_id); +CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx + ON folders ( + tenant_id, + COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), + name + ); + +CREATE TABLE documents ( + id UUID PRIMARY KEY, + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100), + folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + issued_at TIMESTAMPTZ, + title VARCHAR(255) NOT NULL, + current_version_id UUID NOT NULL, + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE INDEX idx_documents_folder ON documents(folder_id); +CREATE INDEX idx_documents_deleted_at ON documents(deleted_at); +CREATE INDEX documents_tenant_id_idx ON documents(tenant_id); +CREATE INDEX idx_documents_current_version_id ON documents(current_version_id); +CREATE INDEX idx_documents_folder_title + ON documents ( + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + title + ) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX documents_tenant_folder_filename_unique + ON documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + filename + ) + WHERE deleted_at IS NULL; + +CREATE TABLE document_versions ( + id UUID PRIMARY KEY, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + version_number INT NOT NULL, + s3_key VARCHAR(500) NOT NULL, + size_bytes BIGINT NOT NULL, + checksum VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + tenant_id UUID NOT NULL REFERENCES tenants(id), + CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number) +); + +CREATE INDEX idx_document_versions_document ON document_versions(document_id); +CREATE INDEX document_versions_tenant_id_idx ON document_versions(tenant_id); + +ALTER TABLE documents + ADD CONSTRAINT documents_current_version_fk + FOREIGN KEY (current_version_id) + REFERENCES document_versions(id) + DEFERRABLE INITIALLY DEFERRED; + +CREATE TABLE tags ( + id UUID PRIMARY KEY, + label VARCHAR(100) NOT NULL, + color VARCHAR(7), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE UNIQUE INDEX tags_tenant_label_unique ON tags(tenant_id, label); +CREATE INDEX tags_tenant_id_idx ON tags(tenant_id); + +CREATE TABLE document_tags ( + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_by UUID REFERENCES users(id), + tenant_id UUID NOT NULL REFERENCES tenants(id), + PRIMARY KEY (document_id, tag_id) +); + +CREATE INDEX idx_document_tags_tag ON document_tags(tag_id); +CREATE INDEX document_tags_tenant_id_idx ON document_tags(tenant_id); + +CREATE TABLE correspondents ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE UNIQUE INDEX correspondents_tenant_name_unique + ON correspondents (tenant_id, name); +CREATE INDEX correspondents_tenant_id_idx ON correspondents(tenant_id); + +CREATE TABLE document_correspondents ( + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_by UUID REFERENCES users(id), + tenant_id UUID NOT NULL REFERENCES tenants(id), + PRIMARY KEY (document_id, correspondent_id) +); + +CREATE INDEX idx_document_correspondents_document ON document_correspondents(document_id); +CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents(correspondent_id); +CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents(tenant_id); + +CREATE TABLE document_assets ( + id UUID PRIMARY KEY, + document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE, + asset_type TEXT NOT NULL, + mime_type TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + cardinality INTEGER, + tenant_id UUID NOT NULL REFERENCES tenants(id), + CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type), + CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1) +); + +CREATE INDEX idx_document_assets_version ON document_assets(document_version_id); +CREATE INDEX idx_document_assets_type ON document_assets(asset_type); +CREATE INDEX document_assets_tenant_id_idx ON document_assets(tenant_id); + +CREATE TABLE document_asset_objects ( + id UUID PRIMARY KEY, + asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + s3_key TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + tenant_id UUID NOT NULL REFERENCES tenants(id), + CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1), + CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal) +); + +CREATE INDEX idx_document_asset_objects_asset_ordinal + ON document_asset_objects(asset_id, ordinal); +CREATE INDEX document_asset_objects_tenant_id_idx ON document_asset_objects(tenant_id); + +CREATE TABLE jobs ( + id UUID PRIMARY KEY, + job_type TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + attempts INTEGER NOT NULL DEFAULT 0, + run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed')) +); + +CREATE INDEX idx_jobs_status_run_after ON jobs(status, run_after); +CREATE INDEX idx_jobs_job_type ON jobs(job_type); +CREATE INDEX jobs_tenant_id_idx ON jobs(tenant_id); + +CREATE OR REPLACE FUNCTION touch_jobs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_jobs_updated_at +BEFORE UPDATE ON jobs +FOR EACH ROW +EXECUTE FUNCTION touch_jobs_updated_at(); + +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); +CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens(tenant_id); + +CREATE TABLE webdav_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + token_prefix TEXT NOT NULL, + token_hash TEXT NOT NULL, + label TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix); +CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id); + +CREATE TABLE user_passkeys ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id BYTEA NOT NULL UNIQUE, + public_key BYTEA NOT NULL, + credential JSONB NOT NULL, + sign_count BIGINT NOT NULL, + transports TEXT[] NOT NULL DEFAULT '{}'::text[], + aaguid UUID, + nickname TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + revoked_by UUID, + revoked_reason TEXT +); + +CREATE INDEX user_passkeys_user_id_idx ON user_passkeys(user_id); + +CREATE TABLE webauthn_challenges ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + purpose TEXT NOT NULL, + challenge BYTEA NOT NULL, + state BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication')) +); + +CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges(user_id); +CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges(expires_at); diff --git a/backend/migrations/202510300001_rename_document_uploaded_at/down.sql b/backend/migrations/202510300001_rename_document_uploaded_at/down.sql new file mode 100644 index 0000000..9a70eb4 --- /dev/null +++ b/backend/migrations/202510300001_rename_document_uploaded_at/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + RENAME COLUMN created_at TO uploaded_at; diff --git a/backend/migrations/202510300001_rename_document_uploaded_at/up.sql b/backend/migrations/202510300001_rename_document_uploaded_at/up.sql new file mode 100644 index 0000000..7fbf576 --- /dev/null +++ b/backend/migrations/202510300001_rename_document_uploaded_at/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + RENAME COLUMN uploaded_at TO created_at; diff --git a/backend/migrations/202510310000_create_magic_tokens/down.sql b/backend/migrations/202510310000_create_magic_tokens/down.sql new file mode 100644 index 0000000..cdbb5ed --- /dev/null +++ b/backend/migrations/202510310000_create_magic_tokens/down.sql @@ -0,0 +1,2 @@ +DROP TABLE magic_tokens; +DROP TYPE magic_token_kind; diff --git a/backend/migrations/202510310000_create_magic_tokens/up.sql b/backend/migrations/202510310000_create_magic_tokens/up.sql new file mode 100644 index 0000000..736674e --- /dev/null +++ b/backend/migrations/202510310000_create_magic_tokens/up.sql @@ -0,0 +1,18 @@ +CREATE TYPE magic_token_kind AS ENUM ('email_login', 'demo_login'); + +CREATE TABLE magic_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind magic_token_kind NOT NULL, + token_hash VARCHAR NOT NULL UNIQUE, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + expires_at TIMESTAMPTZ NOT NULL, + max_uses INTEGER, + used_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + last_used_at TIMESTAMPTZ +); + +CREATE INDEX magic_tokens_token_hash_idx ON magic_tokens (token_hash); +CREATE INDEX magic_tokens_expires_at_idx ON magic_tokens (expires_at); diff --git a/backend/migrations/202510310100_split_schemas/down.sql b/backend/migrations/202510310100_split_schemas/down.sql new file mode 100644 index 0000000..4c93eda --- /dev/null +++ b/backend/migrations/202510310100_split_schemas/down.sql @@ -0,0 +1,28 @@ +-- Move tables and types back to the public schema +ALTER TABLE tenant.webdav_tokens SET SCHEMA public; +ALTER TABLE tenant.user_memberships SET SCHEMA public; +ALTER TABLE tenant.refresh_tokens SET SCHEMA public; +ALTER TABLE tenant.tags SET SCHEMA public; +ALTER TABLE tenant.document_correspondents SET SCHEMA public; +ALTER TABLE tenant.document_tags SET SCHEMA public; +ALTER TABLE tenant.document_asset_objects SET SCHEMA public; +ALTER TABLE tenant.document_assets SET SCHEMA public; +ALTER TABLE tenant.document_versions SET SCHEMA public; +ALTER TABLE tenant.documents SET SCHEMA public; +ALTER TABLE tenant.folders SET SCHEMA public; +ALTER TABLE tenant.correspondents SET SCHEMA public; + +ALTER FUNCTION shared.touch_jobs_updated_at() SET SCHEMA public; + +ALTER TABLE shared.magic_tokens SET SCHEMA public; +ALTER TABLE shared.jobs SET SCHEMA public; +ALTER TABLE shared.webauthn_challenges SET SCHEMA public; +ALTER TABLE shared.user_passkeys SET SCHEMA public; +ALTER TABLE shared.users SET SCHEMA public; +ALTER TABLE shared.tenants SET SCHEMA public; + +ALTER TYPE shared.magic_token_kind SET SCHEMA public; +ALTER TYPE shared.tenant_status SET SCHEMA public; + +DROP SCHEMA IF EXISTS tenant CASCADE; +DROP SCHEMA IF EXISTS shared CASCADE; diff --git a/backend/migrations/202510310100_split_schemas/up.sql b/backend/migrations/202510310100_split_schemas/up.sql new file mode 100644 index 0000000..5ab9471 --- /dev/null +++ b/backend/migrations/202510310100_split_schemas/up.sql @@ -0,0 +1,45 @@ +CREATE SCHEMA IF NOT EXISTS shared; +CREATE SCHEMA IF NOT EXISTS tenant; + +-- Move global types and tables into the shared schema +ALTER TYPE tenant_status SET SCHEMA shared; +ALTER TYPE magic_token_kind SET SCHEMA shared; + +ALTER TABLE tenants SET SCHEMA shared; +ALTER TABLE users SET SCHEMA shared; +ALTER TABLE user_passkeys SET SCHEMA shared; +ALTER TABLE webauthn_challenges SET SCHEMA shared; +ALTER TABLE jobs SET SCHEMA shared; +ALTER TABLE magic_tokens SET SCHEMA shared; + +ALTER FUNCTION touch_jobs_updated_at() SET SCHEMA shared; + +-- Move tenant-scoped tables into the tenant schema +ALTER TABLE correspondents SET SCHEMA tenant; +ALTER TABLE folders SET SCHEMA tenant; +ALTER TABLE documents SET SCHEMA tenant; +ALTER TABLE document_versions SET SCHEMA tenant; +ALTER TABLE document_assets SET SCHEMA tenant; +ALTER TABLE document_asset_objects SET SCHEMA tenant; +ALTER TABLE document_tags SET SCHEMA tenant; +ALTER TABLE document_correspondents SET SCHEMA tenant; +ALTER TABLE tags SET SCHEMA tenant; +ALTER TABLE refresh_tokens SET SCHEMA tenant; +ALTER TABLE user_memberships SET SCHEMA tenant; +ALTER TABLE webdav_tokens SET SCHEMA tenant; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN + RETURN; + END IF; + + GRANT USAGE ON SCHEMA shared TO papercrate_app; + GRANT USAGE ON SCHEMA tenant TO papercrate_app; + GRANT SELECT ON ALL TABLES IN SCHEMA shared TO papercrate_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tenant TO papercrate_app; + + ALTER DEFAULT PRIVILEGES IN SCHEMA shared GRANT SELECT ON TABLES TO papercrate_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA tenant GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO papercrate_app; +END +$$; diff --git a/backend/migrations/202510310200_enable_rls/down.sql b/backend/migrations/202510310200_enable_rls/down.sql new file mode 100644 index 0000000..be3675d --- /dev/null +++ b/backend/migrations/202510310200_enable_rls/down.sql @@ -0,0 +1,51 @@ +DROP POLICY IF EXISTS tenant_membership_select_policy ON tenant.user_memberships; +ALTER TABLE tenant.user_memberships NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.user_memberships DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.webdav_tokens; +ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.tags; +ALTER TABLE tenant.tags NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.tags DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_refresh_token_policy ON tenant.refresh_tokens; +ALTER TABLE tenant.refresh_tokens NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.refresh_tokens DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.folders; +ALTER TABLE tenant.folders NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.folders DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.documents; +ALTER TABLE tenant.documents NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.documents DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_versions; +ALTER TABLE tenant.document_versions NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_versions DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_tags; +ALTER TABLE tenant.document_tags NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_tags DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_correspondents; +ALTER TABLE tenant.document_correspondents NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_correspondents DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_assets; +ALTER TABLE tenant.document_assets NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_assets DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_asset_objects; +ALTER TABLE tenant.document_asset_objects NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_asset_objects DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.correspondents; +ALTER TABLE tenant.correspondents NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.correspondents DISABLE ROW LEVEL SECURITY; + +DROP FUNCTION IF EXISTS shared.current_refresh_token_hash(); +DROP FUNCTION IF EXISTS shared.current_user_id(); +DROP FUNCTION IF EXISTS shared.current_tenant_id(); diff --git a/backend/migrations/202510310200_enable_rls/up.sql b/backend/migrations/202510310200_enable_rls/up.sql new file mode 100644 index 0000000..2a7550f --- /dev/null +++ b/backend/migrations/202510310200_enable_rls/up.sql @@ -0,0 +1,69 @@ +CREATE OR REPLACE FUNCTION shared.current_tenant_id() RETURNS uuid AS $$ + SELECT CASE + WHEN setting IS NULL OR setting = '' THEN NULL + ELSE setting::uuid + END + FROM (SELECT current_setting('papercrate.tenant_id', true) AS setting) s; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION shared.current_user_id() RETURNS uuid AS $$ + SELECT CASE + WHEN setting IS NULL OR setting = '' THEN NULL + ELSE setting::uuid + END + FROM (SELECT current_setting('papercrate.user_id', true) AS setting) s; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '') +$$ LANGUAGE SQL STABLE; + +-- Helper to create tenant isolation policy +CREATE OR REPLACE FUNCTION shared.ensure_tenant_policy(table_reg regclass) RETURNS void AS $$ +BEGIN + EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', table_reg); + EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', table_reg); + EXECUTE format( + 'CREATE POLICY tenant_isolation_policy ON %s USING (tenant_id = shared.current_tenant_id()) WITH CHECK (tenant_id = shared.current_tenant_id())', + table_reg + ); +END; +$$ LANGUAGE plpgsql; + +SELECT shared.ensure_tenant_policy('tenant.correspondents'); +SELECT shared.ensure_tenant_policy('tenant.document_asset_objects'); +SELECT shared.ensure_tenant_policy('tenant.document_assets'); +SELECT shared.ensure_tenant_policy('tenant.document_correspondents'); +SELECT shared.ensure_tenant_policy('tenant.document_tags'); +SELECT shared.ensure_tenant_policy('tenant.document_versions'); +SELECT shared.ensure_tenant_policy('tenant.documents'); +SELECT shared.ensure_tenant_policy('tenant.folders'); +SELECT shared.ensure_tenant_policy('tenant.tags'); +SELECT shared.ensure_tenant_policy('tenant.webdav_tokens'); + +-- user_memberships has a special read policy to allow tenant discovery during login +ALTER TABLE tenant.user_memberships ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.user_memberships FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_membership_select_policy ON tenant.user_memberships + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_user_id() IS NOT NULL + AND user_id = shared.current_user_id() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); + +ALTER TABLE tenant.refresh_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.refresh_tokens FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_refresh_token_policy ON tenant.refresh_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_refresh_token_hash() IS NOT NULL + AND token_hash = shared.current_refresh_token_hash() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); + +DROP FUNCTION shared.ensure_tenant_policy(regclass); diff --git a/backend/migrations/202510310201_enable_webdav_token_rls/down.sql b/backend/migrations/202510310201_enable_webdav_token_rls/down.sql new file mode 100644 index 0000000..56e8426 --- /dev/null +++ b/backend/migrations/202510310201_enable_webdav_token_rls/down.sql @@ -0,0 +1,5 @@ +DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.webdav_tokens; +ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY; + +DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix(); diff --git a/backend/migrations/202510310201_enable_webdav_token_rls/up.sql b/backend/migrations/202510310201_enable_webdav_token_rls/up.sql new file mode 100644 index 0000000..290ff75 --- /dev/null +++ b/backend/migrations/202510310201_enable_webdav_token_rls/up.sql @@ -0,0 +1,15 @@ +CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_webdav_token_prefix() IS NOT NULL + AND token_prefix = shared.current_webdav_token_prefix() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); diff --git a/backend/migrations/202511010000_api_tokens/down.sql b/backend/migrations/202511010000_api_tokens/down.sql new file mode 100644 index 0000000..04636f2 --- /dev/null +++ b/backend/migrations/202511010000_api_tokens/down.sql @@ -0,0 +1,31 @@ +DROP POLICY IF EXISTS tenant_api_token_policy ON tenant.api_tokens; +DROP FUNCTION IF EXISTS shared.current_api_token_prefix(); + +ALTER TABLE tenant.api_tokens DISABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.api_tokens NO FORCE ROW LEVEL SECURITY; + +ALTER TABLE tenant.api_tokens + DROP COLUMN IF EXISTS capabilities; + +DROP TYPE IF EXISTS shared.api_token_capability; + +ALTER TABLE tenant.api_tokens RENAME TO webdav_tokens; +ALTER INDEX tenant.api_tokens_token_prefix_key RENAME TO webdav_tokens_token_prefix_key; +ALTER INDEX tenant.api_tokens_user_tenant_idx RENAME TO webdav_tokens_user_tenant_idx; + +CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY; + +CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_webdav_token_prefix() IS NOT NULL + AND token_prefix = shared.current_webdav_token_prefix() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); diff --git a/backend/migrations/202511010000_api_tokens/up.sql b/backend/migrations/202511010000_api_tokens/up.sql new file mode 100644 index 0000000..66d09fb --- /dev/null +++ b/backend/migrations/202511010000_api_tokens/up.sql @@ -0,0 +1,28 @@ +ALTER TABLE tenant.webdav_tokens RENAME TO api_tokens; +ALTER INDEX tenant.webdav_tokens_token_prefix_key RENAME TO api_tokens_token_prefix_key; +ALTER INDEX tenant.webdav_tokens_user_tenant_idx RENAME TO api_tokens_user_tenant_idx; + +DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.api_tokens; +DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix(); + +CREATE TYPE shared.api_token_capability AS ENUM ('api', 'webdav'); + +ALTER TABLE tenant.api_tokens + ADD COLUMN capabilities shared.api_token_capability[] NOT NULL DEFAULT ARRAY['webdav']::shared.api_token_capability[]; + +ALTER TABLE tenant.api_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.api_tokens FORCE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION shared.current_api_token_prefix() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.api_token_prefix', true), '') +$$ LANGUAGE SQL STABLE; + +CREATE POLICY tenant_api_token_policy ON tenant.api_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_api_token_prefix() IS NOT NULL + AND token_prefix = shared.current_api_token_prefix() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); diff --git a/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql new file mode 100644 index 0000000..5a16912 --- /dev/null +++ b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql @@ -0,0 +1,26 @@ +CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_refresh_token_hash() IS NOT NULL + AND token_hash = shared.current_refresh_token_hash() + ) + ); + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + WITH CHECK (tenant_id = shared.current_tenant_id()); + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + RENAME TO tenant_refresh_token_policy; + +ALTER INDEX tenant.idx_user_sessions_user_id RENAME TO idx_refresh_tokens_user_id; +ALTER INDEX tenant.idx_user_sessions_token_hash RENAME TO idx_refresh_tokens_token_hash; +ALTER INDEX tenant.user_sessions_tenant_id_idx RENAME TO refresh_tokens_tenant_id_idx; + +ALTER TABLE tenant.user_sessions RENAME TO refresh_tokens; + +DROP FUNCTION IF EXISTS shared.current_user_session_hash(); diff --git a/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql new file mode 100644 index 0000000..a6bb642 --- /dev/null +++ b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql @@ -0,0 +1,26 @@ +ALTER TABLE tenant.refresh_tokens RENAME TO user_sessions; + +ALTER INDEX tenant.idx_refresh_tokens_user_id RENAME TO idx_user_sessions_user_id; +ALTER INDEX tenant.idx_refresh_tokens_token_hash RENAME TO idx_user_sessions_token_hash; +ALTER INDEX tenant.refresh_tokens_tenant_id_idx RENAME TO user_sessions_tenant_id_idx; + +ALTER POLICY tenant_refresh_token_policy ON tenant.user_sessions + RENAME TO tenant_user_session_policy; + +CREATE OR REPLACE FUNCTION shared.current_user_session_hash() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.user_session_hash', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_user_session_hash() IS NOT NULL + AND token_hash = shared.current_user_session_hash() + ) + ); + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + WITH CHECK (tenant_id = shared.current_tenant_id()); + +DROP FUNCTION IF EXISTS shared.current_refresh_token_hash(); diff --git a/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql new file mode 100644 index 0000000..58f08d3 --- /dev/null +++ b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql @@ -0,0 +1,13 @@ +ALTER TABLE tenant.document_tags + DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey, + ADD CONSTRAINT document_tags_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE NO ACTION; + +ALTER TABLE tenant.document_correspondents + DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey, + ADD CONSTRAINT document_correspondents_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE NO ACTION; diff --git a/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql new file mode 100644 index 0000000..cbdf1db --- /dev/null +++ b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE tenant.document_tags + DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey, + ADD CONSTRAINT document_tags_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE SET NULL; + +ALTER TABLE tenant.document_correspondents + DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey, + ADD CONSTRAINT document_correspondents_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE SET NULL; diff --git a/backend/migrations/202511010003_capability_sets/down.sql b/backend/migrations/202511010003_capability_sets/down.sql new file mode 100644 index 0000000..d20033c --- /dev/null +++ b/backend/migrations/202511010003_capability_sets/down.sql @@ -0,0 +1,28 @@ +CREATE TYPE api_token_capability AS ENUM ('api', 'webdav'); + +ALTER TABLE tenant.api_tokens + ADD COLUMN capabilities api_token_capability[] NOT NULL DEFAULT ARRAY[]::api_token_capability[]; + +UPDATE tenant.api_tokens t +SET capabilities = ARRAY['api']::api_token_capability[] +FROM tenant.capability_sets cs +WHERE t.capability_set_id = cs.id + AND cs.slug = 'owner'; + +UPDATE tenant.api_tokens t +SET capabilities = ARRAY['webdav']::api_token_capability[] +FROM tenant.capability_sets cs +WHERE t.capability_set_id = cs.id + AND cs.slug = 'webdav' + AND (t.capabilities IS NULL OR array_length(t.capabilities, 1) = 0); + +ALTER TABLE tenant.api_tokens + DROP COLUMN capability_set_id; + +ALTER TABLE tenant.user_memberships + DROP COLUMN capability_set_id; + +DROP TABLE IF EXISTS tenant.capability_set_capabilities; +DROP TABLE IF EXISTS tenant.capability_sets; + +DROP TYPE IF EXISTS api_capability; diff --git a/backend/migrations/202511010003_capability_sets/up.sql b/backend/migrations/202511010003_capability_sets/up.sql new file mode 100644 index 0000000..f3bb977 --- /dev/null +++ b/backend/migrations/202511010003_capability_sets/up.sql @@ -0,0 +1,158 @@ +CREATE TYPE api_capability AS ENUM ( + 'documents:read', + 'documents:edit', + 'documents:write', + 'documents:upload', + 'folders:read', + 'folders:edit', + 'folders:write', + 'tags:read', + 'tags:edit', + 'tags:write', + 'correspondents:read', + 'correspondents:edit', + 'correspondents:write', + 'profile:read', + 'profile:write', + 'webdav:read', + 'webdav:write', + 'capability_sets:read', + 'capability_sets:write' +); + +CREATE TABLE tenant.capability_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + cap_version INT NOT NULL DEFAULT 1, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, slug) +); + +CREATE TABLE tenant.capability_set_capabilities ( + capability_set_id UUID NOT NULL REFERENCES tenant.capability_sets(id) ON DELETE CASCADE, + capability api_capability NOT NULL, + PRIMARY KEY (capability_set_id, capability) +); + +ALTER TABLE tenant.api_tokens + ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id); + +ALTER TABLE tenant.user_memberships + ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id); + +WITH owner_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'owner', TRUE + FROM shared.tenants + RETURNING id, tenant_id +), +user_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'user', TRUE + FROM shared.tenants + RETURNING id, tenant_id +), +readonly_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'readonly', TRUE + FROM shared.tenants + RETURNING id, tenant_id +), +webdav_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'webdav', TRUE + FROM shared.tenants + RETURNING id, tenant_id +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT set_id, + capability +FROM ( + SELECT os.id AS set_id, + UNNEST(ARRAY[ + 'documents:read'::api_capability, + 'documents:edit'::api_capability, + 'documents:write'::api_capability, + 'documents:upload'::api_capability, + 'folders:read'::api_capability, + 'folders:edit'::api_capability, + 'folders:write'::api_capability, + 'tags:read'::api_capability, + 'tags:edit'::api_capability, + 'tags:write'::api_capability, + 'correspondents:read'::api_capability, + 'correspondents:edit'::api_capability, + 'correspondents:write'::api_capability, + 'profile:read'::api_capability, + 'profile:write'::api_capability, + 'webdav:read'::api_capability, + 'webdav:write'::api_capability, + 'capability_sets:read'::api_capability, + 'capability_sets:write'::api_capability + ]) AS capability + FROM owner_sets os + UNION ALL + SELECT us.id, + UNNEST(ARRAY[ + 'documents:read'::api_capability, + 'documents:edit'::api_capability, + 'documents:write'::api_capability, + 'documents:upload'::api_capability, + 'folders:read'::api_capability, + 'folders:edit'::api_capability, + 'folders:write'::api_capability, + 'tags:read'::api_capability, + 'tags:edit'::api_capability, + 'tags:write'::api_capability, + 'correspondents:read'::api_capability, + 'correspondents:edit'::api_capability, + 'correspondents:write'::api_capability, + 'profile:read'::api_capability, + 'profile:write'::api_capability + ]) AS capability + FROM user_sets us + UNION ALL + SELECT rs.id, + UNNEST(ARRAY[ + 'documents:read'::api_capability, + 'folders:read'::api_capability, + 'tags:read'::api_capability, + 'correspondents:read'::api_capability, + 'webdav:read'::api_capability + ]) AS capability + FROM readonly_sets rs + UNION ALL + SELECT ws.id, + UNNEST(ARRAY['webdav:read'::api_capability]) AS capability + FROM webdav_sets ws +) seeded; + +UPDATE tenant.user_memberships um +SET capability_set_id = cs.id +FROM tenant.capability_sets cs +WHERE cs.tenant_id = um.tenant_id + AND cs.slug = 'owner'; + +UPDATE tenant.api_tokens t +SET capability_set_id = cs.id +FROM tenant.capability_sets cs +WHERE cs.tenant_id = t.tenant_id + AND cs.slug = 'owner'; + +UPDATE tenant.api_tokens t +SET capability_set_id = cs.id +FROM tenant.capability_sets cs +WHERE cs.tenant_id = t.tenant_id + AND cs.slug = 'webdav' + AND t.capability_set_id IS NULL; + +ALTER TABLE tenant.api_tokens + ALTER COLUMN capability_set_id SET NOT NULL; + +ALTER TABLE tenant.api_tokens + DROP COLUMN capabilities; + +DROP TYPE IF EXISTS api_token_capability; diff --git a/backend/migrations/202511010004_purge_document_jobs/down.sql b/backend/migrations/202511010004_purge_document_jobs/down.sql new file mode 100644 index 0000000..0bba65c --- /dev/null +++ b/backend/migrations/202511010004_purge_document_jobs/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique; diff --git a/backend/migrations/202511010004_purge_document_jobs/up.sql b/backend/migrations/202511010004_purge_document_jobs/up.sql new file mode 100644 index 0000000..240b72c --- /dev/null +++ b/backend/migrations/202511010004_purge_document_jobs/up.sql @@ -0,0 +1,8 @@ +CREATE UNIQUE INDEX jobs_purge_document_pending_unique + ON shared.jobs ( + tenant_id, + ((payload ->> 'document_id')::uuid) + ) + WHERE job_type = 'purge-document' + AND payload ? 'document_id' + AND status IN ('queued', 'processing'); diff --git a/backend/migrations/202511010005_documents_ordering_indexes/down.sql b/backend/migrations/202511010005_documents_ordering_indexes/down.sql new file mode 100644 index 0000000..d357cf1 --- /dev/null +++ b/backend/migrations/202511010005_documents_ordering_indexes/down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_updated_at; +DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_created_at; +DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_issued_at; +DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_title_order; +DROP COLLATION IF EXISTS unicode_ci; diff --git a/backend/migrations/202511010005_documents_ordering_indexes/up.sql b/backend/migrations/202511010005_documents_ordering_indexes/up.sql new file mode 100644 index 0000000..2f936f2 --- /dev/null +++ b/backend/migrations/202511010005_documents_ordering_indexes/up.sql @@ -0,0 +1,37 @@ +CREATE COLLATION IF NOT EXISTS unicode_ci + (provider = icu, locale = 'und-u-ks-level2'); + +CREATE INDEX idx_documents_tenant_folder_title_order + ON tenant.documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + title COLLATE "unicode_ci" + ) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_documents_tenant_folder_issued_at + ON tenant.documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + issued_at, + title COLLATE "unicode_ci" + ) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_documents_tenant_folder_created_at + ON tenant.documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + created_at, + title COLLATE "unicode_ci" + ) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_documents_tenant_folder_updated_at + ON tenant.documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + updated_at, + title COLLATE "unicode_ci" + ) + WHERE deleted_at IS NULL; diff --git a/backend/migrations/202511110000_make_jobs_tenant_nullable/down.sql b/backend/migrations/202511110000_make_jobs_tenant_nullable/down.sql new file mode 100644 index 0000000..8172ced --- /dev/null +++ b/backend/migrations/202511110000_make_jobs_tenant_nullable/down.sql @@ -0,0 +1,13 @@ +ALTER TABLE shared.jobs + DROP CONSTRAINT jobs_tenant_id_fkey; + +ALTER TABLE shared.jobs + ALTER COLUMN tenant_id SET NOT NULL; + +ALTER TABLE shared.jobs + ADD CONSTRAINT jobs_tenant_id_fkey + FOREIGN KEY (tenant_id) + REFERENCES shared.tenants(id); + +ALTER TABLE shared.jobs + DROP COLUMN result; diff --git a/backend/migrations/202511110000_make_jobs_tenant_nullable/up.sql b/backend/migrations/202511110000_make_jobs_tenant_nullable/up.sql new file mode 100644 index 0000000..86be1e8 --- /dev/null +++ b/backend/migrations/202511110000_make_jobs_tenant_nullable/up.sql @@ -0,0 +1,14 @@ +ALTER TABLE shared.jobs + ALTER COLUMN tenant_id DROP NOT NULL; + +ALTER TABLE shared.jobs + DROP CONSTRAINT jobs_tenant_id_fkey; + +ALTER TABLE shared.jobs + ADD CONSTRAINT jobs_tenant_id_fkey + FOREIGN KEY (tenant_id) + REFERENCES shared.tenants(id) + ON DELETE SET NULL; + +ALTER TABLE shared.jobs + ADD COLUMN result JSONB; diff --git a/backend/migrations/202511110100_add_tenant_capability_enum/down.sql b/backend/migrations/202511110100_add_tenant_capability_enum/down.sql new file mode 100644 index 0000000..c7d48e0 --- /dev/null +++ b/backend/migrations/202511110100_add_tenant_capability_enum/down.sql @@ -0,0 +1,4 @@ +-- diesel:run_in_transaction = false + +-- Enum values cannot be removed safely; this down migration intentionally left empty. +SELECT 1; diff --git a/backend/migrations/202511110100_add_tenant_capability_enum/up.sql b/backend/migrations/202511110100_add_tenant_capability_enum/up.sql new file mode 100644 index 0000000..0063e29 --- /dev/null +++ b/backend/migrations/202511110100_add_tenant_capability_enum/up.sql @@ -0,0 +1,46 @@ +-- diesel:run_in_transaction = false + +DO $$ +BEGIN + BEGIN + EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:write'''; + EXCEPTION + WHEN undefined_object THEN + BEGIN + EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:write'''; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + WHEN duplicate_object THEN NULL; + END; +END $$; + +DO $$ +BEGIN + BEGIN + EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:reset'''; + EXCEPTION + WHEN undefined_object THEN + BEGIN + EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:reset'''; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + WHEN duplicate_object THEN NULL; + END; +END $$; + +DO $$ +BEGIN + BEGIN + EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:delete'''; + EXCEPTION + WHEN undefined_object THEN + BEGIN + EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:delete'''; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + WHEN duplicate_object THEN NULL; + END; +END $$; diff --git a/backend/migrations/202511110200_owner_tenant_capabilities/down.sql b/backend/migrations/202511110200_owner_tenant_capabilities/down.sql new file mode 100644 index 0000000..ed3e808 --- /dev/null +++ b/backend/migrations/202511110200_owner_tenant_capabilities/down.sql @@ -0,0 +1,9 @@ +-- diesel:run_in_transaction = false + +DELETE FROM tenant.capability_set_capabilities +WHERE capability IN ( + 'tenants:write'::api_capability, + 'tenants:reset'::api_capability, + 'tenants:delete'::api_capability + ) + AND capability_set_id IN (SELECT id FROM tenant.capability_sets WHERE slug = 'owner'); diff --git a/backend/migrations/202511110200_owner_tenant_capabilities/up.sql b/backend/migrations/202511110200_owner_tenant_capabilities/up.sql new file mode 100644 index 0000000..24cabab --- /dev/null +++ b/backend/migrations/202511110200_owner_tenant_capabilities/up.sql @@ -0,0 +1,22 @@ +-- diesel:run_in_transaction = false + +WITH owner_sets AS ( + SELECT id FROM tenant.capability_sets WHERE slug = 'owner' +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT id, 'tenants:write'::api_capability FROM owner_sets +ON CONFLICT DO NOTHING; + +WITH owner_sets AS ( + SELECT id FROM tenant.capability_sets WHERE slug = 'owner' +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT id, 'tenants:reset'::api_capability FROM owner_sets +ON CONFLICT DO NOTHING; + +WITH owner_sets AS ( + SELECT id FROM tenant.capability_sets WHERE slug = 'owner' +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT id, 'tenants:delete'::api_capability FROM owner_sets +ON CONFLICT DO NOTHING; diff --git a/backend/migrations/202511120000_collapse_asset_objects/down.sql b/backend/migrations/202511120000_collapse_asset_objects/down.sql new file mode 100644 index 0000000..6c3a430 --- /dev/null +++ b/backend/migrations/202511120000_collapse_asset_objects/down.sql @@ -0,0 +1,24 @@ +CREATE TABLE tenant.document_asset_objects ( + id UUID PRIMARY KEY, + asset_id UUID NOT NULL REFERENCES tenant.document_assets(id) ON DELETE CASCADE, + ordinal INT NOT NULL, + s3_key TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + tenant_id UUID NOT NULL REFERENCES shared.tenants(id), + CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1), + CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal) +); + +CREATE INDEX idx_document_asset_objects_asset_ordinal + ON tenant.document_asset_objects(asset_id, ordinal); + +CREATE INDEX document_asset_objects_tenant_id_idx ON tenant.document_asset_objects(tenant_id); + +ALTER TABLE tenant.document_assets ADD COLUMN cardinality INT; +UPDATE tenant.document_assets SET cardinality = 1; + +INSERT INTO tenant.document_asset_objects (id, asset_id, ordinal, s3_key, metadata, tenant_id) +SELECT gen_random_uuid(), id, 1, s3_key, metadata, tenant_id +FROM tenant.document_assets; + +ALTER TABLE tenant.document_assets DROP COLUMN s3_key; diff --git a/backend/migrations/202511120000_collapse_asset_objects/up.sql b/backend/migrations/202511120000_collapse_asset_objects/up.sql new file mode 100644 index 0000000..cf26ae4 --- /dev/null +++ b/backend/migrations/202511120000_collapse_asset_objects/up.sql @@ -0,0 +1,27 @@ +-- Prevent concurrent inserts/updates during backfill. +LOCK TABLE tenant.document_asset_objects IN ACCESS EXCLUSIVE MODE; +LOCK TABLE tenant.document_assets IN ACCESS EXCLUSIVE MODE; + +ALTER TABLE tenant.document_assets ADD COLUMN s3_key TEXT; + +UPDATE tenant.document_assets AS da +SET s3_key = o.s3_key, + metadata = COALESCE(da.metadata, '{}'::jsonb) || COALESCE(o.metadata, '{}'::jsonb) +FROM tenant.document_asset_objects AS o +WHERE o.asset_id = da.id + AND o.ordinal = 1; + +DELETE FROM tenant.document_asset_objects WHERE ordinal <> 1; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tenant.document_assets WHERE s3_key IS NULL) THEN + RAISE EXCEPTION 'cannot drop document_asset_objects: some assets are missing a populated ordinal 1 object (s3_key null)'; + END IF; +END +$$; + +ALTER TABLE tenant.document_assets ALTER COLUMN s3_key SET NOT NULL; +ALTER TABLE tenant.document_assets DROP COLUMN cardinality; + +DROP TABLE tenant.document_asset_objects; diff --git a/backend/migrations/202511130000_rename_document_content_type/down.sql b/backend/migrations/202511130000_rename_document_content_type/down.sql new file mode 100644 index 0000000..09336bc --- /dev/null +++ b/backend/migrations/202511130000_rename_document_content_type/down.sql @@ -0,0 +1,3 @@ +-- Revert column rename. +ALTER TABLE tenant.documents + RENAME COLUMN mime_type TO content_type; diff --git a/backend/migrations/202511130000_rename_document_content_type/up.sql b/backend/migrations/202511130000_rename_document_content_type/up.sql new file mode 100644 index 0000000..7d84272 --- /dev/null +++ b/backend/migrations/202511130000_rename_document_content_type/up.sql @@ -0,0 +1,3 @@ +-- Rename document content_type column to mime_type for consistency with API. +ALTER TABLE tenant.documents + RENAME COLUMN content_type TO mime_type; diff --git a/backend/migrations/202511300000_rename_ocr_text_asset/down.sql b/backend/migrations/202511300000_rename_ocr_text_asset/down.sql new file mode 100644 index 0000000..9c89d15 --- /dev/null +++ b/backend/migrations/202511300000_rename_ocr_text_asset/down.sql @@ -0,0 +1,3 @@ +UPDATE tenant.document_assets +SET asset_type = 'ocr-text' +WHERE asset_type = 'text-content'; diff --git a/backend/migrations/202511300000_rename_ocr_text_asset/up.sql b/backend/migrations/202511300000_rename_ocr_text_asset/up.sql new file mode 100644 index 0000000..4a7bf6e --- /dev/null +++ b/backend/migrations/202511300000_rename_ocr_text_asset/up.sql @@ -0,0 +1,3 @@ +UPDATE tenant.document_assets +SET asset_type = 'text-content' +WHERE asset_type = 'ocr-text'; diff --git a/backend/migrations/202511300100_add_title_trgm_index/down.sql b/backend/migrations/202511300100_add_title_trgm_index/down.sql new file mode 100644 index 0000000..ab856cb --- /dev/null +++ b/backend/migrations/202511300100_add_title_trgm_index/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS tenant.idx_documents_title_trgm; diff --git a/backend/migrations/202511300100_add_title_trgm_index/up.sql b/backend/migrations/202511300100_add_title_trgm_index/up.sql new file mode 100644 index 0000000..ff1f0a6 --- /dev/null +++ b/backend/migrations/202511300100_add_title_trgm_index/up.sql @@ -0,0 +1,6 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX idx_documents_title_trgm + ON tenant.documents + USING gin (title gin_trgm_ops) + WHERE deleted_at IS NULL; diff --git a/backend/postgres-init/01_create_app_roles.sql b/backend/postgres-init/01_create_app_roles.sql new file mode 100644 index 0000000..c6a8202 --- /dev/null +++ b/backend/postgres-init/01_create_app_roles.sql @@ -0,0 +1,21 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN + CREATE ROLE papercrate_app NOLOGIN; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app_login') THEN + CREATE ROLE papercrate_app_login LOGIN PASSWORD 'papercrate_app'; + GRANT papercrate_app TO papercrate_app_login; + END IF; +END +$$; + +-- Ensure the login role inherits and uses a sensible search path by default +ALTER ROLE papercrate_app_login INHERIT; +ALTER ROLE papercrate_app_login SET search_path = 'tenant, shared, public'; + +GRANT CONNECT ON DATABASE papercrate TO papercrate_app; +GRANT CONNECT ON DATABASE papercrate TO papercrate_app_login; +GRANT USAGE ON SCHEMA public TO papercrate_app; +GRANT USAGE ON SCHEMA public TO papercrate_app_login; diff --git a/backend/resources/issued_at_months.yaml b/backend/resources/issued_at_months.yaml new file mode 100644 index 0000000..28c727d --- /dev/null +++ b/backend/resources/issued_at_months.yaml @@ -0,0 +1,648 @@ +months: + - name: "january" + month: 1 + locales: + - "en" + - name: "jan" + month: 1 + locales: + - "en" + - name: "janvier" + month: 1 + locales: + - "fr" + - name: "janv" + month: 1 + locales: + - "fr" + - name: "januar" + month: 1 + locales: + - "de" + - name: "janu\u00e1r" + month: 1 + locales: + - "hu" + - name: "leden" + month: 1 + locales: + - "cs" + - name: "sije\u010danj" + month: 1 + locales: + - "hr" + - name: "sijecanj" + month: 1 + locales: + - "hr" + - name: "stycze\u0144" + month: 1 + locales: + - "pl" + - name: "styczen" + month: 1 + locales: + - "pl" + - name: "ocak" + month: 1 + locales: + - "tr" + - name: "february" + month: 2 + locales: + - "en" + - name: "feb" + month: 2 + locales: + - "en" + - name: "f\u00e9vrier" + month: 2 + locales: + - "fr" + - name: "fevrier" + month: 2 + locales: + - "fr" + - name: "f\u00e9vr" + month: 2 + locales: + - "fr" + - name: "fevr" + month: 2 + locales: + - "fr" + - name: "februar" + month: 2 + locales: + - "de" + - name: "\u00fanor" + month: 2 + locales: + - "cs" + - name: "unor" + month: 2 + locales: + - "cs" + - name: "\u00fanora" + month: 2 + locales: + - "cs" + - name: "unora" + month: 2 + locales: + - "cs" + - name: "velja\u010da" + month: 2 + locales: + - "hr" + - name: "veljaca" + month: 2 + locales: + - "hr" + - name: "velja\u010de" + month: 2 + locales: + - "hr" + - name: "veljace" + month: 2 + locales: + - "hr" + - name: "luty" + month: 2 + locales: + - "pl" + - name: "\u015fubat" + month: 2 + locales: + - "tr" + - name: "subat" + month: 2 + locales: + - "tr" + - name: "march" + month: 3 + locales: + - "en" + - name: "mar" + month: 3 + locales: + - "en" + - name: "m\u00e4rz" + month: 3 + locales: + - "de" + - name: "maerz" + month: 3 + locales: + - "de" + - name: "mars" + month: 3 + locales: + - "fr" + - name: "m\u00e4r" + month: 3 + locales: + - "de" + - name: "marz" + month: 3 + locales: + - "de" + - name: "b\u0159ezen" + month: 3 + locales: + - "cs" + - name: "brezen" + month: 3 + locales: + - "cs" + - name: "b\u0159ezna" + month: 3 + locales: + - "cs" + - name: "brezna" + month: 3 + locales: + - "cs" + - name: "o\u017eujak" + month: 3 + locales: + - "hr" + - name: "ozujak" + month: 3 + locales: + - "hr" + - name: "o\u017eujka" + month: 3 + locales: + - "hr" + - name: "ozujka" + month: 3 + locales: + - "hr" + - name: "marzec" + month: 3 + locales: + - "pl" + - name: "mart" + month: 3 + locales: + - "sr" + - "bs" + - name: "m\u00e1rcius" + month: 3 + locales: + - "hu" + - name: "martie" + month: 3 + locales: + - "ro" + - name: "april" + month: 4 + locales: + - "en" + - name: "apr" + month: 4 + locales: + - "en" + - name: "avril" + month: 4 + locales: + - "fr" + - name: "abril" + month: 4 + locales: + - "es" + - "pt" + - name: "duben" + month: 4 + locales: + - "cs" + - name: "travanj" + month: 4 + locales: + - "hr" + - name: "nisan" + month: 4 + locales: + - "tr" + - name: "kwiecie\u0144" + month: 4 + locales: + - "pl" + - name: "kwiecien" + month: 4 + locales: + - "pl" + - name: "aprile" + month: 4 + locales: + - "it" + - name: "may" + month: 5 + locales: + - "en" + - name: "mai" + month: 5 + locales: + - "fr" + - "de" + - name: "mayo" + month: 5 + locales: + - "es" + - name: "kv\u011bten" + month: 5 + locales: + - "cs" + - name: "kveten" + month: 5 + locales: + - "cs" + - name: "kv\u011btna" + month: 5 + locales: + - "cs" + - name: "kvetna" + month: 5 + locales: + - "cs" + - name: "svibanj" + month: 5 + locales: + - "hr" + - name: "maj" + month: 5 + locales: + - "pl" + - "bs" + - "sr" + - "hr" + - name: "may\u0131s" + month: 5 + locales: + - "tr" + - name: "mayis" + month: 5 + locales: + - "tr" + - name: "maggio" + month: 5 + locales: + - "it" + - name: "m\u00e1j" + month: 5 + locales: + - "hu" + - "sk" + - name: "june" + month: 6 + locales: + - "en" + - name: "jun" + month: 6 + locales: + - "en" + - "de" + - name: "juin" + month: 6 + locales: + - "fr" + - name: "junio" + month: 6 + locales: + - "es" + - name: "juni" + month: 6 + locales: + - "de" + - name: "\u010derven" + month: 6 + locales: + - "cs" + - name: "cerven" + month: 6 + locales: + - "cs" + - name: "lipanj" + month: 6 + locales: + - "hr" + - name: "haziran" + month: 6 + locales: + - "tr" + - name: "czerwiec" + month: 6 + locales: + - "pl" + - name: "j\u00fanius" + month: 6 + locales: + - "hu" + - name: "giugno" + month: 6 + locales: + - "it" + - name: "july" + month: 7 + locales: + - "en" + - name: "jul" + month: 7 + locales: + - "en" + - "de" + - name: "juillet" + month: 7 + locales: + - "fr" + - name: "julio" + month: 7 + locales: + - "es" + - name: "temmuz" + month: 7 + locales: + - "tr" + - name: "\u010dervenec" + month: 7 + locales: + - "cs" + - name: "cervenec" + month: 7 + locales: + - "cs" + - name: "srpanj" + month: 7 + locales: + - "hr" + - name: "lipiec" + month: 7 + locales: + - "pl" + - name: "luglio" + month: 7 + locales: + - "it" + - name: "j\u00falius" + month: 7 + locales: + - "hu" + - name: "august" + month: 8 + locales: + - "en" + - name: "aug" + month: 8 + locales: + - "en" + - "de" + - name: "ao\u00fbt" + month: 8 + locales: + - "fr" + - name: "aout" + month: 8 + locales: + - "fr" + - name: "agosto" + month: 8 + locales: + - "es" + - "pt" + - "it" + - name: "a\u011fustos" + month: 8 + locales: + - "tr" + - name: "agustos" + month: 8 + locales: + - "tr" + - name: "kolovoz" + month: 8 + locales: + - "hr" + - name: "srpen" + month: 8 + locales: + - "cs" + - name: "sierpie\u0144" + month: 8 + locales: + - "pl" + - name: "sierpien" + month: 8 + locales: + - "pl" + - name: "augustus" + month: 8 + locales: + - "nl" + - name: "agost" + month: 8 + locales: + - "it" + - "ca" + - name: "september" + month: 9 + locales: + - "en" + - name: "sept" + month: 9 + locales: + - "en" + - "fr" + - name: "sep" + month: 9 + locales: + - "en" + - name: "septembre" + month: 9 + locales: + - "fr" + - name: "septiembre" + month: 9 + locales: + - "es" + - name: "eyl\u00fcl" + month: 9 + locales: + - "tr" + - name: "eylul" + month: 9 + locales: + - "tr" + - name: "z\u00e1\u0159\u00ed" + month: 9 + locales: + - "cs" + - name: "zari" + month: 9 + locales: + - "cs" + - name: "rujan" + month: 9 + locales: + - "hr" + - name: "wrzesie\u0144" + month: 9 + locales: + - "pl" + - name: "wrzesien" + month: 9 + locales: + - "pl" + - name: "septembrie" + month: 9 + locales: + - "ro" + - name: "settembre" + month: 9 + locales: + - "it" + - name: "october" + month: 10 + locales: + - "en" + - name: "oct" + month: 10 + locales: + - "en" + - name: "oktober" + month: 10 + locales: + - "de" + - name: "okt\u00f3ber" + month: 10 + locales: + - "hu" + - name: "octobre" + month: 10 + locales: + - "fr" + - name: "octubre" + month: 10 + locales: + - "es" + - name: "\u0159\u00edjen" + month: 10 + locales: + - "cs" + - name: "rijen" + month: 10 + locales: + - "hr" + - name: "pa\u017adziernik" + month: 10 + locales: + - "pl" + - name: "pazdziernik" + month: 10 + locales: + - "pl" + - name: "ekim" + month: 10 + locales: + - "tr" + - name: "octombrie" + month: 10 + locales: + - "ro" + - name: "november" + month: 11 + locales: + - "en" + - name: "nov" + month: 11 + locales: + - "en" + - name: "novembre" + month: 11 + locales: + - "fr" + - name: "noviembre" + month: 11 + locales: + - "es" + - name: "studeni" + month: 11 + locales: + - "hr" + - name: "kas\u0131m" + month: 11 + locales: + - "tr" + - name: "kasim" + month: 11 + locales: + - "tr" + - name: "listopad" + month: 11 + locales: + - "pl" + - "cs" + - name: "novembro" + month: 11 + locales: + - "pt" + - name: "listopadu" + month: 11 + locales: + - "cs" + - name: "noiembrie" + month: 11 + locales: + - "ro" + - name: "december" + month: 12 + locales: + - "en" + - name: "dec" + month: 12 + locales: + - "en" + - name: "dezember" + month: 12 + locales: + - "de" + - name: "d\u00e9cembre" + month: 12 + locales: + - "fr" + - name: "decembre" + month: 12 + locales: + - "fr" + - name: "prosinec" + month: 12 + locales: + - "cs" + - name: "prosinac" + month: 12 + locales: + - "hr" + - name: "grudzie\u0144" + month: 12 + locales: + - "pl" + - name: "grudzien" + month: 12 + locales: + - "pl" + - name: "grudnia" + month: 12 + locales: + - "pl" + - name: "aral\u0131k" + month: 12 + locales: + - "tr" + - name: "aralik" + month: 12 + locales: + - "tr" + - name: "decembrie" + month: 12 + locales: + - "ro" diff --git a/backend/src/auth/api_tokens.rs b/backend/src/auth/api_tokens.rs new file mode 100644 index 0000000..5a75704 --- /dev/null +++ b/backend/src/auth/api_tokens.rs @@ -0,0 +1,324 @@ +use argon2::{ + password_hash::{rand_core::OsRng as PasswordHashOsRng, PasswordHasher, SaltString}, + Argon2, +}; +use chrono::{NaiveDateTime, Utc}; +use diesel::prelude::*; +use rand::{rngs::OsRng, TryRngCore}; +use uuid::Uuid; + +use crate::{ + auth::capability_sets::{load_capabilities_for_set, load_capability_set}, + error::AppError, + models::{ApiCapability, ApiToken, CapabilitySet, NewApiToken}, + schema::api_tokens, + state::PgPooledConnection, + tenants::{apply_api_token_prefix, clear_api_token_prefix}, +}; + +use crate::schema::api_tokens::dsl as api_tokens_dsl; + +const TOKEN_PREFIX_LENGTH: usize = 12; +const TOKEN_SECRET_LENGTH: usize = 32; + +/// Represents a newly issued API token and the raw secret that was generated for it. +pub struct IssuedApiToken { + pub token: String, + pub record: ApiToken, +} + +/// Creates a new API token for the supplied user/tenant combination. +pub fn create_api_token( + conn: &mut PgPooledConnection, + user_id: Uuid, + tenant_id: Uuid, + label: Option, + expires_at: Option, + capability_set_id: Uuid, +) -> Result { + let capability_set = + validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?; + + let raw_secret = generate_secret()?; + let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); + let token_hash = hash_secret(&raw_secret)?; + let new_token = NewApiToken { + id: Uuid::new_v4(), + user_id, + tenant_id, + token_prefix, + token_hash, + label, + expires_at, + capability_set_id: capability_set.id, + }; + + let record = diesel::insert_into(api_tokens::table) + .values(&new_token) + .get_result::(conn)?; + + Ok(IssuedApiToken { + token: raw_secret, + record, + }) +} + +/// Lists API tokens belonging to a user within an optional tenant scope. +pub fn list_api_tokens( + conn: &mut PgPooledConnection, + user_id: Uuid, + tenant_id: Option, +) -> Result, AppError> { + let mut query = api_tokens::table + .filter(api_tokens::user_id.eq(user_id)) + .into_boxed(); + + if let Some(tenant_id) = tenant_id { + query = query.filter(api_tokens::tenant_id.eq(tenant_id)); + } + + let tokens = query + .order(api_tokens::created_at.asc()) + .load::(conn)?; + + Ok(tokens) +} + +/// Regenerates the secret value for an API token. +pub fn regenerate_api_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, + tenant_id: Option, +) -> Result { + let record = find_user_token(conn, token_id, user_id, tenant_id)?; + + if record.revoked_at.is_some() { + return Err(AppError::bad_request( + "cannot regenerate a revoked API token", + )); + } + + let raw_secret = generate_secret()?; + let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); + let token_hash = hash_secret(&raw_secret)?; + + let updated = diesel::update(api_tokens::table.find(record.id)) + .set(( + api_tokens::token_prefix.eq(&token_prefix), + api_tokens::token_hash.eq(&token_hash), + api_tokens::last_used_at.eq::>(None), + )) + .get_result::(conn)?; + + Ok(IssuedApiToken { + token: raw_secret, + record: updated, + }) +} + +/// Attempts to resolve an API token by its secret value while ensuring it provides the +/// requested capability. +pub fn find_active_token_by_secret( + conn: &mut PgPooledConnection, + tenant_id: Option, + secret: &str, + required_capability: Option, +) -> Result, AppError> { + if secret.len() < TOKEN_PREFIX_LENGTH { + return Ok(None); + } + + let prefix = &secret[..TOKEN_PREFIX_LENGTH]; + let candidates = with_api_token_prefix(conn, prefix, |conn| { + let mut query = api_tokens::table + .filter(api_tokens::token_prefix.eq(prefix)) + .filter(api_tokens::revoked_at.is_null()) + .into_boxed(); + + let now = Utc::now().naive_utc(); + query = query.filter( + api_tokens::expires_at + .is_null() + .or(api_tokens::expires_at.gt(now)), + ); + + if let Some(tenant_id) = tenant_id { + query = query.filter(api_tokens::tenant_id.eq(tenant_id)); + } + + query.load::(conn).map_err(AppError::from) + })?; + + for token in candidates { + if let Some(required) = required_capability { + let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?; + if !capabilities.contains(&required) { + continue; + } + } + + if verify_token_secret(secret, &token.token_hash)? { + return Ok(Some(token)); + } + } + + Ok(None) +} + +/// Revokes an API token belonging to the specified user. +pub fn revoke_api_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, +) -> Result<(), AppError> { + let token = find_user_token(conn, token_id, user_id, None)?; + + diesel::update(api_tokens::table.find(token.id)) + .set(api_tokens::revoked_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + + Ok(()) +} + +/// Updates the last-used timestamp for a token. +pub fn touch_api_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> { + diesel::update(api_tokens::table.filter(api_tokens::id.eq(token_id))) + .set(api_tokens::last_used_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + Ok(()) +} + +/// Verifies a secret against its stored hash representation. +pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result { + crate::auth::password::verify_password(secret, token_hash).map_err(|err| { + tracing::error!(error = ?err, "failed to verify token"); + AppError::internal("failed to verify token") + }) +} + +fn find_user_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, + tenant_id: Option, +) -> Result { + let mut query = api_tokens_dsl::api_tokens + .filter(api_tokens_dsl::id.eq(token_id)) + .filter(api_tokens_dsl::user_id.eq(user_id)) + .into_boxed(); + + if let Some(tid) = tenant_id { + query = query.filter(api_tokens_dsl::tenant_id.eq(tid)); + } + + query + .first::(conn) + .optional() + .map_err(AppError::from)? + .ok_or_else(AppError::not_found) +} + +fn validate_capability_set_belongs_to_tenant( + conn: &mut PgPooledConnection, + capability_set_id: Uuid, + tenant_id: Uuid, +) -> Result { + let capability_set = load_capability_set(conn, capability_set_id)?; + if capability_set.tenant_id != tenant_id { + return Err(AppError::bad_request( + "capability set does not belong to the tenant", + )); + } + + Ok(capability_set) +} + +fn with_api_token_prefix( + conn: &mut PgPooledConnection, + prefix: &str, + operation: F, +) -> Result +where + F: FnOnce(&mut PgPooledConnection) -> Result, +{ + apply_api_token_prefix(conn, prefix)?; + let operation_result = operation(conn); + let clear_result = clear_api_token_prefix(conn); + + if let Err(err) = clear_result { + return Err(err); + } + + operation_result +} + +fn generate_secret() -> Result { + let mut buffer = [0u8; TOKEN_SECRET_LENGTH]; + OsRng.try_fill_bytes(&mut buffer).map_err(|err| { + tracing::error!(error = ?err, "failed to generate token"); + AppError::internal("failed to generate token") + })?; + Ok(hex::encode(buffer)) +} + +fn hash_secret(secret: &str) -> Result { + let mut salt_rng = PasswordHashOsRng; + let salt = SaltString::generate(&mut salt_rng); + let hash = Argon2::default() + .hash_password(secret.as_bytes(), &salt) + .map_err(|err| { + tracing::error!(error = ?err, "failed to hash token"); + AppError::internal("failed to hash token") + })?; + Ok(hash.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::capability_sets::{ + compute_slug, normalize_capabilities, owner_capabilities, webdav_capabilities, + }; + + #[test] + fn generated_secret_has_expected_length() { + let secret = generate_secret().unwrap(); + assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2); + } + + #[test] + fn hash_and_verify_secret_round_trip() { + let secret = generate_secret().unwrap(); + let hash = hash_secret(&secret).unwrap(); + assert!(verify_token_secret(&secret, &hash).unwrap()); + assert!(!verify_token_secret("wrong", &hash).unwrap()); + } + + #[test] + fn normalize_capabilities_deduplicates() { + let mut caps = owner_capabilities().to_vec(); + caps.push(ApiCapability::DocumentsRead); + let normalized = normalize_capabilities(caps).unwrap(); + assert_eq!(normalized.len(), owner_capabilities().len()); + } + + #[test] + fn normalize_capabilities_rejects_empty() { + assert!(normalize_capabilities(Vec::new()).is_err()); + } + + #[test] + fn compute_slug_matches_system_sets() { + let owner_slug = compute_slug(owner_capabilities()); + assert_eq!(owner_slug, "owner"); + + let webdav_slug = compute_slug(webdav_capabilities()); + assert_eq!(webdav_slug, "webdav"); + } + + #[test] + fn prefix_length_is_less_than_secret_length() { + assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2); + } +} diff --git a/backend/src/auth/capability_guard.rs b/backend/src/auth/capability_guard.rs new file mode 100644 index 0000000..9abea57 --- /dev/null +++ b/backend/src/auth/capability_guard.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use axum::{ + http::{Request, StatusCode}, + response::IntoResponse, +}; +use tower::{Layer, Service}; + +use crate::{auth::AuthenticatedUser, error::AppError, models::ApiCapability}; + +#[derive(Clone, Copy)] +pub enum CapabilityStrategy { + All, + Any, +} + +#[derive(Clone)] +pub struct RequireCapabilitiesLayer { + required: Arc>, + strategy: CapabilityStrategy, +} + +impl RequireCapabilitiesLayer { + pub fn all(caps: I) -> Self + where + I: IntoIterator, + { + Self { + required: Arc::new(caps.into_iter().collect()), + strategy: CapabilityStrategy::All, + } + } + + pub fn any(caps: I) -> Self + where + I: IntoIterator, + { + Self { + required: Arc::new(caps.into_iter().collect()), + strategy: CapabilityStrategy::Any, + } + } +} + +impl Layer for RequireCapabilitiesLayer { + type Service = RequireCapabilities; + + fn layer(&self, inner: S) -> Self::Service { + RequireCapabilities { + inner, + required: Arc::clone(&self.required), + strategy: self.strategy, + } + } +} + +#[derive(Clone)] +pub struct RequireCapabilities { + inner: S, + required: Arc>, + strategy: CapabilityStrategy, +} + +impl Service> for RequireCapabilities +where + S: Service, Response = axum::response::Response> + Send, + S::Future: Send + 'static, + B: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = std::pin::Pin< + Box> + Send>, + >; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + if self.required.is_empty() { + let fut = self.inner.call(req); + return Box::pin(async move { fut.await }); + } + + let (parts, body) = req.into_parts(); + let user = match parts.extensions.get::() { + Some(user) => user, + None => { + let response = AppError::unauthorized().into_response(); + return Box::pin(async move { Ok(response) }); + } + }; + + let allowed = match self.strategy { + CapabilityStrategy::All => self + .required + .iter() + .all(|cap| user.capabilities.contains(cap)), + CapabilityStrategy::Any => self + .required + .iter() + .any(|cap| user.capabilities.contains(cap)), + }; + + if !allowed { + let response = AppError::new(StatusCode::FORBIDDEN, "missing required capability") + .with_code("missing_capability") + .into_response(); + return Box::pin(async move { Ok(response) }); + } + + let req = Request::from_parts(parts, body); + let fut = self.inner.call(req); + Box::pin(async move { fut.await }) + } +} diff --git a/backend/src/auth/capability_sets.rs b/backend/src/auth/capability_sets.rs new file mode 100644 index 0000000..e759a82 --- /dev/null +++ b/backend/src/auth/capability_sets.rs @@ -0,0 +1,323 @@ +use chrono::Utc; +use diesel::{pg::Pg, prelude::*, Connection}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{ + error::AppError, + models::{ApiCapability, CapabilitySet, NewCapabilitySet, NewCapabilitySetCapability}, + schema::{ + capability_set_capabilities, capability_set_capabilities::dsl as csc_dsl, capability_sets, + capability_sets::dsl as cs_dsl, + }, +}; + +const OWNER_CAPABILITIES: [ApiCapability; 22] = [ + ApiCapability::CorrespondentsEdit, + ApiCapability::CorrespondentsRead, + ApiCapability::CorrespondentsWrite, + ApiCapability::DocumentsEdit, + ApiCapability::DocumentsRead, + ApiCapability::DocumentsUpload, + ApiCapability::DocumentsWrite, + ApiCapability::FoldersEdit, + ApiCapability::FoldersRead, + ApiCapability::FoldersWrite, + ApiCapability::ProfileRead, + ApiCapability::ProfileWrite, + ApiCapability::TagsEdit, + ApiCapability::TagsRead, + ApiCapability::TagsWrite, + ApiCapability::WebdavRead, + ApiCapability::WebdavWrite, + ApiCapability::CapabilitySetsRead, + ApiCapability::CapabilitySetsWrite, + ApiCapability::TenantsWrite, + ApiCapability::TenantsReset, + ApiCapability::TenantsDelete, +]; + +const USER_CAPABILITIES: [ApiCapability; 16] = [ + ApiCapability::CorrespondentsEdit, + ApiCapability::CorrespondentsRead, + ApiCapability::CorrespondentsWrite, + ApiCapability::DocumentsEdit, + ApiCapability::DocumentsRead, + ApiCapability::DocumentsUpload, + ApiCapability::DocumentsWrite, + ApiCapability::FoldersEdit, + ApiCapability::FoldersRead, + ApiCapability::FoldersWrite, + ApiCapability::ProfileRead, + ApiCapability::ProfileWrite, + ApiCapability::TagsEdit, + ApiCapability::TagsRead, + ApiCapability::TagsWrite, + ApiCapability::WebdavRead, +]; + +const READONLY_CAPABILITIES: [ApiCapability; 5] = [ + ApiCapability::CorrespondentsRead, + ApiCapability::DocumentsRead, + ApiCapability::FoldersRead, + ApiCapability::TagsRead, + ApiCapability::WebdavRead, +]; + +const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead]; + +pub fn owner_capabilities() -> &'static [ApiCapability] { + &OWNER_CAPABILITIES +} + +pub fn user_capabilities() -> &'static [ApiCapability] { + &USER_CAPABILITIES +} + +pub fn readonly_capabilities() -> &'static [ApiCapability] { + &READONLY_CAPABILITIES +} + +pub fn webdav_capabilities() -> &'static [ApiCapability] { + &WEBDAV_CAPABILITIES +} + +pub fn is_system_slug(slug: &str) -> bool { + matches!(slug, "owner" | "user" | "readonly" | "webdav") +} + +pub fn create_capability_set( + conn: &mut C, + tenant_id: Uuid, + slug: &str, + capabilities: Vec, +) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + let normalized = normalize_capabilities(capabilities)?; + + conn.transaction::(|conn| { + if cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(slug)) + .first::(conn) + .optional() + .map_err(AppError::from)? + .is_some() + { + return Err(AppError::conflict("capability set slug already exists")); + } + + let set = NewCapabilitySet { + id: Uuid::new_v4(), + tenant_id, + slug: slug.to_owned(), + cap_version: 1, + is_system: false, + }; + + diesel::insert_into(capability_sets::table) + .values(&set) + .execute(conn) + .map_err(AppError::from)?; + + persist_capabilities(conn, set.id, &normalized)?; + + capability_sets::table + .find(set.id) + .first::(conn) + .map_err(AppError::from) + }) +} + +pub fn normalize_capabilities( + mut capabilities: Vec, +) -> Result, AppError> { + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str())); + capabilities.dedup(); + Ok(capabilities) +} + +pub fn load_capabilities_for_set( + conn: &mut C, + capability_set_id: Uuid, +) -> Result, AppError> +where + C: Connection + diesel::connection::LoadConnection, +{ + let mut capabilities: Vec = csc_dsl::capability_set_capabilities + .filter(csc_dsl::capability_set_id.eq(capability_set_id)) + .select(csc_dsl::capability) + .load(conn) + .map_err(AppError::from)?; + + capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str())); + Ok(capabilities) +} + +pub fn ensure_capability_set( + conn: &mut C, + tenant_id: Uuid, + capabilities: &[ApiCapability], +) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let slug = compute_slug(capabilities); + conn.transaction::(|conn| { + if let Some(existing) = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(&slug)) + .first::(conn) + .optional() + .map_err(AppError::from)? + { + ensure_capability_membership(conn, &existing, capabilities)?; + return Ok(existing); + } + + let set = NewCapabilitySet { + id: Uuid::new_v4(), + tenant_id, + slug: slug.clone(), + cap_version: 1, + is_system: matches!(slug.as_str(), "owner" | "user" | "readonly" | "webdav"), + }; + + diesel::insert_into(capability_sets::table) + .values(&set) + .execute(conn) + .map_err(AppError::from)?; + + persist_capabilities(conn, set.id, capabilities)?; + + Ok(capability_sets::table + .find(set.id) + .first::(conn) + .map_err(AppError::from)?) + }) +} + +pub fn refresh_capability_set( + conn: &mut C, + set: &CapabilitySet, + capabilities: &[ApiCapability], +) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + conn.transaction::(|conn| { + diesel::delete( + csc_dsl::capability_set_capabilities.filter(csc_dsl::capability_set_id.eq(set.id)), + ) + .execute(conn) + .map_err(AppError::from)?; + + persist_capabilities(conn, set.id, capabilities)?; + + diesel::update(capability_sets::table.find(set.id)) + .set(( + cs_dsl::cap_version.eq(set.cap_version + 1), + cs_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .map_err(AppError::from)?; + + capability_sets::table + .find(set.id) + .first::(conn) + .map_err(AppError::from) + }) +} + +pub fn load_capability_set(conn: &mut C, id: Uuid) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + capability_sets::table + .find(id) + .first::(conn) + .map_err(AppError::from) +} + +pub fn compute_slug(capabilities: &[ApiCapability]) -> String { + if capabilities == owner_capabilities() { + return "owner".to_string(); + } + + if capabilities == user_capabilities() { + return "user".to_string(); + } + + if capabilities == readonly_capabilities() { + return "readonly".to_string(); + } + + if capabilities == webdav_capabilities() { + return "webdav".to_string(); + } + + let joined = capabilities + .iter() + .map(|cap| cap.as_str()) + .collect::>() + .join(","); + + let digest = Sha256::digest(joined.as_bytes()); + let hex = hex::encode(digest); + format!("caps-{}", &hex[..12]) +} + +fn ensure_capability_membership( + conn: &mut C, + set: &CapabilitySet, + desired: &[ApiCapability], +) -> Result<(), AppError> +where + C: Connection + diesel::connection::LoadConnection, +{ + let current = load_capabilities_for_set(conn, set.id)?; + if current == desired { + return Ok(()); + } + + let _ = refresh_capability_set(conn, set, desired)?; + Ok(()) +} + +fn persist_capabilities( + conn: &mut C, + set_id: Uuid, + capabilities: &[ApiCapability], +) -> Result<(), AppError> +where + C: Connection + diesel::connection::LoadConnection, +{ + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let records: Vec = capabilities + .iter() + .map(|cap| NewCapabilitySetCapability { + capability_set_id: set_id, + capability: *cap, + }) + .collect(); + + diesel::insert_into(capability_set_capabilities::table) + .values(&records) + .execute(conn) + .map_err(AppError::from)?; + + Ok(()) +} diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs new file mode 100644 index 0000000..6c4f496 --- /dev/null +++ b/backend/src/auth/jwt.rs @@ -0,0 +1,248 @@ +use anyhow::Result; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::config::AppConfig; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PrincipalKind { + UserSession, + ApiToken, +} + +#[derive(Debug, Clone)] +pub struct AccessTokenContext { + pub user_id: Uuid, + pub tenant_id: Uuid, + pub username: String, + pub principal_kind: PrincipalKind, + pub principal_id: Uuid, + pub capability_set_id: Uuid, + pub cap_version: i32, +} + +#[derive(Clone)] +pub struct JwtService { + encoding: EncodingKey, + decoding: DecodingKey, + issuer: String, + audience: String, + expiry: Duration, + download_audience: String, + download_expiry: Duration, + selector_audience: String, + selector_expiry: Duration, + signup_audience: String, + signup_expiry: Duration, +} + +impl JwtService { + pub fn from_config(config: &AppConfig) -> Result { + let access_expiry = Duration::minutes(config.jwt_expiry_minutes); + + Ok(Self { + encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()), + decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()), + issuer: config.jwt_issuer.clone(), + audience: config.jwt_audience.clone(), + expiry: access_expiry, + download_audience: config.download_token_audience.clone(), + download_expiry: Duration::minutes(config.download_token_expiry_minutes), + selector_audience: format!("{}:tenant-selector", config.jwt_audience), + selector_expiry: access_expiry, + signup_audience: format!("{}:signup", config.jwt_audience), + signup_expiry: Duration::minutes(15), + }) + } + + pub fn generate_token(&self, context: AccessTokenContext) -> Result { + let now = Utc::now(); + let exp = now + self.expiry; + let claims = Claims { + sub: context.user_id, + tenant_id: context.tenant_id, + username: context.username, + principal_kind: context.principal_kind, + principal_id: context.principal_id, + capability_set_id: context.capability_set_id, + cap_version: context.cap_version, + iss: self.issuer.clone(), + aud: self.audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } + + pub fn generate_download_token( + &self, + document_id: Uuid, + version_id: Uuid, + user_id: Uuid, + tenant_id: Uuid, + ) -> Result { + let now = Utc::now(); + let exp = now + self.download_expiry; + let claims = DownloadClaims { + subject: DownloadSubject::Document { + doc_id: document_id, + version_id, + }, + user_id, + tenant_id, + iss: self.issuer.clone(), + aud: self.download_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn generate_asset_download_token( + &self, + asset_id: Uuid, + user_id: Uuid, + tenant_id: Uuid, + ) -> Result { + let now = Utc::now(); + let exp = now + self.download_expiry; + let claims = DownloadClaims { + subject: DownloadSubject::Asset { asset_id }, + user_id, + tenant_id, + iss: self.issuer.clone(), + aud: self.download_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_download_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.download_audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } + + pub fn generate_tenant_selector_token(&self, user_id: Uuid) -> Result { + let now = Utc::now(); + let exp = now + self.selector_expiry; + let claims = TenantSelectionClaims { + sub: user_id, + iss: self.issuer.clone(), + aud: self.selector_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_tenant_selector_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.selector_audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } + + pub fn generate_signup_token( + &self, + user_id: Uuid, + challenge_id: Uuid, + username: String, + ) -> Result { + let now = Utc::now(); + let exp = now + self.signup_expiry; + let claims = SignupClaims { + sub: user_id, + challenge_id, + username, + iss: self.issuer.clone(), + aud: self.signup_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_signup_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.signup_audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub sub: Uuid, + pub tenant_id: Uuid, + pub username: String, + pub principal_kind: PrincipalKind, + pub principal_id: Uuid, + pub capability_set_id: Uuid, + pub cap_version: i32, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "scope", rename_all = "snake_case")] +pub enum DownloadSubject { + Document { doc_id: Uuid, version_id: Uuid }, + Asset { asset_id: Uuid }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadClaims { + #[serde(flatten)] + pub subject: DownloadSubject, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TenantSelectionClaims { + pub sub: Uuid, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignupClaims { + pub sub: Uuid, + pub challenge_id: Uuid, + pub username: String, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs new file mode 100644 index 0000000..9218f61 --- /dev/null +++ b/backend/src/auth/mod.rs @@ -0,0 +1,221 @@ +pub mod api_tokens; +pub mod capability_guard; +pub mod capability_sets; +pub mod jwt; +pub mod passkeys; +pub mod password; + +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; +use axum_extra::headers::{authorization::Bearer, Authorization}; +use axum_extra::TypedHeader; +use diesel::{pg::PgConnection, prelude::*}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::{ + auth::capability_sets::{load_capabilities_for_set, load_capability_set}, + error::{AppError, AppResult}, + models::{ApiCapability, TenantStatus}, + schema::tenants::dsl as tenant_dsl, + state::{AppState, PgPooledConnection}, +}; +use uuid::Uuid; + +use crate::auth::jwt::PrincipalKind; + +#[derive(Debug, Clone)] +pub struct TenantMembershipUser { + pub user_id: Uuid, +} + +impl FromRequestParts for TenantMembershipUser { + type Rejection = AppError; + + #[allow(refining_impl_trait)] + fn from_request_parts<'a>( + parts: &'a mut Parts, + state: &AppState, + ) -> impl std::future::Future> + Send + 'a { + let state = state.clone(); + async move { + let TypedHeader(Authorization(bearer)) = + TypedHeader::>::from_request_parts(parts, &state) + .await + .map_err(|_| AppError::unauthorized())?; + + if let Ok(claims) = state.jwt.verify_token(bearer.token()) { + return Ok(Self { + user_id: claims.sub, + }); + } + + let selector = state + .jwt + .verify_tenant_selector_token(bearer.token()) + .map_err(|_| AppError::unauthorized())?; + + Ok(Self { + user_id: selector.sub, + }) + } + } +} + +#[derive(Clone)] +pub struct TenantConnectionHolder { + inner: Arc>>, +} + +impl TenantConnectionHolder { + pub fn new(conn: PgPooledConnection) -> Self { + Self { + inner: Arc::new(Mutex::new(Some(conn))), + } + } + + pub fn into_conn(self) -> Option { + self.inner.lock().ok()?.take() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct AuthenticatedUser { + pub user_id: uuid::Uuid, + pub username: String, + pub tenant_id: uuid::Uuid, + pub principal_kind: PrincipalKind, + pub principal_id: Uuid, + pub capability_set_id: Uuid, + pub cap_version: i32, + pub capabilities: Vec, +} + +impl FromRequestParts for AuthenticatedUser { + type Rejection = AppError; + + #[allow(refining_impl_trait)] + fn from_request_parts<'a>( + parts: &'a mut Parts, + state: &AppState, + ) -> impl std::future::Future> + Send + 'a { + let state = state.clone(); + async move { + if let Some(user) = parts.extensions.get::() { + return Ok(user.clone()); + } + + let TypedHeader(Authorization(bearer)) = + TypedHeader::>::from_request_parts(parts, &state) + .await + .map_err(|_| AppError::unauthorized())?; + + let claims = state + .jwt + .verify_token(bearer.token()) + .map_err(|_| AppError::unauthorized())?; + + let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?; + let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id) + .map_err(|_| AppError::unauthorized())?; + + if capability_set.cap_version != claims.cap_version { + return Err(AppError::unauthorized()); + } + + let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id) + .map_err(|_| AppError::unauthorized())?; + + let user = AuthenticatedUser { + user_id: claims.sub, + username: claims.username, + tenant_id: claims.tenant_id, + principal_kind: claims.principal_kind, + principal_id: claims.principal_id, + capability_set_id: claims.capability_set_id, + cap_version: claims.cap_version, + capabilities, + }; + + parts.extensions.insert(user.clone()); + parts + .extensions + .insert(TenantConnectionHolder::new(tenant_conn)); + + Ok(user) + } + } +} + +pub struct TenantScopedConn { + pub conn: PgPooledConnection, + pub tenant_id: Uuid, + pub user_id: Uuid, + pub user: AuthenticatedUser, +} + +impl TenantScopedConn { + pub fn conn(&mut self) -> &mut PgPooledConnection { + &mut self.conn + } +} + +impl FromRequestParts for TenantScopedConn { + type Rejection = AppError; + + #[allow(refining_impl_trait)] + fn from_request_parts<'a>( + parts: &'a mut Parts, + state: &AppState, + ) -> impl std::future::Future> + Send + 'a { + let state = state.clone(); + async move { + let user = AuthenticatedUser::from_request_parts(parts, &state).await?; + let tenant_id = user.tenant_id; + let mut conn = if let Some(holder) = parts.extensions.remove::() + { + holder + .into_conn() + .ok_or_else(|| AppError::internal("tenant connection unavailable"))? + } else { + state.db_for_tenant(tenant_id)? + }; + + ensure_active_tenant_with_conn(&mut conn, tenant_id)?; + + Ok(Self { + conn, + tenant_id, + user_id: user.user_id, + user, + }) + } + } +} + +pub(crate) fn ensure_active_tenant(state: &AppState, tenant_id: Uuid) -> AppResult<()> { + let mut conn = state.db_unscoped()?; + ensure_active_tenant_with_conn(&mut conn, tenant_id) +} + +pub(crate) fn ensure_active_tenant_with_conn( + conn: &mut PgConnection, + tenant_id: Uuid, +) -> AppResult<()> { + use tenant_dsl::tenants; + + let status: TenantStatus = tenants + .find(tenant_id) + .select(tenant_dsl::status) + .first(conn)?; + + if status != TenantStatus::Active { + return Err(AppError::new(StatusCode::FORBIDDEN, "tenant is not active")); + } + + Ok(()) +} diff --git a/backend/src/auth/passkeys.rs b/backend/src/auth/passkeys.rs new file mode 100644 index 0000000..8de961f --- /dev/null +++ b/backend/src/auth/passkeys.rs @@ -0,0 +1,592 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; +use diesel::{dsl::count_star, prelude::*}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use webauthn_rs::prelude::{Credential, *}; + +use crate::{ + config::AppConfig, + error::{AppError, AppResult}, + models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge}, + schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl}, +}; + +const PURPOSE_REGISTRATION: &str = "registration"; +const PURPOSE_AUTHENTICATION: &str = "authentication"; +const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10; + +#[derive(Clone)] +pub struct PasskeyService { + webauthn: Arc, + challenge_ttl: ChronoDuration, +} + +pub struct PreparedPasskey { + pub id: Uuid, + pub credential_id: Vec, + pub public_key: Vec, + pub credential: serde_json::Value, + pub sign_count: i64, + pub transports: Vec>, + pub aaguid: Option, +} + +impl PreparedPasskey { + pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option) -> NewUserPasskey { + NewUserPasskey { + id: self.id, + user_id, + credential_id: self.credential_id, + public_key: self.public_key, + credential: self.credential, + sign_count: self.sign_count, + transports: self.transports, + aaguid: self.aaguid, + nickname, + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RegistrationChallengeResponse { + pub challenge_id: Uuid, + #[serde(flatten)] + #[schema(value_type = Object)] + pub challenge: CreationChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticationChallengeResponse { + pub challenge_id: Uuid, + #[serde(flatten)] + #[schema(value_type = Object)] + pub challenge: RequestChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeySummary { + pub id: Uuid, + pub nickname: Option, + pub created_at: NaiveDateTime, + pub last_used_at: Option, + pub transports: Vec, + pub revoked_at: Option, + pub revoked_reason: Option, +} + +impl PasskeyService { + pub fn try_new(config: &AppConfig) -> Result> { + let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) { + Some(rp_id) if !rp_id.is_empty() => rp_id, + _ => return Ok(None), + }; + let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) { + Some(origin) if !origin.is_empty() => origin, + _ => return Ok(None), + }; + + let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?; + + let builder = WebauthnBuilder::new(rp_id, &origin) + .context("failed to initialise WebAuthn builder")? + .rp_name(&config.webauthn_rp_name) + .allow_subdomains(false) + .allow_any_port(false); + + let webauthn = builder + .build() + .context("failed to build WebAuthn instance")?; + + Ok(Some(Self { + webauthn: Arc::new(webauthn), + challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES), + })) + } + + fn prune_expired(&self, conn: &mut PgConnection) { + let now = Utc::now().naive_utc(); + let _ = diesel::delete( + challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)), + ) + .execute(conn); + } + + fn begin_registration( + &self, + conn: &mut PgConnection, + user_id: Uuid, + username: &str, + challenge_user_id: Option, + exclude: Option>, + ) -> AppResult { + self.prune_expired(conn); + + let (challenge, state) = self + .webauthn + .start_passkey_registration(user_id, username, username, exclude) + .map_err(|err| { + tracing::error!(error = %err, "failed to start passkey registration"); + AppError::internal("failed to start passkey registration") + })?; + + let challenge_id = Uuid::new_v4(); + let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); + let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); + let state_bytes = serde_json::to_vec(&state) + .context("failed to encode passkey registration state") + .map_err(AppError::internal)?; + + let record = NewWebauthnChallenge { + id: challenge_id, + user_id: challenge_user_id, + purpose: PURPOSE_REGISTRATION.to_string(), + challenge: challenge_bytes, + state: state_bytes, + expires_at, + }; + + diesel::insert_into(challenge_dsl::webauthn_challenges) + .values(&record) + .execute(conn)?; + + Ok(RegistrationChallengeResponse { + challenge_id, + challenge, + }) + } + + pub fn start_registration( + &self, + conn: &mut PgConnection, + user: &User, + ) -> AppResult { + let existing: Vec = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user.id)) + .filter(passkey_dsl::revoked_at.is_null()) + .load(conn)?; + + let exclude = if existing.is_empty() { + None + } else { + Some( + existing + .iter() + .map(|pk| CredentialID::from(pk.credential_id.clone())) + .collect(), + ) + }; + + self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude) + } + + pub fn start_signup_registration( + &self, + conn: &mut PgConnection, + user_id: Uuid, + username: &str, + ) -> AppResult { + self.begin_registration(conn, user_id, username, None, None) + } + + fn complete_registration( + &self, + conn: &mut PgConnection, + challenge_id: Uuid, + credential: &RegisterPublicKeyCredential, + expected_user: Option, + ) -> AppResult { + let record: WebauthnChallenge = challenge_dsl::webauthn_challenges + .find(challenge_id) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::bad_request("challenge not found") + } else { + AppError::from(err) + } + })?; + + if record.purpose != PURPOSE_REGISTRATION { + return Err(AppError::bad_request("challenge is not for registration")); + } + + if let Some(expected) = expected_user { + if record.user_id != Some(expected) { + return Err(AppError::unauthorized()); + } + } else if record.user_id.is_some() { + return Err(AppError::bad_request( + "unexpected user context for signup registration", + )); + } + + if record.expires_at < Utc::now().naive_utc() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::bad_request("challenge expired")); + } + + let state: PasskeyRegistration = serde_json::from_slice(&record.state) + .context("failed to decode registration state") + .map_err(AppError::internal)?; + + let passkey = self + .webauthn + .finish_passkey_registration(credential, &state) + .map_err(|err| { + tracing::warn!(error = %err, "passkey registration validation failed"); + AppError::bad_request("invalid passkey attestation") + })?; + + let credential_struct: Credential = passkey.clone().into(); + let credential_id_vec: Vec = credential_struct.cred_id.clone().into(); + + let duplicate = passkey_dsl::user_passkeys + .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) + .first::(conn) + .optional()?; + if duplicate.is_some() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::conflict("credential already registered")); + } + + let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) + .context("failed to encode credential public key") + .map_err(AppError::internal)?; + + let transports: Vec> = credential_struct + .transports + .clone() + .unwrap_or_default() + .into_iter() + .map(|transport| Some(transport.as_ref().to_string())) + .collect(); + + let aaguid = match credential_struct.attestation.metadata { + AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => { + Some(aaguid) + } + _ => None, + }; + + let credential_json = serde_json::to_value(&passkey) + .context("failed to serialise passkey") + .map_err(AppError::internal)?; + + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + + Ok(PreparedPasskey { + id: Uuid::new_v4(), + credential_id: credential_id_vec, + public_key: public_key_bytes, + credential: credential_json, + sign_count: credential_struct.counter as i64, + transports, + aaguid, + }) + } + + pub fn finish_registration( + &self, + conn: &mut PgConnection, + user: &User, + challenge_id: Uuid, + credential: RegisterPublicKeyCredential, + nickname: Option, + ) -> AppResult { + let prepared = + self.complete_registration(conn, challenge_id, &credential, Some(user.id))?; + + let new_passkey = prepared.into_new_user_passkey(user.id, nickname); + + diesel::insert_into(passkey_dsl::user_passkeys) + .values(&new_passkey) + .execute(conn)?; + + let created: UserPasskey = passkey_dsl::user_passkeys + .find(new_passkey.id) + .select(UserPasskey::as_select()) + .first(conn)?; + + Ok(created) + } + + pub fn start_authentication( + &self, + conn: &mut PgConnection, + user: &User, + ) -> AppResult { + self.prune_expired(conn); + + let stored: Vec = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user.id)) + .filter(passkey_dsl::revoked_at.is_null()) + .select(UserPasskey::as_select()) + .load(conn)?; + + if stored.is_empty() { + return Err(AppError::bad_request("no passkeys registered")); + } + + let mut passkeys = Vec::with_capacity(stored.len()); + for pk in &stored { + let passkey: Passkey = serde_json::from_value(pk.credential.clone()) + .context("failed to parse stored passkey") + .map_err(AppError::internal)?; + passkeys.push(passkey); + } + + let (challenge, state) = self + .webauthn + .start_passkey_authentication(&passkeys) + .map_err(|err| { + tracing::error!(error = %err, "failed to start passkey authentication"); + AppError::internal("failed to start passkey authentication") + })?; + + let challenge_id = Uuid::new_v4(); + let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); + let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); + let state_bytes = serde_json::to_vec(&state) + .context("failed to encode authentication state") + .map_err(AppError::internal)?; + + let record = NewWebauthnChallenge { + id: challenge_id, + user_id: Some(user.id), + purpose: PURPOSE_AUTHENTICATION.to_string(), + challenge: challenge_bytes, + state: state_bytes, + expires_at, + }; + + diesel::insert_into(challenge_dsl::webauthn_challenges) + .values(&record) + .execute(conn)?; + + Ok(AuthenticationChallengeResponse { + challenge_id, + challenge, + }) + } + + pub fn list_for_user( + &self, + conn: &mut PgConnection, + user_id: Uuid, + ) -> AppResult> { + let passkeys: Vec = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user_id)) + .order(passkey_dsl::created_at.asc()) + .select(UserPasskey::as_select()) + .load(conn)?; + + Ok(passkeys.into_iter().map(PasskeySummary::from).collect()) + } + + pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult { + let count: i64 = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user_id)) + .filter(passkey_dsl::revoked_at.is_null()) + .select(count_star()) + .first(conn)?; + Ok(count) + } + + pub fn consume_signup_challenge( + &self, + conn: &mut PgConnection, + challenge_id: Uuid, + credential: &RegisterPublicKeyCredential, + ) -> AppResult { + self.complete_registration(conn, challenge_id, credential, None) + } + + pub fn revoke_passkey( + &self, + conn: &mut PgConnection, + user_id: Uuid, + passkey_id: Uuid, + reason: Option, + ) -> AppResult<()> { + let now = Utc::now().naive_utc(); + let updated = diesel::update( + passkey_dsl::user_passkeys + .filter(passkey_dsl::id.eq(passkey_id)) + .filter(passkey_dsl::user_id.eq(user_id)) + .filter(passkey_dsl::revoked_at.is_null()), + ) + .set(( + passkey_dsl::revoked_at.eq(Some(now)), + passkey_dsl::revoked_reason.eq(reason), + passkey_dsl::updated_at.eq(now), + )) + .execute(conn)?; + + if updated == 0 { + return Err(AppError::not_found()); + } + + Ok(()) + } + + pub fn finish_authentication( + &self, + conn: &mut PgConnection, + challenge_id: Uuid, + credential: PublicKeyCredential, + ) -> AppResult<(User, UserPasskey, AuthenticationResult)> { + let record: WebauthnChallenge = challenge_dsl::webauthn_challenges + .find(challenge_id) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::bad_request("challenge not found") + } else { + AppError::from(err) + } + })?; + + if record.purpose != PURPOSE_AUTHENTICATION { + return Err(AppError::bad_request("challenge is not for authentication")); + } + + let user_id = record + .user_id + .ok_or_else(|| AppError::bad_request("challenge missing user context"))?; + + if record.expires_at < Utc::now().naive_utc() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::bad_request("challenge expired")); + } + + let state: PasskeyAuthentication = serde_json::from_slice(&record.state) + .context("failed to decode authentication state") + .map_err(AppError::internal)?; + + let auth_result = self + .webauthn + .finish_passkey_authentication(&credential, &state) + .map_err(|err| { + tracing::warn!(error = %err, "passkey authentication failed"); + AppError::unauthorized() + })?; + + let credential_id_vec: Vec = auth_result.cred_id().clone().into(); + + let mut passkey: UserPasskey = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user_id)) + .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) + .filter(passkey_dsl::revoked_at.is_null()) + .select(UserPasskey::as_select()) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::unauthorized() + } else { + AppError::from(err) + } + })?; + + let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone()) + .context("failed to parse stored passkey") + .map_err(AppError::internal)?; + + if auth_result.needs_update() { + let _ = passkey_model.update_credential(&auth_result); + } + + let credential_struct: Credential = passkey_model.clone().into(); + let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) + .context("failed to encode credential public key") + .map_err(AppError::internal)?; + + let transports: Vec> = credential_struct + .transports + .clone() + .unwrap_or_default() + .into_iter() + .map(|transport| Some(transport.as_ref().to_string())) + .collect(); + + let credential_json = serde_json::to_value(&passkey_model) + .context("failed to serialise passkey") + .map_err(AppError::internal)?; + + let now = Utc::now().naive_utc(); + diesel::update(passkey_dsl::user_passkeys.find(passkey.id)) + .set(( + passkey_dsl::sign_count.eq(auth_result.counter() as i64), + passkey_dsl::transports.eq(&transports), + passkey_dsl::credential.eq(credential_json.clone()), + passkey_dsl::public_key.eq(public_key_bytes), + passkey_dsl::last_used_at.eq(Some(now)), + passkey_dsl::updated_at.eq(now), + )) + .execute(conn)?; + + passkey.sign_count = auth_result.counter() as i64; + passkey.transports = transports; + passkey.credential = credential_json; + passkey.last_used_at = Some(now); + passkey.updated_at = now; + + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + + let user = crate::schema::users::table + .find(user_id) + .first::(conn)?; + + Ok((user, passkey, auth_result)) + } +} + +impl From for PasskeySummary { + fn from(passkey: UserPasskey) -> Self { + let transports = passkey + .transports + .into_iter() + .filter_map(|value| value) + .collect(); + + Self { + id: passkey.id, + nickname: passkey.nickname, + created_at: passkey.created_at, + last_used_at: passkey.last_used_at, + transports, + revoked_at: passkey.revoked_at, + revoked_reason: passkey.revoked_reason, + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyRegistrationFinishPayload { + pub challenge_id: Uuid, + #[schema(value_type = Object)] + pub credential: RegisterPublicKeyCredential, + #[serde(default)] + pub nickname: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyLoginStartPayload { + pub username: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyLoginFinishPayload { + pub challenge_id: Uuid, + #[schema(value_type = Object)] + pub credential: PublicKeyCredential, +} diff --git a/backend/src/auth/password.rs b/backend/src/auth/password.rs new file mode 100644 index 0000000..a79ceed --- /dev/null +++ b/backend/src/auth/password.rs @@ -0,0 +1,24 @@ +use anyhow::{anyhow, Result}; +use argon2::{ + password_hash::{ + rand_core::OsRng as PasswordHashOsRng, PasswordHash, PasswordHasher, PasswordVerifier, + SaltString, + }, + Argon2, +}; + +pub fn verify_password(password: &str, password_hash: &str) -> Result { + let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) +} + +pub fn hash_password(password: &str) -> Result { + let mut rng = PasswordHashOsRng; + let salt = SaltString::generate(&mut rng); + let hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|err| anyhow!(err))?; + Ok(hash.to_string()) +} diff --git a/backend/src/bin/admin.rs b/backend/src/bin/admin.rs new file mode 100644 index 0000000..a8d174c --- /dev/null +++ b/backend/src/bin/admin.rs @@ -0,0 +1,888 @@ +use std::sync::Arc; + +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{Duration as ChronoDuration, Utc}; +use clap::{Parser, Subcommand, ValueEnum}; +use diesel::{dsl::exists, pg::PgConnection, prelude::*, select}; +use diesel_migrations::MigrationHarness; +use rand::{rngs::OsRng, TryRngCore}; +use reqwest::{Client, Method, StatusCode}; +use sha2::{Digest, Sha256}; +use tokio::task; +use uuid::Uuid; + +use papercrate::{ + auth::capability_sets::{ensure_capability_set, owner_capabilities}, + config::{redact_database_url, AppConfig}, + db::{self, PgPool}, + documents::search::ensure_quickwit_index, + jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_DELETE_TENANT}, + migrations::MIGRATIONS, + models::{ + DocumentAsset, MagicToken, MagicTokenKind, NewUser, NewUserMembership, Tenant, + TenantStatus, User, + }, + s3, + schema::{document_assets, documents, magic_tokens, tenants, user_memberships, users}, + storage::{ObjectStorage, S3Storage, TenantStorage}, + tenants::{apply_tenant_guc, clear_tenant_context, TenantService}, + utils::{text::normalize_identifier, tracing::init_tracing}, + workers::tenants::{build_delete_proof_message, sign_delete_proof, DeleteAction}, +}; + +#[derive(Parser)] +#[command( + name = "papercrate-admin", + version, + about = "Papercrate administration utility" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + CreateUser { + username: String, + }, + ListUsers, + DeleteUser { + username: String, + }, + CreateTenant { + name: String, + #[arg(long = "storage-root")] + storage_root: Option, + #[arg(long = "quickwit-index")] + quickwit_index: Option, + }, + DeleteTenant { + tenant_id: Uuid, + #[arg(long = "tenant-name")] + tenant_name: String, + }, + ResetTenant { + tenant_id: Uuid, + #[arg(long = "tenant-name")] + tenant_name: String, + #[arg(long = "final-status", value_enum, default_value_t = TenantFinalStatusArg::Active)] + final_status: TenantFinalStatusArg, + }, + AddUserToTenant { + username: String, + tenant_id: Uuid, + }, + RemoveUserFromTenant { + username: String, + tenant_id: Uuid, + }, + ReanalyzeDocuments { + tenant_id: Uuid, + }, + ListTenants, + DeleteAssets { + tenant_id: Uuid, + #[arg(long = "asset-type")] + asset_type: Option, + #[arg(long = "all", help = "Confirm deleting every asset for the tenant")] + delete_all: bool, + }, + QuickwitCreate { + tenant_id: Uuid, + }, + QuickwitDelete { + tenant_id: Uuid, + }, + EnqueueDeleteTenant { + tenant_id: Uuid, + #[arg(long = "tenant-name")] + tenant_name: String, + #[arg(long = "remove-tenant")] + remove_tenant: bool, + #[arg(long = "final-status", value_enum)] + final_status: Option, + }, + MagicToken { + username: String, + #[arg(long = "ttl-minutes", default_value_t = 10)] + ttl_minutes: i64, + #[arg( + long = "max-uses", + value_name = "MAX_USES", + help = "Maximum number of uses before the token is rejected (default: unlimited)" + )] + max_uses: Option, + #[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)] + kind: MagicTokenKindArg, + }, + MigrateDatabase { + #[arg( + long = "database-url", + value_name = "URL", + help = "Override the migrations database URL (defaults to MIGRATIONS_DATABASE_URL or DATABASE_URL)" + )] + database_url: Option, + }, +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum MagicTokenKindArg { + #[value(name = "email_login")] + EmailLogin, + #[value(name = "demo_login")] + DemoLogin, +} + +impl From for MagicTokenKind { + fn from(value: MagicTokenKindArg) -> Self { + match value { + MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin, + MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin, + } + } +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum TenantFinalStatusArg { + Active, + Suspended, +} + +impl TenantFinalStatusArg { + fn as_str(&self) -> &'static str { + match self { + TenantFinalStatusArg::Active => "active", + TenantFinalStatusArg::Suspended => "suspended", + } + } +} + +#[tokio::main] +async fn main() -> Result<()> { + init_tracing("info"); + let cli = Cli::parse(); + let config = AppConfig::load_and_log("admin")?; + let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; + + match cli.command { + Command::CreateUser { username } => create_user(&pool, &username)?, + Command::ListUsers => list_users(&pool)?, + Command::DeleteUser { username } => delete_user(&pool, &username)?, + Command::CreateTenant { + name, + storage_root, + quickwit_index, + } => create_tenant(&pool, &name, storage_root, quickwit_index)?, + Command::DeleteTenant { + tenant_id, + tenant_name, + } => delete_tenant(&config, &pool, tenant_id, &tenant_name)?, + Command::ResetTenant { + tenant_id, + tenant_name, + final_status, + } => reset_tenant(&config, &pool, tenant_id, &tenant_name, final_status)?, + Command::AddUserToTenant { + username, + tenant_id, + } => add_user_to_tenant(&pool, &username, tenant_id)?, + Command::RemoveUserFromTenant { + username, + tenant_id, + } => remove_user_from_tenant(&pool, &username, tenant_id)?, + Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?, + Command::ListTenants => list_tenants(&pool)?, + Command::DeleteAssets { + tenant_id, + asset_type, + delete_all, + } => { + let asset_type = asset_type.as_deref(); + if asset_type.is_none() && !delete_all { + bail!("refusing to delete all assets without --all confirmation"); + } + + delete_assets_for_tenant(&config, &pool, tenant_id, asset_type).await? + } + Command::QuickwitCreate { tenant_id } => { + quickwit_index(&config, &pool, tenant_id, Method::POST).await? + } + Command::QuickwitDelete { tenant_id } => { + quickwit_index(&config, &pool, tenant_id, Method::DELETE).await? + } + Command::EnqueueDeleteTenant { + tenant_id, + tenant_name, + remove_tenant, + final_status, + } => enqueue_delete_tenant_job( + &config, + &pool, + tenant_id, + &tenant_name, + remove_tenant, + final_status, + )?, + Command::MagicToken { + username, + ttl_minutes, + max_uses, + kind, + } => { + create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?; + } + Command::MigrateDatabase { database_url } => { + let url = database_url.unwrap_or_else(|| config.migrations_database_url().to_string()); + migrate_database(url).await?; + } + } + + Ok(()) +} + +async fn migrate_database(database_url: String) -> Result<()> { + let redacted = redact_database_url(&database_url); + tracing::info!(database_url = %redacted, "running pending migrations"); + + let result = task::spawn_blocking(move || -> Result<()> { + let mut conn = + PgConnection::establish(&database_url).context("failed to connect to database")?; + conn.run_pending_migrations(MIGRATIONS) + .map_err(|err| anyhow!("failed to run migrations: {err}"))?; + Ok(()) + }) + .await + .context("migration task panicked")?; + + result?; + tracing::info!(database_url = %redacted, "migrations completed"); + Ok(()) +} + +fn create_user(pool: &PgPool, username: &str) -> Result<()> { + let username = normalize_identifier( + username, + 100, + "username must not be empty", + "username must not exceed 100 characters", + Some("username may only contain printable characters"), + |ch| !ch.is_control(), + ) + .map_err(|err| anyhow!("{:?}", err))?; + + let mut conn = pool.get().context("failed to get database connection")?; + let exists: bool = + select(exists(users::table.filter(users::username.eq(&username)))).get_result(&mut conn)?; + if exists { + bail!("user '{}' already exists", username); + } + + let new_user = NewUser { + id: Uuid::new_v4(), + username: username.clone(), + }; + + diesel::insert_into(users::table) + .values(&new_user) + .execute(&mut conn)?; + + println!("created user '{}' (id: {})", username, new_user.id); + Ok(()) +} + +fn list_users(pool: &PgPool) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let users_list: Vec = users::table.order(users::username.asc()).load(&mut conn)?; + if users_list.is_empty() { + println!("No users found."); + return Ok(()); + } + + for user in users_list { + let memberships: Vec = user_memberships::table + .inner_join(tenants::table) + .filter(user_memberships::user_id.eq(user.id)) + .select(tenants::name) + .order(tenants::name.asc()) + .load(&mut conn)?; + + if memberships.is_empty() { + println!("{} ({})", user.username, user.id); + } else { + println!( + "{} ({}) -> {}", + user.username, + user.id, + memberships.join(", ") + ); + } + } + + Ok(()) +} + +fn delete_user(pool: &PgPool, username: &str) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let user: User = users::table + .filter(users::username.eq(username)) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("user '{}' not found", username))?; + + let has_memberships: bool = select(exists( + user_memberships::table.filter(user_memberships::user_id.eq(user.id)), + )) + .get_result(&mut conn)?; + if has_memberships { + bail!( + "user '{}' is still a member of one or more tenants; remove memberships first", + username + ); + } + + diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?; + + println!("deleted user '{}'", username); + Ok(()) +} + +fn create_tenant( + pool: &PgPool, + name: &str, + storage_root_arg: Option, + quickwit_index_arg: Option, +) -> Result<()> { + let service = TenantService::new(pool.clone()); + let tenant = service + .create_tenant( + name, + storage_root_arg.as_deref(), + quickwit_index_arg.as_deref(), + TenantStatus::Creating, + &[], + None, + ) + .map_err(|err| anyhow!(format!("{err:?}")))?; + + let storage_root = tenant.storage_root.as_deref().unwrap_or(""); + let quickwit_index = tenant.quickwit_index.as_deref().unwrap_or(""); + + println!( + "created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'", + tenant.name, + tenant.id, + storage_root, + quickwit_index, + tenant.status.as_str() + ); + Ok(()) +} + +fn create_magic_token( + pool: &PgPool, + username: &str, + ttl_minutes: i64, + max_uses: Option, + kind: MagicTokenKind, +) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let user: User = users::table + .filter(users::username.eq(username)) + .first(&mut conn) + .with_context(|| format!("user '{}' not found", username))?; + + let raw_token = generate_random_token(); + let token_hash = hash_token(&raw_token); + let expires_at = Utc::now() + ChronoDuration::minutes(ttl_minutes); + + let new_token = MagicToken { + id: Uuid::new_v4(), + user_id: user.id, + kind, + token_hash, + metadata: serde_json::json!({}), + expires_at: expires_at.naive_utc(), + max_uses, + used_count: 0, + created_at: Utc::now().naive_utc(), + created_by: None, + last_used_at: None, + }; + + diesel::insert_into(magic_tokens::table) + .values(&new_token) + .execute(&mut conn)?; + + println!( + "Magic token created for '{}' (kind: {}, expires_at: {}, max_uses: {})", + username, + kind.as_str(), + expires_at, + max_uses.map_or("∞".to_string(), |v| v.to_string()) + ); + println!("Token: {}", raw_token); + + Ok(()) +} + +fn generate_random_token() -> String { + let mut bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut bytes) + .expect("failed to read random bytes"); + hex::encode(bytes) +} + +fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn delete_tenant( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + tenant_name: &str, +) -> Result<()> { + enqueue_delete_tenant_job_internal(config, pool, tenant_id, tenant_name, true, None)?; + println!( + "delete job enqueued; tenant '{}' will be permanently removed", + tenant_name + ); + Ok(()) +} + +fn reset_tenant( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + tenant_name: &str, + final_status: TenantFinalStatusArg, +) -> Result<()> { + enqueue_delete_tenant_job_internal( + config, + pool, + tenant_id, + tenant_name, + false, + Some(final_status), + )?; + println!( + "reset job enqueued; tenant '{}' will be wiped and set to {}", + tenant_name, + final_status.as_str() + ); + Ok(()) +} + +fn enqueue_delete_tenant_job_internal( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + expected_name: &str, + remove_tenant: bool, + final_status: Option, +) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + apply_tenant_guc(&mut conn, tenant.id) + .map_err(|err| anyhow!("failed to set tenant context for {}: {err:?}", tenant.name))?; + + if tenant.name != expected_name { + bail!( + "tenant name mismatch: expected '{}', database has '{}'", + expected_name, + tenant.name + ); + } + + diesel::update(tenants::table.find(tenant.id)) + .set(tenants::status.eq(TenantStatus::Deleting)) + .execute(&mut conn)?; + + let nonce = generate_random_token(); + let issued_at = Utc::now(); + let issued_at_str = issued_at.to_rfc3339(); + let action = if remove_tenant { + DeleteAction::Delete + } else { + DeleteAction::Reset + }; + let resolved_final_status = if remove_tenant { + None + } else { + Some( + final_status + .unwrap_or(TenantFinalStatusArg::Suspended) + .as_str(), + ) + }; + + let message = build_delete_proof_message( + tenant.id, + expected_name, + action, + &nonce, + &issued_at_str, + resolved_final_status, + ); + let signature = sign_delete_proof(&config.jwt_secret, &message).map_err(|err| anyhow!(err))?; + + let mut payload = serde_json::json!({ + "remove_tenant": remove_tenant, + "tenant_name": tenant.name.clone(), + "action": action.as_str(), + "nonce": nonce, + "issued_at": issued_at_str, + "signature": signature, + }); + if let Some(status) = resolved_final_status { + payload["final_status"] = serde_json::json!(status); + } + + enqueue_job(&mut conn, tenant.id, JOB_DELETE_TENANT, payload, None)?; + let status_label = if remove_tenant { + "deleted" + } else { + final_status.map(|s| s.as_str()).unwrap_or("suspended") + }; + println!( + "delete-tenant job enqueued for '{}' (remove_tenant={}, final_status={})", + tenant.name, remove_tenant, status_label + ); + + clear_tenant_context(&mut conn).map_err(|err| { + anyhow!( + "failed to clear tenant context for {}: {err:?}", + tenant.name + ) + })?; + + Ok(()) +} + +fn enqueue_delete_tenant_job( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + tenant_name: &str, + remove_tenant: bool, + final_status: Option, +) -> Result<()> { + enqueue_delete_tenant_job_internal( + config, + pool, + tenant_id, + tenant_name, + remove_tenant, + final_status, + ) +} + +fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let user: User = users::table + .filter(users::username.eq(username)) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("user '{}' not found", username))?; + + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + let owner_capability_set_id = ensure_capability_set(&mut conn, tenant.id, owner_capabilities()) + .map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))? + .id; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id: user.id, + tenant_id: tenant.id, + capability_set_id: Some(owner_capability_set_id), + }; + + diesel::insert_into(user_memberships::table) + .values(&membership) + .on_conflict((user_memberships::user_id, user_memberships::tenant_id)) + .do_nothing() + .execute(&mut conn)?; + + println!("added user '{}' to tenant '{}'", username, tenant.name); + Ok(()) +} + +fn remove_user_from_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let user: User = users::table + .filter(users::username.eq(username)) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("user '{}' not found", username))?; + + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + let removed = diesel::delete( + user_memberships::table + .filter(user_memberships::user_id.eq(user.id)) + .filter(user_memberships::tenant_id.eq(tenant.id)), + ) + .execute(&mut conn)?; + + if removed == 0 { + println!( + "user '{}' was not a member of tenant '{}'", + username, tenant.name + ); + } else { + println!("removed user '{}' from tenant '{}'", username, tenant.name); + } + Ok(()) +} + +fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + let targets: Vec<(Uuid, Uuid)> = documents::table + .filter(documents::tenant_id.eq(tenant.id)) + .filter(documents::deleted_at.is_null()) + .select((documents::id, documents::current_version_id)) + .load(&mut conn)?; + + if targets.is_empty() { + println!("tenant '{}' has no active documents", tenant.name); + return Ok(()); + } + + let mut queued = 0usize; + for (document_id, version_id) in targets { + enqueue_job( + &mut conn, + tenant.id, + JOB_ANALYZE_DOCUMENT, + serde_json::json!({ + "document_id": document_id, + "document_version_id": version_id, + "force": true, + }), + None, + ) + .map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?; + queued += 1; + } + + println!( + "queued {} documents for re-analysis in tenant '{}'", + queued, tenant.name + ); + Ok(()) +} + +fn list_tenants(pool: &PgPool) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + let tenants: Vec = tenants::table + .order(tenants::name.asc()) + .load(&mut conn) + .context("failed to load tenants")?; + + if tenants.is_empty() { + println!("No tenants found."); + return Ok(()); + } + + for tenant in tenants { + println!("{} {}", tenant.id, tenant.name); + } + + Ok(()) +} + +async fn delete_assets_for_tenant( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + asset_type: Option<&str>, +) -> Result<()> { + let bucket = s3::build_bucket(config)?; + let storage: Arc = Arc::new(S3Storage::new(bucket)); + + let mut conn = pool.get().context("failed to get database connection")?; + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + apply_tenant_guc(&mut conn, tenant.id) + .map_err(|err| anyhow!("failed to set tenant context for {}: {err:?}", tenant.name))?; + + let result = async { + let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant) + .with_context(|| format!("missing storage root for tenant {}", tenant.name))?; + + let mut asset_query = document_assets::table + .filter(document_assets::tenant_id.eq(tenant.id)) + .into_boxed(); + + if let Some(asset_type) = asset_type { + asset_query = asset_query.filter(document_assets::asset_type.eq(asset_type)); + } + + let assets: Vec = asset_query + .load(&mut conn) + .with_context(|| format!("failed to load assets for tenant {}", tenant.name))?; + + if assets.is_empty() { + match asset_type { + Some(asset_type) => { + println!("Tenant {}: no assets of type '{}'", tenant.name, asset_type) + } + None => println!("Tenant {}: no assets", tenant.name), + } + return Ok(()); + } + + match asset_type { + Some(asset_type) => println!( + "Tenant {} ({}): deleting {} '{}' assets…", + tenant.name, + tenant.id, + assets.len(), + asset_type + ), + None => println!( + "Tenant {} ({}): deleting {} assets…", + tenant.name, + tenant.id, + assets.len() + ), + } + + let asset_ids: Vec = assets.iter().map(|asset| asset.id).collect(); + + for asset in &assets { + if let Err(err) = tenant_storage.delete_object(&asset.s3_key).await { + eprintln!( + "Failed to delete object {} (tenant {}): {err}", + asset.s3_key, tenant.name + ); + } + } + + diesel::delete( + document_assets::table + .filter(document_assets::tenant_id.eq(tenant.id)) + .filter(document_assets::id.eq_any(&asset_ids)), + ) + .execute(&mut conn) + .with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?; + + println!("Tenant {}: asset records deleted.", tenant.name); + Ok(()) + } + .await; + + clear_tenant_context(&mut conn).map_err(|err| { + anyhow!( + "failed to clear tenant context for {}: {err:?}", + tenant.name + ) + })?; + + result +} + +async fn quickwit_index( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + method: Method, +) -> Result<()> { + let endpoint = config + .quickwit_endpoint + .as_ref() + .ok_or_else(|| anyhow!("quickwit endpoint not configured"))?; + + let mut conn = pool.get().context("failed to get database connection")?; + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + let client = Client::new(); + let index_id = format!("documents-{}", tenant.id); + let base_endpoint = endpoint.trim_end_matches('/'); + + match method { + Method::POST => { + ensure_quickwit_index(&client, base_endpoint, &index_id) + .await + .context("failed to ensure quickwit index")?; + + diesel::update(tenants::table.filter(tenants::id.eq(tenant.id))) + .set(tenants::quickwit_index.eq(Some(index_id.clone()))) + .execute(&mut conn) + .context("failed to update tenant quickwit_index")?; + + println!( + "Tenant '{}' quickwit index set to '{}'.", + tenant.name, index_id + ); + } + Method::DELETE => { + let response = client + .delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id)) + .send() + .await + .context("failed to send delete index request")?; + + match response.status() { + status if status.is_success() || status == StatusCode::NOT_FOUND => { + diesel::update(tenants::table.filter(tenants::id.eq(tenant.id))) + .set(tenants::quickwit_index.eq::>(None)) + .execute(&mut conn) + .context("failed to clear tenant quickwit_index")?; + + println!("Tenant '{}' quickwit index cleared.", tenant.name); + } + status => { + let body = response.text().await.unwrap_or_default(); + bail!( + "quickwit delete index failed with status {}: {}", + status, + body + ); + } + } + } + _ => unreachable!(), + } + + Ok(()) +} diff --git a/backend/src/bin/openapi_dump.rs b/backend/src/bin/openapi_dump.rs new file mode 100644 index 0000000..0ad06a4 --- /dev/null +++ b/backend/src/bin/openapi_dump.rs @@ -0,0 +1,8 @@ +use papercrate::openapi::ApiDoc; +use utoipa::OpenApi; + +fn main() { + let spec = ApiDoc::openapi(); + let json = serde_json::to_string_pretty(&spec).expect("serialize openapi"); + println!("{}", json); +} diff --git a/backend/src/bin/webdav.rs b/backend/src/bin/webdav.rs new file mode 100644 index 0000000..8d5a85c --- /dev/null +++ b/backend/src/bin/webdav.rs @@ -0,0 +1,28 @@ +use std::net::SocketAddr; + +use tokio::net::TcpListener; +use tower::make::Shared; + +use papercrate::{routes::webdav, utils::bootstrap::init_component}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let state = init_component("webdav", None).await?; + let webdav_host = state.config.webdav_host.clone(); + let webdav_port = state.config.webdav_port; + tracing::info!( + component = "webdav", + webdav_host = %webdav_host, + webdav_port, + "starting webdav server" + ); + + let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?; + let router = webdav::create_router().with_state(state.as_ref().clone()); + + let listener = TcpListener::bind(listen_addr).await?; + tracing::info!("listening for WebDAV on {}", listen_addr); + + axum::serve(listener, Shared::new(router)).await?; + Ok(()) +} diff --git a/backend/src/bin/worker.rs b/backend/src/bin/worker.rs new file mode 100644 index 0000000..decc3c1 --- /dev/null +++ b/backend/src/bin/worker.rs @@ -0,0 +1,21 @@ +use std::time::Duration; + +use tokio::signal; + +use papercrate::{default_handlers, utils::bootstrap::init_component, Worker}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let state = init_component("worker", Some(1)).await?; + tracing::info!(component = "worker", "starting worker process"); + let worker = Worker::new(state, default_handlers(), Duration::from_secs(2)); + + tokio::select! { + _ = worker.run() => {} + _ = signal::ctrl_c() => { + tracing::info!("worker received shutdown signal"); + } + } + + Ok(()) +} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..81d0cc5 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,298 @@ +use anyhow::{Context, Result}; +use url::Url; + +use serde::de::Deserializer; +use serde::Deserialize; +use serde_aux::field_attributes::deserialize_bool_from_anything; + +use crate::db::DEFAULT_MAX_POOL_SIZE; + +#[derive(Clone, Debug, Deserialize)] +pub struct AppConfig { + pub database_url: String, + #[serde(default)] + pub migrations_database_url: Option, + #[serde(default = "default_database_max_pool_size")] + pub database_max_pool_size: u32, + #[serde(default = "default_server_host")] + pub server_host: String, + #[serde(default = "default_server_port")] + pub server_port: u16, + #[serde(default = "default_webdav_host")] + pub webdav_host: String, + #[serde(default = "default_webdav_port")] + pub webdav_port: u16, + pub jwt_secret: String, + #[serde(default = "default_jwt_issuer")] + pub jwt_issuer: String, + #[serde(default = "default_jwt_audience")] + pub jwt_audience: String, + #[serde(default = "default_jwt_expiry_minutes")] + pub jwt_expiry_minutes: i64, + #[serde(default = "default_download_token_audience")] + pub download_token_audience: String, + #[serde(default = "default_download_token_expiry_minutes")] + pub download_token_expiry_minutes: i64, + #[serde(default = "default_refresh_token_expiry_days")] + pub refresh_token_expiry_days: i64, + #[serde( + default = "default_refresh_cookie_secure", + deserialize_with = "deserialize_bool_from_anything" + )] + pub refresh_cookie_secure: bool, + #[serde(default)] + pub refresh_cookie_domain: Option, + #[serde(default)] + pub cors_allowed_origin: Option, + #[serde(default, deserialize_with = "deserialize_bool_from_anything")] + pub proxy_downloads: bool, + #[serde(default)] + pub aws_endpoint_url: Option, + #[serde(default)] + pub aws_access_key_id: Option, + #[serde(default)] + pub aws_secret_access_key: Option, + #[serde(default = "default_aws_region")] + pub aws_region: String, + pub s3_bucket: String, + #[serde(default)] + pub quickwit_endpoint: Option, + #[serde(default)] + pub quickwit_index: Option, + #[serde(default = "default_worker_max_document_bytes")] + pub worker_max_document_bytes: u64, + #[serde(default = "default_upload_body_limit_bytes")] + pub upload_body_limit_bytes: u64, + #[serde(default = "default_service_timezone")] + pub service_timezone: String, + #[serde(default = "default_issued_at_date_order")] + pub issued_at_date_order: String, + #[serde(default)] + pub issued_at_filename_date_order: Option, + #[serde(default, deserialize_with = "deserialize_string_list")] + pub issued_at_date_parser_locales: Vec, + #[serde(default, deserialize_with = "deserialize_string_list")] + pub issued_at_ignore_dates: Vec, + #[serde(default)] + pub webauthn_rp_id: Option, + #[serde(default)] + pub webauthn_origin: Option, + #[serde(default = "default_webauthn_rp_name")] + pub webauthn_rp_name: String, +} + +impl AppConfig { + pub fn load_and_log(component: &str) -> Result { + dotenv::dotenv().ok(); + let config = Self::from_env()?; + tracing::info!( + component, + database_url = %config.redacted_database_url(), + migrations_database_url = %config.redacted_migrations_database_url(), + pool_size = config.database_max_pool_size, + quickwit_enabled = config.quickwit_endpoint.is_some(), + passkeys_enabled = config.webauthn_origin.is_some(), + s3_bucket = %config.s3_bucket, + worker_max_document_bytes = config.worker_max_document_bytes, + upload_body_limit_bytes = config.upload_body_limit_bytes, + proxy_downloads = config.proxy_downloads, + "loaded backend configuration" + ); + Ok(config) + } + + pub fn from_env() -> Result { + let config: AppConfig = envy::from_env() + .context("failed to parse application configuration from environment")?; + Ok(config.normalize()) + } + + pub fn redacted_database_url(&self) -> String { + redact_database_url(&self.database_url) + } + + pub fn redacted_migrations_database_url(&self) -> String { + redact_database_url(self.migrations_database_url()) + } + + pub fn migrations_database_url(&self) -> &str { + if let Some(ref url) = self.migrations_database_url { + url + } else { + &self.database_url + } + } +} + +impl AppConfig { + fn normalize(mut self) -> Self { + if self.webdav_host.is_empty() { + self.webdav_host = self.server_host.clone(); + } + + if self.webauthn_rp_id.is_none() { + self.webauthn_rp_id = Some(self.server_host.clone()); + } + + if self.webauthn_origin.is_none() { + let scheme = if self.server_host == "127.0.0.1" || self.server_host == "localhost" { + "http" + } else { + "https" + }; + self.webauthn_origin = Some(format!( + "{scheme}://{}:{}", + self.server_host, self.server_port + )); + } + self + } +} + +fn default_database_max_pool_size() -> u32 { + DEFAULT_MAX_POOL_SIZE +} + +fn default_server_host() -> String { + "127.0.0.1".to_string() +} + +fn default_server_port() -> u16 { + 3000 +} + +fn default_webdav_host() -> String { + String::new() +} + +fn default_webdav_port() -> u16 { + 3001 +} + +fn default_jwt_issuer() -> String { + "papercrate".to_string() +} + +fn default_jwt_audience() -> String { + "papercrate-clients".to_string() +} + +fn default_jwt_expiry_minutes() -> i64 { + 60 +} + +fn default_download_token_audience() -> String { + "papercrate-download".to_string() +} + +fn default_download_token_expiry_minutes() -> i64 { + 60 +} + +fn default_refresh_token_expiry_days() -> i64 { + 30 +} + +fn default_refresh_cookie_secure() -> bool { + false +} + +fn default_aws_region() -> String { + "us-east-1".to_string() +} + +fn default_worker_max_document_bytes() -> u64 { + 200 * 1024 * 1024 +} + +fn default_upload_body_limit_bytes() -> u64 { + 128 * 1024 * 1024 +} + +fn default_service_timezone() -> String { + "UTC".to_string() +} + +fn default_issued_at_date_order() -> String { + "DMY".to_string() +} + +fn deserialize_string_list<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Helper { + List(Vec), + Single(String), + } + + let helper = Option::::deserialize(deserializer)?; + let mut values = Vec::new(); + + if let Some(helper) = helper { + match helper { + Helper::List(list) => { + for entry in list { + let trimmed = entry.trim(); + if !trimmed.is_empty() { + values.push(trimmed.to_string()); + } + } + } + Helper::Single(value) => { + for part in value.split(',') { + let trimmed = part.trim(); + if !trimmed.is_empty() { + values.push(trimmed.to_string()); + } + } + } + } + } + + Ok(values) +} + +fn default_webauthn_rp_name() -> String { + "Papercrate".to_string() +} + +pub fn redact_database_url(raw: &str) -> String { + match Url::parse(raw) { + Ok(mut parsed) => { + if parsed.password().is_some() { + let _ = parsed.set_password(Some("*****")); + parsed.to_string() + } else { + raw.to_string() + } + } + Err(_) => "***".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::redact_database_url; + + #[test] + fn redacts_password_in_database_url() { + let redacted = redact_database_url("postgres://user:secret@localhost/db"); + assert!(redacted.contains("postgres://user:*****@")); + assert!(!redacted.contains("secret")); + } + + #[test] + fn handles_url_without_password() { + let redacted = redact_database_url("postgres://localhost/db"); + assert_eq!(redacted, "postgres://localhost/db"); + } + + #[test] + fn falls_back_when_parse_fails() { + let redacted = redact_database_url("not a url"); + assert_eq!(redacted, "***"); + } +} diff --git a/backend/src/db.rs b/backend/src/db.rs new file mode 100644 index 0000000..7798c4c --- /dev/null +++ b/backend/src/db.rs @@ -0,0 +1,51 @@ +use std::time::Duration; + +use diesel::pg::PgConnection; +use diesel::r2d2::{ConnectionManager, CustomizeConnection, Pool}; +use diesel::RunQueryDsl; + +pub type PgPool = Pool>; + +pub const DEFAULT_MAX_POOL_SIZE: u32 = 2; + +#[derive(Debug)] +struct SchemaCustomizer; + +impl CustomizeConnection for SchemaCustomizer { + fn on_acquire(&self, conn: &mut PgConnection) -> Result<(), diesel::r2d2::Error> { + diesel::sql_query( + "SELECT set_config('search_path', ( + SELECT string_agg(schema_name, ', ') + FROM ( + SELECT 'tenant' AS schema_name WHERE EXISTS ( + SELECT 1 FROM pg_namespace WHERE nspname = 'tenant' + ) + UNION ALL + SELECT 'shared' AS schema_name WHERE EXISTS ( + SELECT 1 FROM pg_namespace WHERE nspname = 'shared' + ) + UNION ALL + SELECT 'public' AS schema_name + ) AS schemas + ), false)", + ) + .execute(conn) + .map(|_| ()) + .map_err(diesel::r2d2::Error::QueryError) + } +} + +pub fn init_pool(database_url: &str) -> anyhow::Result { + init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE) +} + +pub fn init_pool_with_size(database_url: &str, max_size: u32) -> anyhow::Result { + let manager = ConnectionManager::::new(database_url); + let pool_size = max_size.max(1); + let pool = Pool::builder() + .max_size(pool_size) + .connection_timeout(Duration::from_secs(10)) + .connection_customizer(Box::new(SchemaCustomizer)) + .build(manager)?; + Ok(pool) +} diff --git a/backend/src/documents/asset.rs b/backend/src/documents/asset.rs new file mode 100644 index 0000000..4ee4788 --- /dev/null +++ b/backend/src/documents/asset.rs @@ -0,0 +1,256 @@ +use std::collections::HashMap; +use std::path::Path as FsPath; + +use chrono::{Duration as ChronoDuration, Utc}; +use diesel::prelude::*; +use serde::Serialize; +use serde_json::Value; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::models::{Document, DocumentAsset, DocumentVersion}; +use crate::schema::{document_assets, document_versions}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::{http::inline_content_disposition, time::to_iso}; + +#[derive(Serialize, Clone, ToSchema)] +pub struct DownloadLink { + pub url: String, + pub expires_at: i64, +} + +#[derive(Serialize, Clone, ToSchema)] +pub struct DocumentAssetResponse { + pub id: Uuid, + pub asset_type: String, + pub mime_type: String, + #[schema(value_type = Object)] + pub metadata: Value, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub download: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentAssetDetailResponse { + pub id: Uuid, + pub asset_type: String, + pub mime_type: String, + #[schema(value_type = Object)] + pub metadata: Value, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub download: Option, +} + +#[derive(Serialize, Clone, ToSchema)] +pub struct DocumentVersionResponse { + pub id: Uuid, + pub version_number: i32, + pub size_bytes: i64, + pub checksum: String, + pub created_at: String, + #[schema(value_type = Object)] + pub metadata: Value, +} + +#[derive(Serialize, Clone, ToSchema)] +pub struct DocumentVersionDetailResponse { + #[serde(flatten)] + pub version: DocumentVersionResponse, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assets: Vec, + pub download: DownloadLink, +} + +pub fn build_download_link( + state: &AppState, + document: &Document, + version_id: Uuid, + user_id: Uuid, +) -> AppResult { + state + .jwt + .generate_download_token(document.id, version_id, user_id, document.tenant_id) + .map_err(|err| { + tracing::error!(error = ?err, "failed to generate download token"); + AppError::internal("failed to generate download token") + }) + .and_then(|token| { + let expires_at = Utc::now() + .checked_add_signed(ChronoDuration::minutes( + state.config.download_token_expiry_minutes, + )) + .ok_or_else(|| AppError::internal("failed to compute download expiry"))? + .timestamp_millis(); + + Ok(DownloadLink { + url: format!("/api/download/{token}"), + expires_at, + }) + }) +} + +pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse { + DocumentVersionResponse { + id: version.id, + version_number: version.version_number, + size_bytes: version.size_bytes, + checksum: version.checksum, + created_at: to_iso(version.created_at), + metadata: version.metadata, + } +} + +pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse { + DocumentAssetResponse { + id: asset.id, + asset_type: asset.asset_type, + mime_type: asset.mime_type, + metadata: asset.metadata, + download: None, + } +} + +pub fn to_asset_detail_response( + asset: DocumentAsset, + download: Option, +) -> DocumentAssetDetailResponse { + DocumentAssetDetailResponse { + id: asset.id, + asset_type: asset.asset_type, + mime_type: asset.mime_type, + metadata: asset.metadata, + created_at: to_iso(asset.created_at), + download, + } +} + +pub fn asset_disposition(asset: &DocumentAsset) -> Option { + let filename = asset.asset_type.clone(); + inline_content_disposition(&filename) +} + +pub fn delete_asset( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + asset_id: Uuid, +) -> AppResult<()> { + diesel::delete( + document_assets::table + .filter(document_assets::id.eq(asset_id)) + .filter(document_assets::tenant_id.eq(tenant_id)), + ) + .execute(conn)?; + + Ok(()) +} + +pub fn load_asset_responses_with_conn( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + version_id: Uuid, +) -> AppResult> { + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq(version_id)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .order(document_assets::created_at.asc()) + .load(conn)?; + + Ok(assets.into_iter().map(to_asset_summary).collect()) +} + +pub fn load_primary_assets( + conn: &mut PgPooledConnection, + documents: &[Document], +) -> AppResult)>> { + if documents.is_empty() { + return Ok(HashMap::new()); + } + + let mut doc_to_version: HashMap = HashMap::with_capacity(documents.len()); + let mut version_ids: Vec = Vec::with_capacity(documents.len()); + for doc in documents { + doc_to_version.insert(doc.id, doc.current_version_id); + version_ids.push(doc.current_version_id); + } + + version_ids.sort(); + version_ids.dedup(); + + let versions: Vec = document_versions::table + .filter(document_versions::id.eq_any(&version_ids)) + .load(conn)?; + + let mut version_map: HashMap = HashMap::new(); + for version in versions { + version_map.insert(version.id, version); + } + + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq_any(&version_ids)) + .order(( + document_assets::document_version_id.asc(), + document_assets::created_at.asc(), + )) + .load(conn)?; + + let mut assets_by_version: HashMap> = HashMap::new(); + for asset in assets { + let version_id = asset.document_version_id; + let response = to_asset_summary(asset); + assets_by_version + .entry(version_id) + .or_default() + .push(response); + } + + let mut result: HashMap)> = + HashMap::with_capacity(doc_to_version.len()); + for (doc_id, version_id) in doc_to_version { + if let Some(version) = version_map.remove(&version_id) { + let assets = assets_by_version.remove(&version_id).unwrap_or_default(); + result.insert(doc_id, (to_version_response(version), assets)); + } + } + + Ok(result) +} + +pub fn derive_document_title(original: &str) -> String { + let trimmed = original.trim(); + if trimmed.is_empty() { + return "Document".to_string(); + } + + let stem = FsPath::new(trimmed) + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + stem.unwrap_or_else(|| trimmed.to_string()) +} + +pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String { + let extension = FsPath::new(current_filename) + .extension() + .and_then(|ext| ext.to_str()); + + if let Some(ext) = extension { + if title + .rsplit_once('.') + .map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext)) + .unwrap_or(false) + { + title.to_string() + } else { + format!("{title}.{ext}") + } + } else { + title.to_string() + } +} diff --git a/backend/src/documents/correspondents.rs b/backend/src/documents/correspondents.rs new file mode 100644 index 0000000..5c9976e --- /dev/null +++ b/backend/src/documents/correspondents.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; + +use chrono::Utc; +use diesel::prelude::*; +use serde::Serialize; +use serde_json::Value; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::models::{Correspondent, DocumentCorrespondent, NewDocumentCorrespondent}; +use crate::schema::{correspondents, document_correspondents, documents}; +use crate::utils::time::to_iso; + +#[derive(Serialize, Clone, ToSchema)] +pub struct DocumentCorrespondentResponse { + pub id: Uuid, + pub name: String, + #[schema(value_type = Object)] + pub metadata: Value, + pub assigned_at: String, +} + +pub fn normalize_correspondent_ids(ids: &[Uuid]) -> AppResult> { + let mut unique: Vec = ids.iter().copied().collect(); + unique.sort_unstable(); + unique.dedup(); + + if unique.is_empty() { + return Err(AppError::bad_request( + "assignments must contain at least one correspondent", + )); + } + + Ok(unique) +} + +pub fn insert_document_correspondents( + conn: &mut PgConnection, + tenant_id: Uuid, + document_id: Uuid, + user_id: Uuid, + correspondent_ids: &[Uuid], +) -> AppResult { + let ids = normalize_correspondent_ids(correspondent_ids)?; + + let existing: Vec = correspondents::table + .filter(correspondents::id.eq_any(&ids)) + .filter(correspondents::tenant_id.eq(tenant_id)) + .select(correspondents::id) + .load(conn)?; + + if existing.len() != ids.len() { + return Err(AppError::bad_request( + "one or more correspondents do not exist", + )); + } + + let new_rows: Vec = ids + .into_iter() + .map(|correspondent_id| NewDocumentCorrespondent { + document_id, + correspondent_id, + assigned_by: Some(user_id), + tenant_id, + }) + .collect(); + + if new_rows.is_empty() { + return Ok(0); + } + + let inserted = diesel::insert_into(document_correspondents::table) + .values(&new_rows) + .on_conflict_do_nothing() + .execute(conn)?; + + if inserted > 0 { + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(inserted) +} + +pub fn load_correspondents_for_documents( + conn: &mut PgConnection, + document_ids: &[Uuid], +) -> AppResult>> { + if document_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table + .inner_join(correspondents::table) + .filter(document_correspondents::document_id.eq_any(document_ids)) + .order(( + document_correspondents::document_id.asc(), + document_correspondents::assigned_at.asc(), + )) + .load(conn)?; + + let mut map: HashMap> = HashMap::new(); + for (assignment, correspondent) in rows { + map.entry(assignment.document_id) + .or_default() + .push(DocumentCorrespondentResponse { + id: correspondent.id, + name: correspondent.name, + metadata: correspondent.metadata, + assigned_at: to_iso(assignment.assigned_at), + }); + } + + Ok(map) +} diff --git a/backend/src/documents/folders.rs b/backend/src/documents/folders.rs new file mode 100644 index 0000000..ee25e0c --- /dev/null +++ b/backend/src/documents/folders.rs @@ -0,0 +1,21 @@ +use diesel::dsl::exists; +use diesel::prelude::*; +use uuid::Uuid; + +use crate::error::AppResult; +use crate::schema::folders; +use crate::utils::validation::ensure_exists; + +pub fn ensure_folder_exists_on_conn( + conn: &mut PgConnection, + tenant_id: Uuid, + folder_id: Uuid, +) -> AppResult<()> { + let exists: bool = diesel::select(exists( + folders::table + .filter(folders::id.eq(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)), + )) + .get_result(conn)?; + ensure_exists(exists, "folder") +} diff --git a/backend/src/documents/metadata.rs b/backend/src/documents/metadata.rs new file mode 100644 index 0000000..88e3400 --- /dev/null +++ b/backend/src/documents/metadata.rs @@ -0,0 +1,52 @@ +use serde_json::{map::Entry, Map, Value}; + +use crate::error::{AppError, AppResult}; + +pub fn merge_document_metadata(existing: Value, updates: Value) -> AppResult { + let mut base = match existing { + Value::Object(map) => map, + Value::Null => Map::new(), + _ => { + return Err(AppError::bad_request( + "existing metadata is not an object; set replace=true to overwrite", + )); + } + }; + + let incoming = match updates { + Value::Object(map) => map, + _ => { + return Err(AppError::bad_request( + "metadata value must be a JSON object when replace is false", + )); + } + }; + + merge_metadata_maps(&mut base, incoming); + Ok(Value::Object(base)) +} + +fn merge_metadata_maps(target: &mut Map, updates: Map) { + for (key, value) in updates { + match target.entry(key) { + Entry::Occupied(mut entry) => { + let existing = entry.get_mut(); + match value { + Value::Object(update_map) => { + if let Value::Object(existing_map) = existing { + merge_metadata_maps(existing_map, update_map); + } else { + *existing = Value::Object(update_map); + } + } + other => { + *existing = other; + } + } + } + Entry::Vacant(entry) => { + entry.insert(value); + } + } + } +} diff --git a/backend/src/documents/mod.rs b/backend/src/documents/mod.rs new file mode 100644 index 0000000..97e08cb --- /dev/null +++ b/backend/src/documents/mod.rs @@ -0,0 +1,10 @@ +pub mod asset; +pub mod correspondents; +pub mod folders; +pub mod metadata; +pub mod ordering; +pub mod relations; +pub mod search; +pub mod tags; + +pub use ordering::{DocumentSortField, SortDirection}; diff --git a/backend/src/documents/ordering.rs b/backend/src/documents/ordering.rs new file mode 100644 index 0000000..1b895a2 --- /dev/null +++ b/backend/src/documents/ordering.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const UNICODE_COLLATION_NAME: &str = "unicode_ci"; +pub const UNICODE_COLLATION_LOCALE: &str = "und-u-ks-level2"; + +const TITLE_ASC: &str = "title COLLATE \"unicode_ci\" ASC"; +const TITLE_DESC: &str = "title COLLATE \"unicode_ci\" DESC"; +const ISSUED_AT_ASC: &str = "issued_at ASC NULLS LAST"; +const ISSUED_AT_DESC: &str = "issued_at DESC NULLS LAST"; +const CREATED_AT_ASC: &str = "created_at ASC"; +const CREATED_AT_DESC: &str = "created_at DESC"; +const UPDATED_AT_ASC: &str = "updated_at ASC"; +const UPDATED_AT_DESC: &str = "updated_at DESC"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum DocumentSortField { + Title, + IssuedAt, + CreatedAt, + UpdatedAt, +} + +impl Default for DocumentSortField { + fn default() -> Self { + DocumentSortField::Title + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SortDirection { + Asc, + Desc, +} + +impl Default for SortDirection { + fn default() -> Self { + SortDirection::Asc + } +} + +pub fn ordering_clauses( + field: DocumentSortField, + direction: SortDirection, +) -> (&'static str, Option<&'static str>) { + match (field, direction) { + (DocumentSortField::Title, SortDirection::Asc) => (TITLE_ASC, None), + (DocumentSortField::Title, SortDirection::Desc) => (TITLE_DESC, None), + (DocumentSortField::IssuedAt, SortDirection::Asc) => (ISSUED_AT_ASC, Some(TITLE_ASC)), + (DocumentSortField::IssuedAt, SortDirection::Desc) => (ISSUED_AT_DESC, Some(TITLE_ASC)), + (DocumentSortField::CreatedAt, SortDirection::Asc) => (CREATED_AT_ASC, Some(TITLE_ASC)), + (DocumentSortField::CreatedAt, SortDirection::Desc) => (CREATED_AT_DESC, Some(TITLE_ASC)), + (DocumentSortField::UpdatedAt, SortDirection::Asc) => (UPDATED_AT_ASC, Some(TITLE_ASC)), + (DocumentSortField::UpdatedAt, SortDirection::Desc) => (UPDATED_AT_DESC, Some(TITLE_ASC)), + } +} diff --git a/backend/src/documents/relations.rs b/backend/src/documents/relations.rs new file mode 100644 index 0000000..22ae92a --- /dev/null +++ b/backend/src/documents/relations.rs @@ -0,0 +1,33 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::documents::correspondents::{ + load_correspondents_for_documents, DocumentCorrespondentResponse, +}; +use crate::documents::tags::load_tags_for_documents; +use crate::error::AppResult; +use crate::models::Tag; +use crate::state::PgPooledConnection; + +/// Loads tags and correspondents for the provided documents in a single pass. +pub fn load_tags_and_correspondents( + conn: &mut PgPooledConnection, + document_ids: &[Uuid], +) -> AppResult, Vec)>> { + if document_ids.is_empty() { + return Ok(HashMap::new()); + } + + let tags_map = load_tags_for_documents(conn, document_ids)?; + let mut correspondents_map = load_correspondents_for_documents(conn, document_ids)?; + + let mut result = HashMap::with_capacity(document_ids.len()); + for id in document_ids { + let tags = tags_map.get(id).cloned().unwrap_or_default(); + let correspondents = correspondents_map.remove(id).unwrap_or_default(); + result.insert(*id, (tags, correspondents)); + } + + Ok(result) +} diff --git a/backend/src/documents/search.rs b/backend/src/documents/search.rs new file mode 100644 index 0000000..f238af7 --- /dev/null +++ b/backend/src/documents/search.rs @@ -0,0 +1,304 @@ +use std::collections::HashSet; + +use anyhow::{anyhow, bail, Result}; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tracing::{debug, error}; +use uuid::Uuid; + +use crate::models::{Document, DocumentVersion}; + +pub const QUICKWIT_MAX_HITS: usize = 200; + +pub fn build_quickwit_query(input: &str) -> Option { + let tokens: Vec = input + .split_whitespace() + .filter(|token| !token.is_empty()) + .map(|token| { + let normalized = token.to_lowercase(); + escape_quickwit_token(&normalized) + }) + .collect(); + + if tokens.is_empty() { + return None; + } + + let parts: Vec = tokens + .into_iter() + .map(|token| format!("(title:{token} OR text:{token})")) + .collect(); + + Some(parts.join(" AND ")) +} + +pub fn escape_quickwit_token(token: &str) -> String { + let mut escaped = String::with_capacity(token.len()); + for ch in token.chars() { + match ch { + '+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' + | '*' | '?' | ':' | '\\' | '/' => { + escaped.push('\\'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + escaped +} + +pub async fn quickwit_search( + endpoint: &str, + index: &str, + tenant_id: Uuid, + query: &str, +) -> Result> { + let tenant_clause = format!("tenant_id:{}", tenant_id); + let quickwit_query = match build_quickwit_query(query) { + Some(q) => { + debug!(%query, quickwit_query = %q, "built quickwit search query"); + format!("{} AND ({})", tenant_clause, q) + } + None => { + debug!(%query, "quickwit search skipped because query produced no tokens"); + return Ok(vec![]); + } + }; + + let client = Client::new(); + let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index); + + let payload = json!({ + "query": quickwit_query, + "max_hits": QUICKWIT_MAX_HITS, + }); + + debug!(%url, payload = %payload, "sending quickwit search request"); + let response = client.post(url).json(&payload).send().await?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + error!(%status, body = %body, "quickwit search request failed"); + return Err(anyhow!( + "quickwit search failed with status {status}: {body}" + )); + } + + let data: QuickwitSearchResponse = response.json().await?; + debug!("quickwit search response parsed successfully"); + let QuickwitSearchResponse { hits } = data; + + let total_hits = hits.len(); + let mut seen = HashSet::new(); + let mut doc_ids = Vec::with_capacity(total_hits); + + for hit in hits { + if let Some(doc_id) = extract_document_id(&hit) { + if seen.insert(doc_id) { + doc_ids.push(doc_id); + } + } + } + + debug!( + total_hits = total_hits, + unique_ids = doc_ids.len(), + "quickwit search completed" + ); + Ok(doc_ids) +} + +pub fn quickwit_index_template(index_id: &str) -> Value { + json!({ + "version": "0.8", + "index_id": index_id, + "doc_mapping": { + "tokenizers": [ + { + "name": "substring", + "type": "ngram", + "min_gram": 2, + "max_gram": 20, + "prefix_only": false + } + ], + "field_mappings": [ + { "name": "tenant_id", "type": "text", "stored": true }, + { "name": "document_id", "type": "text", "stored": true }, + { "name": "version_id", "type": "text", "stored": true }, + { "name": "title", "type": "text", "tokenizer": "substring", "stored": true }, + { "name": "text", "type": "text", "tokenizer": "substring", "record": "position" } + ] + }, + "search_settings": { + "default_search_fields": ["title", "text"] + } + }) +} + +pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> { + let base = endpoint.trim_end_matches('/'); + let create_url = format!("{}/api/v1/indexes", base); + let payload = quickwit_index_template(index_id); + + let response = client.post(&create_url).json(&payload).send().await?; + match response.status() { + status if status.is_success() => Ok(()), + StatusCode::CONFLICT => { + let lookup_url = format!("{}/api/v1/indexes/{}", base, index_id); + let lookup = client.get(&lookup_url).send().await?; + if lookup.status().is_success() { + Ok(()) + } else { + let status = lookup.status(); + let body = lookup.text().await.unwrap_or_default(); + bail!("quickwit index lookup failed with status {status}: {body}"); + } + } + status => { + let body = response.text().await.unwrap_or_default(); + bail!("quickwit create index failed with status {status}: {body}"); + } + } +} + +pub async fn delete_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> { + let base = endpoint.trim_end_matches('/'); + let url = format!("{}/api/v1/indexes/{}", base, index_id); + let response = client.delete(&url).send().await?; + match response.status() { + status if status.is_success() => Ok(()), + StatusCode::NOT_FOUND => Ok(()), + status => { + let body = response.text().await.unwrap_or_default(); + bail!("quickwit delete index failed with status {status}: {body}"); + } + } +} + +pub fn extract_document_id(hit: &Value) -> Option { + for key in ["_source", "source", "fields", "stored_fields"] { + if let Some(value) = hit.get(key) { + if let Some(uuid) = extract_uuid_from_value(value) { + return Some(uuid); + } + } + } + + if let Some(value) = hit.get("document_id") { + if let Some(uuid) = extract_uuid_from_value(value) { + return Some(uuid); + } + } + + None +} + +pub fn extract_uuid_from_value(value: &Value) -> Option { + if let Some(obj) = value.as_object() { + if let Some(inner) = obj.get("document_id") { + return parse_uuid_value(inner); + } + } + + if let Some(arr) = value.as_array() { + for item in arr { + if let Some(uuid) = extract_uuid_from_value(item) { + return Some(uuid); + } + } + } + + parse_uuid_value(value) +} + +pub fn parse_uuid_value(value: &Value) -> Option { + if let Some(s) = value.as_str() { + return Uuid::parse_str(s).ok(); + } + + if let Some(arr) = value.as_array() { + for item in arr { + if let Some(uuid) = parse_uuid_value(item) { + return Some(uuid); + } + } + } + + None +} + +#[derive(Deserialize)] +struct QuickwitSearchResponse { + #[serde(default)] + hits: Vec, +} + +#[derive(Serialize)] +pub struct QuickwitIngestRecord { + pub document_id: Uuid, + pub version_id: Uuid, + pub tenant_id: Uuid, + pub title: String, + pub text: String, +} + +pub fn build_quickwit_ingest_record( + document: &Document, + version: &DocumentVersion, + tenant_id: Uuid, + text: &str, +) -> QuickwitIngestRecord { + QuickwitIngestRecord { + document_id: document.id, + version_id: version.id, + tenant_id, + title: document.title.to_lowercase(), + text: text.to_lowercase(), + } +} + +pub async fn quickwit_ingest( + client: &Client, + endpoint: &str, + index: &str, + records: &[QuickwitIngestRecord], +) -> Result<()> { + if records.is_empty() { + return Ok(()); + } + + let url = format!( + "{}/api/v1/{}/ingest?commit=auto", + endpoint.trim_end_matches('/'), + index + ); + + let mut body = String::new(); + for record in records { + let line = serde_json::to_string(record)?; + body.push_str(&line); + body.push('\n'); + } + + debug!(%url, lines = records.len(), "sending quickwit ingest request"); + let response = client + .post(url) + .header("content-type", "application/x-ndjson") + .body(body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + error!(%status, %body, "quickwit ingest request failed"); + return Err(anyhow!( + "quickwit ingest failed with status {status}: {body}" + )); + } + + debug!("quickwit ingest request succeeded"); + Ok(()) +} diff --git a/backend/src/documents/tags.rs b/backend/src/documents/tags.rs new file mode 100644 index 0000000..2a35890 --- /dev/null +++ b/backend/src/documents/tags.rs @@ -0,0 +1,80 @@ +use std::collections::HashMap; + +use diesel::prelude::*; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::models::{Document, NewDocumentTag, Tag}; +use crate::schema::{document_tags, tags}; + +pub fn assign_tags( + conn: &mut PgConnection, + tenant_id: Uuid, + document: &Document, + raw_tag_ids: &[Uuid], + assigned_by: Option, +) -> AppResult { + if raw_tag_ids.is_empty() { + return Ok(0); + } + + let mut tag_ids: Vec = raw_tag_ids.iter().copied().collect(); + tag_ids.sort_unstable(); + tag_ids.dedup(); + + if tag_ids.is_empty() { + return Ok(0); + } + + let existing: Vec = tags::table + .filter(tags::id.eq_any(&tag_ids)) + .filter(tags::tenant_id.eq(tenant_id)) + .select(tags::id) + .load(conn)?; + + if existing.len() != tag_ids.len() { + return Err(AppError::bad_request("one or more tags do not exist")); + } + + let new_tags: Vec = tag_ids + .into_iter() + .map(|tag_id| NewDocumentTag { + document_id: document.id, + tag_id, + assigned_by, + tenant_id, + }) + .collect(); + + if new_tags.is_empty() { + return Ok(0); + } + + let inserted = diesel::insert_into(document_tags::table) + .values(&new_tags) + .on_conflict_do_nothing() + .execute(conn)?; + + Ok(inserted) +} + +pub fn load_tags_for_documents( + conn: &mut PgConnection, + document_ids: &[Uuid], +) -> AppResult>> { + if document_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows: Vec<(Uuid, Tag)> = document_tags::table + .inner_join(tags::table) + .filter(document_tags::document_id.eq_any(document_ids)) + .select((document_tags::document_id, tags::all_columns)) + .load(conn)?; + + let mut map: HashMap> = HashMap::new(); + for (doc_id, tag) in rows { + map.entry(doc_id).or_default().push(tag); + } + Ok(map) +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..3c4c855 --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,128 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use serde_json::Value; +use std::fmt::{self, Display}; +use utoipa::ToSchema; + +pub type AppResult = Result; + +#[derive(Debug)] +pub struct AppError { + status: StatusCode, + message: String, + code: Option, + details: Option, +} + +impl AppError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { + status, + message: message.into(), + code: None, + details: None, + } + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } + + pub fn conflict(message: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, message) + } + + pub fn unauthorized() -> Self { + Self::new(StatusCode::UNAUTHORIZED, "unauthorized") + } + + pub fn forbidden(message: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, message) + } + + pub fn not_found() -> Self { + Self::new(StatusCode::NOT_FOUND, "resource not found") + } + + pub fn internal(error: E) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) + } + + pub fn with_code(mut self, code: impl Into) -> Self { + self.code = Some(code.into()); + self + } + + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.status, self.message) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status; + let body = Json(ApiErrorResponse { + error: self.message, + code: self.code, + details: self.details, + }); + (status, body).into_response() + } +} + +#[derive(Serialize, ToSchema)] +pub struct ApiErrorResponse { + error: String, + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, +} + +impl From for AppError { + fn from(value: diesel::result::Error) -> Self { + match value { + diesel::result::Error::NotFound => AppError::not_found(), + other => { + let message = format!("database operation failed: {other}"); + tracing::error!(error = ?other, message); + AppError::internal(message) + } + } + } +} + +impl From for AppError { + fn from(value: jsonwebtoken::errors::Error) -> Self { + AppError::internal(value) + } +} + +impl From for AppError { + fn from(value: anyhow::Error) -> Self { + AppError::internal(value) + } +} + +impl From for AppError { + fn from(value: std::io::Error) -> Self { + AppError::internal(value) + } +} + +impl From for AppError { + fn from(value: serde_json::Error) -> Self { + AppError::internal(value) + } +} diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs new file mode 100644 index 0000000..3e7b0d6 --- /dev/null +++ b/backend/src/http/mod.rs @@ -0,0 +1 @@ +pub mod responders; diff --git a/backend/src/http/responders.rs b/backend/src/http/responders.rs new file mode 100644 index 0000000..2d30d02 --- /dev/null +++ b/backend/src/http/responders.rs @@ -0,0 +1,157 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; + +use crate::error::{AppError, AppResult}; + +/// Helper trait to convert error-centric results into the application's error type. +pub trait IntoAppResult { + fn into_app_result(self) -> AppResult; +} + +impl IntoAppResult for Result +where + AppError: From, +{ + fn into_app_result(self) -> AppResult { + self.map_err(AppError::from) + } +} + +/// Extension helpers for optional values to map them into `AppResult`. +pub trait OptionAppResultExt { + fn or_not_found(self) -> AppResult; + fn or_bad_request(self, message: impl Into) -> AppResult; +} + +impl OptionAppResultExt for Option { + fn or_not_found(self) -> AppResult { + self.ok_or_else(AppError::not_found) + } + + fn or_bad_request(self, message: impl Into) -> AppResult { + self.ok_or_else(|| AppError::bad_request(message)) + } +} + +/// Provides helpers for statements returning number of affected rows. +pub trait RowsAffectedExt: Sized { + fn or_error(self, error: AppError) -> AppResult; + fn or_not_found(self) -> AppResult { + self.or_error(AppError::not_found()) + } +} + +impl RowsAffectedExt for usize { + fn or_error(self, error: AppError) -> AppResult { + if self == 0 { + Err(error) + } else { + Ok(self) + } + } +} + +/// Wrapper providing a consistent JSON response with a status code. +pub struct JsonResponse { + status: StatusCode, + payload: T, +} + +impl JsonResponse { + pub fn new(status: StatusCode, payload: T) -> Self { + Self { status, payload } + } + + pub fn ok(payload: T) -> Self { + Self::new(StatusCode::OK, payload) + } + + pub fn created(payload: T) -> Self { + Self::new(StatusCode::CREATED, payload) + } + + pub fn accepted(payload: T) -> Self { + Self::new(StatusCode::ACCEPTED, payload) + } + + pub fn into_inner(self) -> T { + self.payload + } + + pub fn as_inner(&self) -> &T { + &self.payload + } +} + +impl From for JsonResponse { + fn from(value: T) -> Self { + Self::ok(value) + } +} + +impl IntoResponse for JsonResponse +where + T: Serialize, +{ + fn into_response(self) -> Response { + (self.status, Json(self.payload)).into_response() + } +} + +/// Helper for returning empty responses with a status code. +pub fn empty(status: StatusCode) -> AppResult { + Ok(status) +} + +/// Helper for returning `204 No Content`. +pub fn no_content() -> AppResult { + empty(StatusCode::NO_CONTENT) +} + +/// Helper for returning JSON payloads with `200 OK`. +pub fn ok_json(value: T) -> AppResult> +where + T: Serialize, +{ + Ok(JsonResponse::ok(value)) +} + +/// Helper for returning JSON payloads with `201 Created`. +pub fn created_json(value: T) -> AppResult> +where + T: Serialize, +{ + Ok(JsonResponse::created(value)) +} + +/// Helper for returning JSON payloads with `202 Accepted`. +pub fn accepted_json(value: T) -> AppResult> +where + T: Serialize, +{ + Ok(JsonResponse::accepted(value)) +} + +/// Standard wrapper for paginated responses. +#[derive(Serialize)] +pub struct PaginatedResponse +where + T: Serialize, + M: Serialize, +{ + pub data: T, + pub meta: M, +} + +pub fn paginated_json(data: T, meta: M) -> AppResult>> +where + T: Serialize, + M: Serialize, +{ + let payload = PaginatedResponse { data, meta }; + Ok(JsonResponse::ok(payload)) +} diff --git a/backend/src/issued_at.rs b/backend/src/issued_at.rs new file mode 100644 index 0000000..f70487e --- /dev/null +++ b/backend/src/issued_at.rs @@ -0,0 +1,167 @@ +use std::collections::HashSet; + +use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; +use once_cell::sync::Lazy; +use tracing::warn; + +use crate::config::AppConfig; + +#[derive(Clone, Copy, Debug)] +pub enum DateOrder { + Dmy, + Mdy, + Ymd, +} + +impl DateOrder { + pub fn parse(value: &str) -> Self { + Self::try_parse(value).unwrap_or(DateOrder::Dmy) + } + + pub fn try_parse(value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "YMD" => Some(DateOrder::Ymd), + "MDY" => Some(DateOrder::Mdy), + "DMY" => Some(DateOrder::Dmy), + _ => None, + } + } +} + +static MIN_ISSUED_AT_DATE: Lazy = + Lazy::new(|| NaiveDate::from_ymd_opt(1901, 1, 1).expect("valid minimum issued_at date")); + +#[derive(Clone, Debug)] +pub struct IssuedAtSettings { + pub timezone: Tz, + pub date_order: DateOrder, + pub filename_date_order: Option, + pub locales: HashSet, + pub ignore_dates: HashSet, + pub min_date: NaiveDate, +} + +impl IssuedAtSettings { + pub fn from_config(config: &AppConfig) -> Self { + let timezone = config.service_timezone.parse::().unwrap_or_else(|_| { + warn!( + timezone = %config.service_timezone, + "invalid service timezone configured; falling back to UTC" + ); + chrono_tz::UTC + }); + + let date_order = DateOrder::parse(&config.issued_at_date_order); + let filename_date_order = + config + .issued_at_filename_date_order + .as_deref() + .and_then(|value| { + DateOrder::try_parse(value).or_else(|| { + warn!(value, "invalid issued_at filename date order; ignoring"); + None + }) + }); + + let locales = config + .issued_at_date_parser_locales + .iter() + .filter_map(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_ascii_lowercase()) + } + }) + .collect::>(); + + // Ignore dates are evaluated after normalizing candidate timestamps to + // the configured service timezone, so administrators should provide + // local calendar dates rather than UTC midnights. + let ignore_dates = config + .issued_at_ignore_dates + .iter() + .filter_map(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + match NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") { + Ok(date) => Some(date), + Err(err) => { + warn!(value = trimmed, error = %err, "invalid issued_at ignore date"); + None + } + } + }) + .collect(); + + Self { + timezone, + date_order, + filename_date_order, + locales, + ignore_dates, + min_date: *MIN_ISSUED_AT_DATE, + } + } + + /// Returns the immutable (lowercase) locale allowlist supplied via config. + pub fn locales(&self) -> &HashSet { + &self.locales + } + + /// Returns the immutable set of local-calendar dates that should be ignored. + pub fn ignore_dates(&self) -> &HashSet { + &self.ignore_dates + } + + /// Returns the configured service timezone (copy type). + pub fn timezone(&self) -> Tz { + self.timezone + } + + pub fn date_order(&self) -> DateOrder { + self.date_order + } + + pub fn filename_date_order(&self) -> Option { + self.filename_date_order + } + + pub fn normalize_naive( + &self, + date: NaiveDate, + now_utc: chrono::DateTime, + ) -> Option> { + if !self.is_valid_with_now(date, now_utc) { + return None; + } + self.timezone + .with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0) + .earliest() + .map(|dt| dt.with_timezone(&Utc)) + } + + pub fn normalize_datetime( + &self, + dt: chrono::DateTime, + now_utc: chrono::DateTime, + ) -> Option> { + let local_date = dt.with_timezone(&self.timezone).date_naive(); + self.normalize_naive(local_date, now_utc) + } + + fn is_valid_with_now(&self, date: NaiveDate, now_utc: chrono::DateTime) -> bool { + if date < self.min_date { + return false; + } + let now_local = now_utc.with_timezone(&self.timezone).date_naive(); + if date > now_local { + return false; + } + !self.ignore_dates.contains(&date) + } +} diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs new file mode 100644 index 0000000..9d1df2a --- /dev/null +++ b/backend/src/jobs.rs @@ -0,0 +1,133 @@ +use std::time::Duration; + +use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; +use diesel::pg::PgConnection; +use diesel::prelude::*; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +use crate::models::{Job, NewJob}; +use crate::schema::jobs; + +pub const STATUS_QUEUED: &str = "queued"; +pub const STATUS_PROCESSING: &str = "processing"; +pub const STATUS_SUCCEEDED: &str = "succeeded"; +pub const STATUS_FAILED: &str = "failed"; + +pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document"; +pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails"; +pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text"; +pub const JOB_PROVISION_TENANT: &str = "provision-tenant"; +pub const JOB_PURGE_DOCUMENT: &str = "purge-document"; +pub const JOB_DELETE_TENANT: &str = "delete-tenant"; + +#[derive(Debug, Error)] +pub enum JobQueueError { + #[error("database error: {0}")] + Database(#[from] diesel::result::Error), +} + +pub type JobQueueResult = Result; + +pub fn enqueue_job( + conn: &mut PgConnection, + tenant_id: Uuid, + job_type: &str, + payload: Value, + run_after: Option, +) -> JobQueueResult { + let new_job = NewJob { + id: Uuid::new_v4(), + job_type: job_type.to_string(), + payload, + status: STATUS_QUEUED.to_string(), + run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()), + tenant_id, + }; + + diesel::insert_into(jobs::table) + .values(&new_job) + .execute(conn)?; + + let job = jobs::table.find(new_job.id).first(conn)?; + Ok(job) +} + +pub fn reserve_job(conn: &mut PgConnection, job_types: &[&str]) -> JobQueueResult> { + let now = Utc::now().naive_utc(); + + conn.transaction(|conn| { + let job_opt = jobs::table + .filter(jobs::status.eq(STATUS_QUEUED)) + .filter(jobs::run_after.le(now)) + .filter(jobs::job_type.eq_any(job_types)) + .order(jobs::run_after.asc()) + .for_update() + .skip_locked() + .first::(conn) + .optional()?; + + if let Some(job) = job_opt { + diesel::update(jobs::table.find(job.id)) + .set(( + jobs::status.eq(STATUS_PROCESSING), + jobs::attempts.eq(job.attempts + 1), + jobs::updated_at.eq(now), + )) + .execute(conn)?; + + let refreshed = jobs::table.find(job.id).first(conn)?; + Ok::, diesel::result::Error>(Some(refreshed)) + } else { + Ok::, diesel::result::Error>(None) + } + }) + .map_err(JobQueueError::from) +} + +pub fn mark_job_succeeded(conn: &mut PgConnection, job_id: Uuid) -> JobQueueResult<()> { + diesel::update(jobs::table.find(job_id)) + .set(( + jobs::status.eq(STATUS_SUCCEEDED), + jobs::last_error.eq::>(None), + jobs::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + Ok(()) +} + +pub fn retry_job_after( + conn: &mut PgConnection, + job_id: Uuid, + delay: Duration, + error_message: &str, +) -> JobQueueResult<()> { + let next_run = Utc::now() + + ChronoDuration::from_std(delay).unwrap_or_else(|_| ChronoDuration::seconds(30)); + + diesel::update(jobs::table.find(job_id)) + .set(( + jobs::status.eq(STATUS_QUEUED), + jobs::run_after.eq(next_run.naive_utc()), + jobs::last_error.eq(Some(error_message.to_string())), + jobs::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + Ok(()) +} + +pub fn mark_job_failed( + conn: &mut PgConnection, + job_id: Uuid, + error_message: &str, +) -> JobQueueResult<()> { + diesel::update(jobs::table.find(job_id)) + .set(( + jobs::status.eq(STATUS_FAILED), + jobs::last_error.eq(Some(error_message.to_string())), + jobs::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + Ok(()) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..fc18edd --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,22 @@ +pub mod auth; +pub mod config; +pub mod db; +pub mod documents; +pub mod error; +pub mod http; +pub mod issued_at; +pub mod jobs; +pub mod models; +pub mod openapi; +pub mod routes; +pub mod s3; +pub mod schema; +pub mod services; +pub mod state; +pub mod storage; +pub mod tenants; +pub mod utils; +pub mod workers; +pub use workers::{default_handlers, Worker}; +pub mod migrations; +pub mod test_support; diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..212c59e --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,28 @@ +use std::net::SocketAddr; + +use tokio::net::TcpListener; +use tower::make::Shared; + +use papercrate::{routes, utils::bootstrap::init_component}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let state = init_component("api", None).await?; + let server_host = state.config.server_host.clone(); + let server_port = state.config.server_port; + tracing::info!( + component = "api", + server_host = %server_host, + server_port, + "starting api server" + ); + + let router = routes::create_router(state.as_ref().clone()); + + let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?; + let listener = TcpListener::bind(addr).await?; + tracing::info!("listening on {}", addr); + + axum::serve(listener, Shared::new(router)).await?; + Ok(()) +} diff --git a/backend/src/migrations.rs b/backend/src/migrations.rs new file mode 100644 index 0000000..d10cb83 --- /dev/null +++ b/backend/src/migrations.rs @@ -0,0 +1,3 @@ +use diesel_migrations::{embed_migrations, EmbeddedMigrations}; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); diff --git a/backend/src/models.rs b/backend/src/models.rs new file mode 100644 index 0000000..c850062 --- /dev/null +++ b/backend/src/models.rs @@ -0,0 +1,775 @@ +use chrono::NaiveDateTime; +use diesel::deserialize::FromSql; +use diesel::pg::{Pg, PgValue}; +use diesel::prelude::*; +use diesel::serialize::{IsNull, Output, ToSql}; +use diesel::{deserialize, serialize, AsExpression, FromSqlRow}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt; +use std::io::Write; +use std::str; +use uuid::Uuid; + +use utoipa::ToSchema; + +use crate::schema::sql_types::{ + ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql, + TenantStatus as TenantStatusSql, +}; +use crate::schema::*; + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = user_memberships)] +#[diesel(belongs_to(User, foreign_key = user_id))] +#[diesel(belongs_to(Tenant, foreign_key = tenant_id))] +pub struct UserMembership { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub capability_set_id: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = user_memberships)] +pub struct NewUserMembership { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub capability_set_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)] +#[diesel(sql_type = TenantStatusSql)] +pub enum TenantStatus { + Creating, + Active, + Suspended, + Deleting, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)] +#[diesel(sql_type = MagicTokenKindSql)] +pub enum MagicTokenKind { + EmailLogin, + DemoLogin, +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + AsExpression, + FromSqlRow, + Serialize, + Deserialize, + ToSchema, +)] +#[diesel(sql_type = ApiCapabilitySql)] +pub enum ApiCapability { + #[serde(rename = "documents:read")] + DocumentsRead, + #[serde(rename = "documents:edit")] + DocumentsEdit, + #[serde(rename = "documents:write")] + DocumentsWrite, + #[serde(rename = "documents:upload")] + DocumentsUpload, + #[serde(rename = "folders:read")] + FoldersRead, + #[serde(rename = "folders:edit")] + FoldersEdit, + #[serde(rename = "folders:write")] + FoldersWrite, + #[serde(rename = "tags:read")] + TagsRead, + #[serde(rename = "tags:edit")] + TagsEdit, + #[serde(rename = "tags:write")] + TagsWrite, + #[serde(rename = "correspondents:read")] + CorrespondentsRead, + #[serde(rename = "correspondents:edit")] + CorrespondentsEdit, + #[serde(rename = "correspondents:write")] + CorrespondentsWrite, + #[serde(rename = "profile:read")] + ProfileRead, + #[serde(rename = "profile:write")] + ProfileWrite, + #[serde(rename = "webdav:read")] + WebdavRead, + #[serde(rename = "webdav:write")] + WebdavWrite, + #[serde(rename = "capability_sets:read")] + CapabilitySetsRead, + #[serde(rename = "capability_sets:write")] + CapabilitySetsWrite, + #[serde(rename = "tenants:write")] + TenantsWrite, + #[serde(rename = "tenants:reset")] + TenantsReset, + #[serde(rename = "tenants:delete")] + TenantsDelete, +} + +impl MagicTokenKind { + pub fn as_str(&self) -> &'static str { + match self { + MagicTokenKind::EmailLogin => "email_login", + MagicTokenKind::DemoLogin => "demo_login", + } + } + + pub fn variants() -> &'static [&'static str] { + &["email_login", "demo_login"] + } +} + +impl ApiCapability { + pub fn as_str(&self) -> &'static str { + match self { + ApiCapability::DocumentsRead => "documents:read", + ApiCapability::DocumentsEdit => "documents:edit", + ApiCapability::DocumentsWrite => "documents:write", + ApiCapability::DocumentsUpload => "documents:upload", + ApiCapability::FoldersRead => "folders:read", + ApiCapability::FoldersEdit => "folders:edit", + ApiCapability::FoldersWrite => "folders:write", + ApiCapability::TagsRead => "tags:read", + ApiCapability::TagsEdit => "tags:edit", + ApiCapability::TagsWrite => "tags:write", + ApiCapability::CorrespondentsRead => "correspondents:read", + ApiCapability::CorrespondentsEdit => "correspondents:edit", + ApiCapability::CorrespondentsWrite => "correspondents:write", + ApiCapability::ProfileRead => "profile:read", + ApiCapability::ProfileWrite => "profile:write", + ApiCapability::WebdavRead => "webdav:read", + ApiCapability::WebdavWrite => "webdav:write", + ApiCapability::CapabilitySetsRead => "capability_sets:read", + ApiCapability::CapabilitySetsWrite => "capability_sets:write", + ApiCapability::TenantsWrite => "tenants:write", + ApiCapability::TenantsReset => "tenants:reset", + ApiCapability::TenantsDelete => "tenants:delete", + } + } + + pub fn variants() -> &'static [&'static str] { + &[ + "documents:read", + "documents:edit", + "documents:write", + "documents:upload", + "folders:read", + "folders:edit", + "folders:write", + "tags:read", + "tags:edit", + "tags:write", + "correspondents:read", + "correspondents:edit", + "correspondents:write", + "profile:read", + "profile:write", + "webdav:read", + "webdav:write", + "capability_sets:read", + "capability_sets:write", + "tenants:write", + "tenants:reset", + "tenants:delete", + ] + } +} + +impl fmt::Display for MagicTokenKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl fmt::Display for ApiCapability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl ToSql for MagicTokenKind { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.as_str().as_bytes())?; + Ok(IsNull::No) + } +} + +impl ToSql for ApiCapability { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.as_str().as_bytes())?; + Ok(IsNull::No) + } +} + +impl FromSql for MagicTokenKind { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + match std::str::from_utf8(bytes.as_bytes())? { + "email_login" => Ok(MagicTokenKind::EmailLogin), + "demo_login" => Ok(MagicTokenKind::DemoLogin), + other => Err(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid magic_token_kind '{other}'"), + ))), + } + } +} + +impl FromSql for ApiCapability { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + match std::str::from_utf8(bytes.as_bytes())? { + "documents:read" => Ok(ApiCapability::DocumentsRead), + "documents:edit" => Ok(ApiCapability::DocumentsEdit), + "documents:write" => Ok(ApiCapability::DocumentsWrite), + "documents:upload" => Ok(ApiCapability::DocumentsUpload), + "folders:read" => Ok(ApiCapability::FoldersRead), + "folders:edit" => Ok(ApiCapability::FoldersEdit), + "folders:write" => Ok(ApiCapability::FoldersWrite), + "tags:read" => Ok(ApiCapability::TagsRead), + "tags:edit" => Ok(ApiCapability::TagsEdit), + "tags:write" => Ok(ApiCapability::TagsWrite), + "correspondents:read" => Ok(ApiCapability::CorrespondentsRead), + "correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit), + "correspondents:write" => Ok(ApiCapability::CorrespondentsWrite), + "profile:read" => Ok(ApiCapability::ProfileRead), + "profile:write" => Ok(ApiCapability::ProfileWrite), + "webdav:read" => Ok(ApiCapability::WebdavRead), + "webdav:write" => Ok(ApiCapability::WebdavWrite), + "capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead), + "capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite), + "tenants:write" => Ok(ApiCapability::TenantsWrite), + "tenants:reset" => Ok(ApiCapability::TenantsReset), + "tenants:delete" => Ok(ApiCapability::TenantsDelete), + other => Err(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid api_capability '{other}'"), + ))), + } + } +} + +impl str::FromStr for MagicTokenKind { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value { + "email_login" => Ok(MagicTokenKind::EmailLogin), + "demo_login" => Ok(MagicTokenKind::DemoLogin), + _ => Err("unsupported magic token kind"), + } + } +} + +impl str::FromStr for ApiCapability { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value { + "documents:read" => Ok(ApiCapability::DocumentsRead), + "documents:edit" => Ok(ApiCapability::DocumentsEdit), + "documents:write" => Ok(ApiCapability::DocumentsWrite), + "documents:upload" => Ok(ApiCapability::DocumentsUpload), + "folders:read" => Ok(ApiCapability::FoldersRead), + "folders:edit" => Ok(ApiCapability::FoldersEdit), + "folders:write" => Ok(ApiCapability::FoldersWrite), + "tags:read" => Ok(ApiCapability::TagsRead), + "tags:edit" => Ok(ApiCapability::TagsEdit), + "tags:write" => Ok(ApiCapability::TagsWrite), + "correspondents:read" => Ok(ApiCapability::CorrespondentsRead), + "correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit), + "correspondents:write" => Ok(ApiCapability::CorrespondentsWrite), + "profile:read" => Ok(ApiCapability::ProfileRead), + "profile:write" => Ok(ApiCapability::ProfileWrite), + "webdav:read" => Ok(ApiCapability::WebdavRead), + "webdav:write" => Ok(ApiCapability::WebdavWrite), + "capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead), + "capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite), + "tenants:write" => Ok(ApiCapability::TenantsWrite), + "tenants:reset" => Ok(ApiCapability::TenantsReset), + "tenants:delete" => Ok(ApiCapability::TenantsDelete), + _ => Err("unsupported api capability"), + } + } +} + +impl TenantStatus { + pub fn as_str(&self) -> &'static str { + match self { + TenantStatus::Creating => "creating", + TenantStatus::Active => "active", + TenantStatus::Suspended => "suspended", + TenantStatus::Deleting => "deleting", + TenantStatus::Error => "error", + } + } + + pub fn from_str(value: &str) -> Option { + match value { + "creating" => Some(TenantStatus::Creating), + "active" => Some(TenantStatus::Active), + "suspended" => Some(TenantStatus::Suspended), + "deleting" => Some(TenantStatus::Deleting), + "error" => Some(TenantStatus::Error), + _ => None, + } + } +} + +impl fmt::Display for TenantStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl ToSql for TenantStatus { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.as_str().as_bytes())?; + Ok(IsNull::No) + } +} + +impl FromSql for TenantStatus { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = str::from_utf8(bytes.as_bytes()) + .map_err(|err| Box::::from(err))?; + TenantStatus::from_str(value).ok_or_else(|| { + Box::::from(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid tenant status '{value}'"), + )) + }) + } +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = tenants)] +#[diesel(primary_key(id))] +pub struct Tenant { + pub id: Uuid, + pub name: String, + pub storage_root: Option, + pub quickwit_index: Option, + pub config: Value, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub status: TenantStatus, + pub created_by: Option, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = users)] +pub struct User { + pub id: Uuid, + pub username: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = users)] +pub struct NewUser { + pub id: Uuid, + pub username: String, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)] +#[diesel(table_name = user_passkeys)] +#[diesel(belongs_to(User))] +pub struct UserPasskey { + pub id: Uuid, + pub user_id: Uuid, + pub credential_id: Vec, + pub public_key: Vec, + pub credential: serde_json::Value, + pub sign_count: i64, + pub transports: Vec>, + pub aaguid: Option, + pub nickname: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub last_used_at: Option, + pub revoked_at: Option, + pub revoked_by: Option, + pub revoked_reason: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = user_passkeys)] +pub struct NewUserPasskey { + pub id: Uuid, + pub user_id: Uuid, + pub credential_id: Vec, + pub public_key: Vec, + pub credential: serde_json::Value, + pub sign_count: i64, + pub transports: Vec>, + pub aaguid: Option, + pub nickname: Option, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = webauthn_challenges)] +#[diesel(belongs_to(User))] +pub struct WebauthnChallenge { + pub id: Uuid, + pub user_id: Option, + pub purpose: String, + pub challenge: Vec, + pub state: Vec, + pub created_at: NaiveDateTime, + pub expires_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = webauthn_challenges)] +pub struct NewWebauthnChallenge { + pub id: Uuid, + pub user_id: Option, + pub purpose: String, + pub challenge: Vec, + pub state: Vec, + pub expires_at: NaiveDateTime, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = api_tokens)] +#[diesel(belongs_to(User))] +#[diesel(belongs_to(Tenant))] +pub struct ApiToken { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub token_prefix: String, + pub token_hash: String, + pub label: Option, + pub created_at: NaiveDateTime, + pub last_used_at: Option, + pub expires_at: Option, + pub revoked_at: Option, + pub capability_set_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = capability_sets)] +#[diesel(belongs_to(Tenant))] +pub struct CapabilitySet { + pub id: Uuid, + pub tenant_id: Uuid, + pub slug: String, + pub cap_version: i32, + pub is_system: bool, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = capability_sets)] +pub struct NewCapabilitySet { + pub id: Uuid, + pub tenant_id: Uuid, + pub slug: String, + pub cap_version: i32, + pub is_system: bool, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = capability_set_capabilities)] +#[diesel(primary_key(capability_set_id, capability))] +#[diesel(belongs_to(CapabilitySet, foreign_key = capability_set_id))] +pub struct CapabilitySetCapability { + pub capability_set_id: Uuid, + pub capability: ApiCapability, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = capability_set_capabilities)] +pub struct NewCapabilitySetCapability { + pub capability_set_id: Uuid, + pub capability: ApiCapability, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = api_tokens)] +pub struct NewApiToken { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub token_prefix: String, + pub token_hash: String, + pub label: Option, + pub expires_at: Option, + pub capability_set_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = folders)] +pub struct Folder { + pub id: Uuid, + pub name: String, + pub parent_id: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = folders)] +pub struct NewFolder { + pub id: Uuid, + pub name: String, + pub parent_id: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = documents)] +#[diesel(belongs_to(Folder, foreign_key = folder_id))] +pub struct Document { + pub id: Uuid, + pub filename: String, + pub original_name: String, + pub mime_type: Option, + pub folder_id: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub deleted_at: Option, + pub metadata: serde_json::Value, + pub issued_at: Option, + pub title: String, + pub current_version_id: Uuid, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = documents)] +pub struct NewDocument { + pub id: Uuid, + pub filename: String, + pub original_name: String, + pub mime_type: Option, + pub folder_id: Option, + pub current_version_id: Uuid, + pub metadata: serde_json::Value, + pub issued_at: Option, + pub title: String, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Insertable)] +#[diesel(table_name = magic_tokens)] +pub struct MagicToken { + pub id: Uuid, + pub user_id: Uuid, + pub kind: MagicTokenKind, + pub token_hash: String, + pub metadata: serde_json::Value, + pub expires_at: NaiveDateTime, + pub max_uses: Option, + pub used_count: i32, + pub created_at: NaiveDateTime, + pub created_by: Option, + pub last_used_at: Option, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = document_versions)] +#[diesel(belongs_to(Document))] +pub struct DocumentVersion { + pub id: Uuid, + pub document_id: Uuid, + pub version_number: i32, + pub s3_key: String, + pub size_bytes: i64, + pub checksum: String, + pub created_at: NaiveDateTime, + pub metadata: serde_json::Value, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_versions)] +pub struct NewDocumentVersion { + pub id: Uuid, + pub document_id: Uuid, + pub version_number: i32, + pub s3_key: String, + pub size_bytes: i64, + pub checksum: String, + pub metadata: serde_json::Value, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = document_assets)] +#[diesel(belongs_to(DocumentVersion, foreign_key = document_version_id))] +pub struct DocumentAsset { + pub id: Uuid, + pub document_version_id: Uuid, + pub asset_type: String, + pub mime_type: String, + pub metadata: serde_json::Value, + pub created_at: NaiveDateTime, + pub s3_key: String, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_assets)] +pub struct NewDocumentAsset { + pub id: Uuid, + pub document_version_id: Uuid, + pub asset_type: String, + pub mime_type: String, + pub metadata: serde_json::Value, + pub s3_key: String, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = jobs)] +pub struct Job { + pub id: Uuid, + pub job_type: String, + pub payload: serde_json::Value, + pub status: String, + pub attempts: i32, + pub run_after: NaiveDateTime, + pub last_error: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub tenant_id: Option, + pub result: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = jobs)] +pub struct NewJob { + pub id: Uuid, + pub job_type: String, + pub payload: serde_json::Value, + pub status: String, + pub run_after: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = tags)] +pub struct Tag { + pub id: Uuid, + pub label: String, + pub color: Option, + pub created_at: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = tags)] +pub struct NewTag { + pub id: Uuid, + pub label: String, + pub color: Option, + pub tenant_id: Uuid, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Queryable, Associations)] +#[diesel(table_name = document_tags)] +#[diesel(belongs_to(Document))] +#[diesel(belongs_to(Tag))] +#[diesel(primary_key(document_id, tag_id))] +pub struct DocumentTag { + pub document_id: Uuid, + pub tag_id: Uuid, + pub assigned_at: NaiveDateTime, + pub assigned_by: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_tags)] +pub struct NewDocumentTag { + pub document_id: Uuid, + pub tag_id: Uuid, + pub assigned_by: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = correspondents)] +pub struct Correspondent { + pub id: Uuid, + pub name: String, + pub metadata: serde_json::Value, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = correspondents)] +pub struct NewCorrespondent { + pub id: Uuid, + pub name: String, + pub metadata: serde_json::Value, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Associations)] +#[diesel(table_name = document_correspondents)] +#[diesel(belongs_to(Document))] +#[diesel(belongs_to(Correspondent))] +#[diesel(primary_key(document_id, correspondent_id))] +pub struct DocumentCorrespondent { + pub document_id: Uuid, + pub correspondent_id: Uuid, + pub assigned_at: NaiveDateTime, + pub assigned_by: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_correspondents)] +pub struct NewDocumentCorrespondent { + pub document_id: Uuid, + pub correspondent_id: Uuid, + pub assigned_by: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = user_sessions)] +#[diesel(belongs_to(User))] +pub struct UserSession { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub issued_at: NaiveDateTime, + pub expires_at: NaiveDateTime, + pub revoked_at: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = user_sessions)] +pub struct NewUserSession { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub issued_at: NaiveDateTime, + pub expires_at: NaiveDateTime, + pub tenant_id: Uuid, +} diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs new file mode 100644 index 0000000..0466ce8 --- /dev/null +++ b/backend/src/openapi.rs @@ -0,0 +1,135 @@ +use utoipa::openapi::{self, tag::TagBuilder, InfoBuilder}; +use utoipa::OpenApi; + +pub struct ApiDoc; + +impl OpenApi for ApiDoc { + fn openapi() -> openapi::OpenApi { + let mut doc = crate::routes::health::HealthApiDoc::openapi(); + doc.merge(crate::routes::auth::AuthApiDoc::openapi()); + doc.merge(crate::routes::documents::DocumentsApiDoc::openapi()); + doc.merge(crate::routes::folders::FoldersApiDoc::openapi()); + doc.merge(crate::routes::tags::TagsApiDoc::openapi()); + doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi()); + doc.merge(crate::routes::profile::ProfileApiDoc::openapi()); + doc.merge(crate::routes::capability_sets::CapabilitySetsApiDoc::openapi()); + doc.merge(crate::routes::tenants::TenantsApiDoc::openapi()); + + doc.info = InfoBuilder::new() + .title("Papercrate API") + .version(env!("CARGO_PKG_VERSION")) + .build(); + + doc.tags = Some(vec![ + TagBuilder::new() + .name("Health") + .description(Some("Service health")) + .build(), + TagBuilder::new() + .name("Auth") + .description(Some("Authentication")) + .build(), + TagBuilder::new() + .name("Documents") + .description(Some("Document management")) + .build(), + TagBuilder::new() + .name("Assets") + .description(Some("Document assets")) + .build(), + TagBuilder::new() + .name("Folders") + .description(Some("Folder management")) + .build(), + TagBuilder::new() + .name("Tags") + .description(Some("Tag catalog")) + .build(), + TagBuilder::new() + .name("Correspondents") + .description(Some("Correspondent catalog")) + .build(), + TagBuilder::new() + .name("Profile") + .description(Some("User profile and WebDAV tokens")) + .build(), + TagBuilder::new() + .name("Capability Sets") + .description(Some("Capability set management")) + .build(), + TagBuilder::new() + .name("Tenants") + .description(Some("Tenant catalog")) + .build(), + ]); + + doc + } +} + +pub mod schemas { + pub use crate::auth::passkeys::{ + AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, + PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, + }; + pub use crate::auth::AuthenticatedUser; + pub use crate::documents::asset::{ + DocumentAssetDetailResponse, DocumentAssetResponse, DocumentVersionDetailResponse, + DocumentVersionResponse, + }; + pub use crate::documents::correspondents::DocumentCorrespondentResponse; + pub use crate::error::ApiErrorResponse; + pub use crate::models::ApiCapability; + pub use crate::routes::correspondents::{ + CorrespondentSummary, CreateCorrespondentRequest, + UpdateCorrespondentRequest, + }; + pub use crate::routes::documents::{ + AssetRequestQuery, DocumentCheckQuery, MoveDocumentRequest, RestoreDocumentRequest, + UploadDocumentForm, + }; + pub use crate::routes::folders::FolderContentsResponse; + pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest}; + pub use crate::services::auth::{ + ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants, + SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, + TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, + }; + pub use crate::services::capability_sets::{ + CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest, + }; + pub use crate::services::correspondents::{ + AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse, + BulkCorrespondentsRequest, CorrespondentAssignmentInput, + }; + pub use crate::services::documents::{ + BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest, + DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate, + DocumentResponse, DocumentStatusFilter, TagResponse, UpdateDocumentRequest, + }; + pub use crate::services::folders::{ + CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderInfo, + UpdateFolderRequest, + }; + pub use crate::services::profile::{ + ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery, + }; + pub use crate::services::tags::{ + AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse, + }; + pub use crate::services::tenants::{ + TenantUserListResponse, TenantUserSummary, UpdateTenantRequest, UpdateTenantUserRequest, + }; +} + +#[cfg(test)] +mod tests { + use super::ApiDoc; + use utoipa::OpenApi; + + #[test] + fn openapi_serializes() { + let spec = ApiDoc::openapi(); + let _ = serde_json::to_string(&spec).expect("serialize openapi"); + } +} diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs new file mode 100644 index 0000000..5531c56 --- /dev/null +++ b/backend/src/routes/auth.rs @@ -0,0 +1,260 @@ +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::Response, + Json, +}; +use axum_extra::{ + headers::{authorization::Bearer, Authorization, Cookie}, + typed_header::TypedHeader, +}; +use utoipa::OpenApi; + +use crate::{ + auth::{ + passkeys::{ + AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, + PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, + }, + AuthenticatedUser, TenantScopedConn, + }, + error::{AppError, AppResult}, + http::responders::JsonResponse, + services::auth::{ + ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants, + SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, + TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME, + }, + state::AppState, +}; + +#[derive(OpenApi)] +#[openapi( + paths( + login, + api_token_exchange, + signup_start, + signup_finish, + refresh, + logout, + me, + select_tenant, + passkey_register_start, + passkey_register_finish, + passkey_login_start, + passkey_login_finish, + ), + components(schemas( + LoginRequest, + ApiTokenExchangeRequest, + SignupStartRequest, + SignupStartResponse, + SignupFinishRequest, + LoginResponse, + LoginResponseVariants, + TenantSnippet, + TenantSelectionResponse, + TenantSelectionRequest, + TenantListResponse, + crate::auth::AuthenticatedUser, + crate::auth::passkeys::RegistrationChallengeResponse, + crate::auth::passkeys::AuthenticationChallengeResponse, + crate::auth::passkeys::PasskeySummary, + crate::auth::passkeys::PasskeyRegistrationFinishPayload, + crate::auth::passkeys::PasskeyLoginStartPayload, + crate::auth::passkeys::PasskeyLoginFinishPayload, + crate::models::ApiCapability, + )) +)] +pub struct AuthApiDoc; + +#[utoipa::path( + post, + path = "/api/auth/login", + request_body = LoginRequest, + responses( + (status = 200, description = "Login succeeded", body = LoginResponseVariants), + (status = 401, description = "Invalid credentials") + ), + tag = "Auth" +)] +pub async fn login( + State(state): State, + Json(payload): Json, +) -> AppResult { + AuthService::new(&state).login(payload) +} + +#[utoipa::path( + post, + path = "/api/auth/exchange-api-token", + request_body = ApiTokenExchangeRequest, + responses((status = 200, description = "Access token issued", body = LoginResponse)), + tag = "Auth" +)] +pub async fn api_token_exchange( + State(state): State, + Json(payload): Json, +) -> AppResult> { + AuthService::new(&state).exchange_api_token(payload) +} + +#[utoipa::path( + post, + path = "/api/auth/signup/start", + request_body = SignupStartRequest, + responses( + (status = 200, description = "Signup challenge created", body = SignupStartResponse), + (status = 400, description = "Invalid signup request"), + (status = 409, description = "Username already exists") + ), + tag = "Auth" +)] +pub async fn signup_start( + State(state): State, + Json(payload): Json, +) -> AppResult> { + AuthService::new(&state).signup_start(payload) +} + +#[utoipa::path( + post, + path = "/api/auth/signup/finish", + request_body = SignupFinishRequest, + responses( + (status = 200, description = "Signup completed", body = LoginResponseVariants), + (status = 400, description = "Invalid signup completion"), + (status = 409, description = "Username already exists") + ), + tag = "Auth" +)] +pub async fn signup_finish( + State(state): State, + Json(payload): Json, +) -> AppResult { + AuthService::new(&state).signup_finish(payload) +} + +#[utoipa::path( + post, + path = "/api/auth/refresh", + responses( + (status = 200, description = "Refreshed access token", body = LoginResponse), + (status = 401, description = "Missing or invalid refresh token") + ), + tag = "Auth" +)] +pub async fn refresh( + State(state): State, + jar: Option>, +) -> AppResult { + let cookies = jar.ok_or_else(AppError::unauthorized)?; + let refresh_value = cookies + .get(SESSION_COOKIE_NAME) + .ok_or_else(AppError::unauthorized)?; + + AuthService::new(&state).refresh(refresh_value) +} + +#[utoipa::path( + post, + path = "/api/auth/select-tenant", + request_body = TenantSelectionRequest, + responses((status = 200, description = "Tenant selected", body = LoginResponse)), + tag = "Auth" +)] +pub async fn select_tenant( + State(state): State, + TypedHeader(Authorization(bearer)): TypedHeader>, + Json(payload): Json, +) -> AppResult { + AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id) +} + +#[utoipa::path( + post, + path = "/api/auth/logout", + responses((status = 204, description = "Session revoked")), + tag = "Auth" +)] +pub async fn logout( + State(state): State, + TenantScopedConn { mut conn, user, .. }: TenantScopedConn, + jar: Option>, +) -> AppResult<(HeaderMap, StatusCode)> { + let refresh_cookie = jar.as_ref().and_then(|cookies| { + cookies + .get(SESSION_COOKIE_NAME) + .map(|value| value.to_owned()) + }); + AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref()) +} + +#[utoipa::path( + get, + path = "/api/auth/me", + responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)), + tag = "Auth" +)] +pub async fn me(user: AuthenticatedUser) -> Json { + Json(user) +} + +#[utoipa::path( + post, + path = "/api/auth/passkeys/register/start", + responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)), + tag = "Auth" +)] +pub async fn passkey_register_start( + State(state): State, + user: AuthenticatedUser, +) -> AppResult> { + AuthService::new(&state).passkey_register_start(user) +} + +#[utoipa::path( + post, + path = "/api/auth/passkeys/register/finish", + request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload, + responses((status = 201, body = crate::auth::passkeys::PasskeySummary)), + tag = "Auth" +)] +pub async fn passkey_register_finish( + State(state): State, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + AuthService::new(&state).passkey_register_finish(user, payload) +} + +#[utoipa::path( + post, + path = "/api/auth/passkeys/login/start", + request_body = crate::auth::passkeys::PasskeyLoginStartPayload, + responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)), + tag = "Auth" +)] +pub async fn passkey_login_start( + State(state): State, + Json(payload): Json, +) -> AppResult> { + AuthService::new(&state).passkey_login_start(&payload.username) +} + +#[utoipa::path( + post, + path = "/api/auth/passkeys/login/finish", + request_body = crate::auth::passkeys::PasskeyLoginFinishPayload, + responses( + (status = 200, description = "Passkey login successful", body = LoginResponseVariants), + (status = 401, description = "Authentication failed") + ), + tag = "Auth" +)] +pub async fn passkey_login_finish( + State(state): State, + Json(payload): Json, +) -> AppResult { + AuthService::new(&state).passkey_login_finish(payload) +} diff --git a/backend/src/routes/capability_sets.rs b/backend/src/routes/capability_sets.rs new file mode 100644 index 0000000..74ab95b --- /dev/null +++ b/backend/src/routes/capability_sets.rs @@ -0,0 +1,134 @@ +use axum::{extract::Path, http::StatusCode, Json}; +use utoipa::OpenApi; +use uuid::Uuid; + +use crate::{ + auth::TenantScopedConn, + error::AppResult, + http::responders::JsonResponse, + services::capability_sets::{ + CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest, + UpdateCapabilitySetRequest, + }, +}; + +#[utoipa::path( + get, + path = "/api/capability-sets", + responses((status = 200, body = [CapabilitySetResponse])), + tag = "Capability Sets" +)] +pub async fn list_capability_sets( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + CapabilitySetService::new().list(&mut conn, tenant_id) +} + +#[utoipa::path( + get, + path = "/api/capabilities", + responses((status = 200, body = [crate::models::ApiCapability])), + tag = "Capability Sets" +)] +pub async fn list_capabilities( + TenantScopedConn { .. }: TenantScopedConn, +) -> AppResult>> { + CapabilitySetService::new().list_capabilities() +} + +#[utoipa::path( + get, + path = "/api/capability-sets/{id}", + params(("id" = Uuid, Path, description = "Capability set ID")), + responses((status = 200, body = CapabilitySetResponse), (status = 404, description = "Not found")), + tag = "Capability Sets" +)] +pub async fn get_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Path(id): Path, +) -> AppResult> { + CapabilitySetService::new().get(&mut conn, tenant_id, id) +} + +#[utoipa::path( + post, + path = "/api/capability-sets", + request_body = CreateCapabilitySetRequest, + responses((status = 201, body = CapabilitySetResponse)), + tag = "Capability Sets" +)] +pub async fn create_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + CapabilitySetService::new().create(&mut conn, tenant_id, payload) +} + +#[utoipa::path( + patch, + path = "/api/capability-sets/{id}", + params(("id" = Uuid, Path, description = "Capability set ID")), + request_body = UpdateCapabilitySetRequest, + responses((status = 200, body = CapabilitySetResponse)), + tag = "Capability Sets" +)] +pub async fn update_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Path(id): Path, + Json(payload): Json, +) -> AppResult> { + CapabilitySetService::new().update(&mut conn, tenant_id, id, payload) +} + +#[utoipa::path( + delete, + path = "/api/capability-sets/{id}", + params(("id" = Uuid, Path, description = "Capability set ID")), + responses((status = 204), (status = 409, description = "Set in use")), + tag = "Capability Sets" +)] +pub async fn delete_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Path(id): Path, +) -> AppResult { + CapabilitySetService::new().delete(&mut conn, tenant_id, id) +} + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::capability_sets::list_capability_sets, + crate::routes::capability_sets::list_capabilities, + crate::routes::capability_sets::get_capability_set, + crate::routes::capability_sets::create_capability_set, + crate::routes::capability_sets::update_capability_set, + crate::routes::capability_sets::delete_capability_set, + ), + components(schemas( + crate::models::ApiCapability, + crate::services::capability_sets::CapabilitySetResponse, + crate::services::capability_sets::CreateCapabilitySetRequest, + crate::services::capability_sets::UpdateCapabilitySetRequest, + )) +)] +pub struct CapabilitySetsApiDoc; diff --git a/backend/src/routes/correspondents.rs b/backend/src/routes/correspondents.rs new file mode 100644 index 0000000..be3f8c6 --- /dev/null +++ b/backend/src/routes/correspondents.rs @@ -0,0 +1,312 @@ +use std::collections::HashMap; + +use axum::{extract::Path, http::StatusCode, Json}; +use chrono::Utc; +use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + auth::TenantScopedConn, + error::{AppError, AppResult}, + http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt}, + models::{Correspondent, NewCorrespondent}, + schema::{correspondents, document_correspondents}, + utils::{ + named_entity::{ensure_name_available, normalize_name}, + time::to_iso, + }, +}; + +#[derive(Serialize, ToSchema)] +pub struct CorrespondentSummary { + pub id: Uuid, + pub name: String, + #[schema(value_type = Object)] + pub metadata: Value, + pub created_at: String, + pub updated_at: String, + pub usage_count: i64, +} + +#[derive(Deserialize, ToSchema)] +pub struct CreateCorrespondentRequest { + pub name: String, + #[serde(default)] + #[schema(nullable, value_type = Object)] + pub metadata: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateCorrespondentRequest { + #[schema(nullable)] + pub name: Option, + #[schema(nullable, value_type = Object)] + pub metadata: Option, +} + +#[derive(AsChangeset, Default)] +#[diesel(table_name = correspondents)] +struct CorrespondentChangeset<'a> { + name: Option<&'a str>, + metadata: Option<&'a Value>, +} + +#[utoipa::path( + get, + path = "/api/correspondents", + responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])), + tag = "Correspondents" +)] +pub async fn list_correspondents( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let correspondents_list: Vec = correspondents::table + .filter(correspondents::tenant_id.eq(tenant_id)) + .order(correspondents::name.asc()) + .load(&mut conn)?; + + let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .group_by(document_correspondents::correspondent_id) + .select((document_correspondents::correspondent_id, count_star())) + .load(&mut conn)?; + + let mut usage_map: HashMap = HashMap::new(); + for (correspondent_id, count) in usage_rows { + usage_map.insert(correspondent_id, count); + } + + let mut response = Vec::with_capacity(correspondents_list.len()); + for correspondent in correspondents_list { + let total = usage_map.remove(&correspondent.id).unwrap_or(0); + response.push(build_summary(correspondent, total)); + } + + ok_json(response) +} + +#[utoipa::path( + post, + path = "/api/correspondents", + request_body = CreateCorrespondentRequest, + responses((status = 200, description = "Correspondent created", body = CorrespondentSummary)), + tag = "Correspondents" +)] +pub async fn create_correspondent( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let name = normalize_name(&payload.name, || { + AppError::bad_request("name must not be empty") + })?; + + let metadata_value = normalize_metadata(payload.metadata); + let new_id = Uuid::new_v4(); + let new_correspondent = NewCorrespondent { + id: new_id, + name: name.clone(), + metadata: metadata_value, + tenant_id, + }; + + match diesel::insert_into(correspondents::table) + .values(&new_correspondent) + .execute(&mut conn) + { + Ok(_) => {} + Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { + return Err(AppError::bad_request("correspondent name already exists")); + } + Err(err) => return Err(AppError::from(err)), + } + + let correspondent: Correspondent = correspondents::table + .find(new_id) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + + ok_json(build_summary(correspondent, 0)) +} + +#[utoipa::path( + patch, + path = "/api/correspondents/{id}", + params(("id" = Uuid, Path, description = "Correspondent ID")), + request_body = UpdateCorrespondentRequest, + responses((status = 200, description = "Correspondent updated", body = CorrespondentSummary)), + tag = "Correspondents" +)] +pub async fn update_correspondent( + Path(correspondent_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let existing: Correspondent = correspondents::table + .find(correspondent_id) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + + let mut new_name: Option = None; + if let Some(ref candidate) = payload.name { + let normalized = normalize_name(candidate, || { + AppError::bad_request("name must not be empty") + })?; + if normalized != existing.name { + ensure_name_available( + || { + correspondents::table + .filter(correspondents::name.eq(&normalized)) + .filter(correspondents::id.ne(correspondent_id)) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first::(&mut conn) + .optional() + }, + || AppError::bad_request("correspondent name already exists"), + )?; + new_name = Some(normalized); + } + } + + let mut new_metadata: Option = None; + if let Some(metadata) = payload.metadata.clone() { + let candidate = normalize_metadata(Some(metadata)); + if candidate != existing.metadata { + new_metadata = Some(candidate); + } + } + + if new_name.is_none() && new_metadata.is_none() { + let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; + return ok_json(build_summary(existing.clone(), usage)); + } + + let mut changeset = CorrespondentChangeset::default(); + if let Some(ref name) = new_name { + changeset.name = Some(name.as_str()); + } + if let Some(ref metadata) = new_metadata { + changeset.metadata = Some(metadata); + } + + let now = Utc::now().naive_utc(); + diesel::update( + correspondents::table + .find(correspondent_id) + .filter(correspondents::tenant_id.eq(tenant_id)), + ) + .set((&changeset, correspondents::updated_at.eq(now))) + .execute(&mut conn) + .into_app_result()? + .or_not_found()?; + + let updated: Correspondent = correspondents::table + .find(correspondent_id) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; + ok_json(build_summary(updated, usage)) +} + +#[utoipa::path( + delete, + path = "/api/correspondents/{id}", + params(("id" = Uuid, Path, description = "Correspondent ID")), + responses((status = 204, description = "Correspondent deleted")), + tag = "Correspondents" +)] +pub async fn delete_correspondent( + Path(correspondent_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let usage: i64 = document_correspondents::table + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .filter(document_correspondents::correspondent_id.eq(correspondent_id)) + .select(count_star()) + .first(&mut conn)?; + + if usage > 0 { + return Err(AppError::bad_request( + "cannot delete correspondent that is still assigned to documents", + )); + } + + diesel::delete( + correspondents::table + .filter(correspondents::id.eq(correspondent_id)) + .filter(correspondents::tenant_id.eq(tenant_id)), + ) + .execute(&mut conn) + .into_app_result()? + .or_not_found()?; + no_content() +} + +fn build_summary(correspondent: Correspondent, usage_count: i64) -> CorrespondentSummary { + CorrespondentSummary { + id: correspondent.id, + name: correspondent.name, + metadata: correspondent.metadata, + created_at: to_iso(correspondent.created_at), + updated_at: to_iso(correspondent.updated_at), + usage_count, + } +} + +fn normalize_metadata(input: Option) -> Value { + match input { + None | Some(Value::Null) => Value::Object(Default::default()), + Some(value) => value, + } +} + +fn load_usage_for_correspondent( + conn: &mut PgConnection, + tenant_id: Uuid, + correspondent_id: Uuid, +) -> AppResult { + let total: i64 = document_correspondents::table + .filter(document_correspondents::correspondent_id.eq(correspondent_id)) + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .select(count_star()) + .get_result(conn)?; + + Ok(total) +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::correspondents::list_correspondents, + crate::routes::correspondents::create_correspondent, + crate::routes::correspondents::update_correspondent, + crate::routes::correspondents::delete_correspondent + ), + components(schemas( + crate::routes::correspondents::CorrespondentSummary, + crate::routes::correspondents::CreateCorrespondentRequest, + crate::routes::correspondents::UpdateCorrespondentRequest + )) +)] +pub struct CorrespondentsApiDoc; diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs new file mode 100644 index 0000000..46419e9 --- /dev/null +++ b/backend/src/routes/documents.rs @@ -0,0 +1,1152 @@ +use std::{collections::HashSet, time::Duration}; + +use axum::body::Body; +use axum::extract::{Json, Multipart, Path, Query, State}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use chrono::{DateTime, NaiveDateTime, Utc}; +use diesel::dsl::exists; +use diesel::{prelude::*, select}; +use futures_util::StreamExt; +use serde::Deserialize; +use serde_json::Value; +use tracing::{error, info}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::auth::{ensure_active_tenant_with_conn, jwt::DownloadSubject, TenantScopedConn}; +use crate::documents::asset::{ + asset_disposition, DocumentAssetDetailResponse, DocumentAssetResponse, + DocumentVersionDetailResponse, DocumentVersionResponse, DownloadLink, +}; +#[allow(unused_imports)] +use crate::error::ApiErrorResponse; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{accepted_json, created_json, no_content, ok_json, JsonResponse}; +use crate::models::{Document, DocumentAsset, DocumentVersion}; +use crate::schema::{ + document_assets, document_versions, documents, user_sessions::dsl as session_dsl, +}; +use crate::services::correspondents::{ + AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse, + BulkCorrespondentsRequest, CorrespondentAssignmentInput, CorrespondentsService, +}; +use crate::services::documents::{ + BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest, + DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate, + DocumentResponse, DocumentUploadOutcome, DocumentUploadRequest, DocumentsService, +}; +use crate::services::tags::{ + AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse, TagsService, +}; +use crate::state::AppState; +use crate::storage::TenantStorage; +use crate::utils::{error::StorageResultExt, http::inline_content_disposition}; + +const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; + +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] +pub struct AssetRequestQuery { + #[serde(default)] + #[schema(default = false)] + pub force: bool, +} + +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] +pub struct DocumentCheckQuery { + pub checksum: String, +} + +#[derive(ToSchema)] +pub struct UploadDocumentForm { + #[schema(value_type = String, format = Binary)] + pub file: String, + #[schema(nullable)] + pub folder_id: Option, + #[schema(nullable, value_type = Object)] + pub metadata: Option, + #[schema(nullable)] + pub title: Option, + #[schema(nullable, value_type = Vec)] + pub tag_ids: Option>, + #[schema(nullable, value_type = Vec)] + pub correspondents: Option>, + #[schema(nullable, example = "2024-01-01T00:00:00Z")] + pub issued_at: Option, + #[schema(nullable, default = true)] + pub skip_existing: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct MoveDocumentRequest { + pub folder_id: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct RestoreDocumentRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_id: Option, +} + +#[utoipa::path( + get, + path = "/api/documents", + params(DocumentListQuery), + responses((status = 200, description = "List documents", body = [DocumentResponse])), + tag = "Documents" +)] +pub async fn list_documents( + State(state): State, + Query(params): Query, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let service = DocumentsService::new(&state); + let documents = service + .list_documents(&mut conn, tenant_id, user_id, params) + .await?; + ok_json(documents) +} + +#[utoipa::path( + get, + path = "/api/documents/check", + params(DocumentCheckQuery), + responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)), + tag = "Documents" +)] +pub async fn check_document( + State(state): State, + Query(query): Query, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let checksum_raw = query.checksum.trim(); + if checksum_raw.is_empty() { + return Err(AppError::bad_request("checksum must not be empty")); + } + + let checksum = checksum_raw.to_ascii_lowercase(); + if !checksum.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(AppError::bad_request( + "checksum must be a hex-encoded string", + )); + } + + let service = DocumentsService::new(&state); + let response = service.check_document(&mut conn, tenant_id, &checksum)?; + ok_json(response) +} + +#[utoipa::path( + get, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document detail", body = DocumentDetailResponse)), + tag = "Documents" +)] +pub async fn get_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let detail = service + .get_document_detail(&mut conn, tenant_id, user_id, document_id) + .await?; + ok_json(detail) +} + +#[utoipa::path( + post, + path = "/api/documents", + request_body = UploadDocumentForm, + responses( + (status = 201, description = "Document created", body = DocumentDetailResponse), + (status = 200, description = "Existing document reused", body = DocumentDetailResponse), + ( + status = 409, + description = "Document with identical contents already exists", + body = ApiErrorResponse + ) + ), + tag = "Documents" +)] +pub async fn upload_document( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + mut multipart: Multipart, +) -> AppResult> { + let tenant_id = tenant_id; + let user_id = user_id; + let mut file_bytes: Option> = None; + let mut original_name: Option = None; + let mut mime_type: Option = None; + let mut folder_id: Option = None; + let mut metadata: Value = Value::Object(Default::default()); + let mut tag_ids: Vec = Vec::new(); + let mut correspondents: Vec = Vec::new(); + let mut issued_at_override: Option = None; + let mut skip_if_existing = true; + let mut title_override: Option = None; + + while let Some(field) = multipart.next_field().await.map_err(|err| { + let msg = format!("invalid multipart data: {err}"); + error!(error = %err, "invalid multipart data"); + AppError::bad_request(msg) + })? { + let name = field.name().map(|n| n.to_string()); + match name.as_deref() { + Some("file") => { + let file_name = field.file_name().map(|n| n.to_string()); + original_name = file_name.clone(); + mime_type = field.content_type().map(|mime| mime.to_string()); + let data = field.bytes().await.map_err(|err| { + let msg = format!("failed to read file bytes: {err}"); + error!(error = %err, "failed to read file bytes"); + AppError::bad_request(msg) + })?; + file_bytes = Some(data.to_vec()); + } + Some("folder_id") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid folder id: {err}"); + error!(error = %err, "invalid folder id"); + AppError::bad_request(msg) + })?; + if !value.trim().is_empty() { + let parsed = Uuid::parse_str(value.trim()) + .map_err(|_| AppError::bad_request("folder_id must be a valid UUID"))?; + folder_id = Some(parsed); + } + } + Some("metadata") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid metadata: {err}"); + error!(error = %err, "invalid metadata payload"); + AppError::bad_request(msg) + })?; + metadata = serde_json::from_str(&value).map_err(|err| { + let msg = format!("metadata must be valid JSON: {err}"); + error!(error = %err, "metadata parse failure"); + AppError::bad_request(msg) + })?; + } + Some("title") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid title: {err}"); + error!(error = %err, "invalid title payload"); + AppError::bad_request(msg) + })?; + let trimmed = value.trim(); + if !trimmed.is_empty() { + title_override = Some(trimmed.to_string()); + } + } + Some("tag_ids") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid tag_ids: {err}"); + error!(error = %err, "invalid tag_ids payload"); + AppError::bad_request(msg) + })?; + let parsed: Vec = serde_json::from_str(&value).map_err(|err| { + let msg = format!("tag_ids must be a JSON array of UUID strings: {err}"); + error!(error = %err, "invalid tag_ids json"); + AppError::bad_request(msg) + })?; + let mut set = HashSet::new(); + for raw in parsed { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let uuid = Uuid::parse_str(trimmed) + .map_err(|_| AppError::bad_request("tag_ids must contain valid UUIDs"))?; + set.insert(uuid); + } + tag_ids = set.into_iter().collect(); + } + Some("correspondents") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid correspondents: {err}"); + error!(error = %err, "invalid correspondents payload"); + AppError::bad_request(msg) + })?; + correspondents = serde_json::from_str(&value).map_err(|err| { + let msg = format!( + "correspondents must be a JSON array of {{correspondent_id}} objects: {err}" + ); + error!(error = %err, "invalid correspondents json"); + AppError::bad_request(msg) + })?; + } + Some("issued_at") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid issued_at: {err}"); + error!(error = %err, "invalid issued_at payload"); + AppError::bad_request(msg) + })?; + let trimmed = value.trim(); + if !trimmed.is_empty() { + let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| { + let msg = format!("issued_at must be an RFC3339 timestamp: {err}"); + error!(error = %err, "invalid issued_at format"); + AppError::bad_request(msg) + })?; + issued_at_override = Some(parsed.naive_utc()); + } + } + Some("skip_existing") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid skip_existing flag: {err}"); + error!(error = %err, "invalid skip_existing payload"); + AppError::bad_request(msg) + })?; + skip_if_existing = matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ); + } + _ => {} + } + } + + let file_bytes = file_bytes.ok_or_else(|| { + error!("upload rejected: missing file field"); + AppError::bad_request("file field is required") + })?; + + if file_bytes.is_empty() { + error!("upload rejected: empty file payload"); + return Err(AppError::bad_request("file field must not be empty")); + } + let original_name = original_name.ok_or_else(|| { + error!("upload rejected: missing original filename"); + AppError::bad_request("filename is required") + })?; + let original_name_for_log = original_name.clone(); + + let request = DocumentUploadRequest { + bytes: file_bytes, + original_name, + mime_type, + folder_id, + metadata, + title_override, + tag_ids, + correspondents, + issued_at_override, + skip_if_existing, + }; + + let service = DocumentsService::new(&state); + let outcome = match service + .upload_document(&mut conn, tenant_id, user_id, request) + .await + { + Ok(outcome) => outcome, + Err(err) => { + error!(error = ?err, original_name = %original_name_for_log, "document upload failed"); + return Err(err); + } + }; + + let response = match outcome { + DocumentUploadOutcome::Created(detail) => { + info!( + document_id = %detail.document.id, + original_name = %detail.document.original_name, + created = true, + reused_existing = false, + "document upload succeeded", + ); + created_json(detail)? + } + DocumentUploadOutcome::Reused(detail) => { + info!( + document_id = %detail.document.id, + original_name = %detail.document.original_name, + created = false, + reused_existing = true, + "document upload succeeded", + ); + ok_json(detail)? + } + }; + + Ok(response) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/assets", + params(("id" = Uuid, Path, description = "Document ID"), AssetRequestQuery), + responses((status = 202, description = "Asset generation requested")), + tag = "Assets" +)] +pub async fn request_document_assets( + State(state): State, + Path(document_id): Path, + Query(query): Query, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = DocumentsService::new(&state); + service.request_document_assets(&mut conn, tenant_id, document_id, query.force)?; + Ok(StatusCode::ACCEPTED) +} + +#[utoipa::path( + post, + path = "/api/documents/bulk/reanalyze", + request_body = BulkReanalyzeSelectionRequest, + responses((status = 200, description = "Reanalyze queued", body = BulkReanalyzeResponse)), + tag = "Documents" +)] +pub async fn reanalyze_selected_documents( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let BulkReanalyzeSelectionRequest { + mut document_ids, + force, + } = payload; + + let service = DocumentsService::new(&state); + let queued = service.reanalyze_documents(&mut conn, tenant_id, &mut document_ids, force)?; + accepted_json(BulkReanalyzeResponse { queued }) +} + +#[utoipa::path( + get, + path = "/api/documents/{id}/assets", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document assets", body = [DocumentAssetResponse])), + tag = "Assets" +)] +pub async fn list_document_assets( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let service = DocumentsService::new(&state); + let assets = service + .list_document_assets(&mut conn, tenant_id, user_id, document_id) + .await?; + ok_json(assets) +} + +#[utoipa::path( + get, + path = "/api/assets/{asset_id}", + params(("asset_id" = Uuid, Path, description = "Asset ID")), + responses((status = 200, description = "Asset detail", body = DocumentAssetDetailResponse)), + tag = "Assets" +)] +pub async fn get_document_asset( + State(state): State, + Path(asset_id): Path, + TenantScopedConn { + conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let detail = service + .get_document_asset(conn, tenant_id, user_id, asset_id) + .await?; + ok_json(detail) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/download", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Download link for current version", body = DownloadLink)), + tag = "Documents" +)] +pub async fn refresh_document_download( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let link = service + .get_document_download_link(&mut conn, tenant_id, user_id, document_id) + .await?; + ok_json(link) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/versions/{version_id}/download", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("version_id" = Uuid, Path, description = "Version ID") + ), + responses((status = 200, description = "Download link for version", body = DownloadLink)), + tag = "Documents" +)] +pub async fn refresh_document_version_download( + State(state): State, + Path((document_id, version_id)): Path<(Uuid, Uuid)>, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let link = service + .get_document_version_download_link(&mut conn, tenant_id, user_id, document_id, version_id) + .await?; + ok_json(link) +} + +#[utoipa::path( + post, + path = "/api/assets/{asset_id}/download", + params(("asset_id" = Uuid, Path, description = "Asset ID")), + responses((status = 200, description = "Asset download link", body = DownloadLink)), + tag = "Assets" +)] +pub async fn refresh_asset_download( + State(state): State, + Path(asset_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let link = service + .get_asset_download_link(&mut conn, tenant_id, user_id, asset_id) + .await?; + ok_json(link) +} + +#[utoipa::path( + get, + path = "/api/documents/{id}/versions", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])), + tag = "Documents" +)] +pub async fn list_document_versions( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let service = DocumentsService::new(&state); + let versions = service.list_document_versions(&mut conn, tenant_id, document_id)?; + ok_json(versions) +} + +#[utoipa::path( + get, + path = "/api/documents/{id}/versions/{version_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("version_id" = Uuid, Path, description = "Version ID"), + ), + responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)), + tag = "Documents" +)] +pub async fn get_document_version( + State(state): State, + Path((document_id, version_id)): Path<(Uuid, Uuid)>, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let detail = service + .get_document_version(&mut conn, tenant_id, user_id, document_id, version_id) + .await?; + ok_json(detail) +} + +#[utoipa::path( + get, + path = "/api/download/{token}", + params(("token" = String, Path, description = "Download token")), + responses((status = 200, description = "Proxied download stream or redirect")), + tag = "Documents" +)] +pub async fn download_with_token( + State(state): State, + Path(token): Path, + headers: HeaderMap, +) -> AppResult { + let claims = state + .jwt + .verify_download_token(&token) + .map_err(|_| AppError::unauthorized())?; + + let mut conn = state.db_for_tenant(claims.tenant_id)?; + ensure_active_tenant_with_conn(&mut conn, claims.tenant_id)?; + + let now = Utc::now().naive_utc(); + let has_active_refresh: bool = select(exists( + session_dsl::user_sessions + .filter(session_dsl::user_id.eq(claims.user_id)) + .filter(session_dsl::tenant_id.eq(claims.tenant_id)) + .filter(session_dsl::revoked_at.is_null()) + .filter(session_dsl::expires_at.gt(now)), + )) + .get_result(&mut conn)?; + + if !has_active_refresh { + return Err(AppError::unauthorized()); + } + + match &claims.subject { + DownloadSubject::Document { doc_id, version_id } => { + let doc_id = *doc_id; + let version_id = *version_id; + let doc: Document = documents::table + .find(doc_id) + .filter(documents::tenant_id.eq(claims.tenant_id)) + .first(&mut conn)?; + if doc.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + let version: DocumentVersion = document_versions::table + .find(version_id) + .filter(document_versions::document_id.eq(doc_id)) + .first(&mut conn)?; + + drop(conn); + + let storage = state.storage_for_tenant(claims.tenant_id)?; + let disposition = inline_content_disposition(&doc.filename); + + if !state.config.proxy_downloads { + let presigned_url = storage + .presign_get_object( + &version.s3_key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + disposition.as_deref(), + ) + .await + .storage_context("failed to generate download URL")?; + + return Ok(axum::response::Redirect::temporary(&presigned_url).into_response()); + } + + proxy_storage_object( + storage, + &version.s3_key, + disposition.as_deref(), + headers.get(header::RANGE).cloned(), + doc.mime_type.as_deref(), + Some(version.id.to_string()), + ) + .await + } + DownloadSubject::Asset { asset_id } => { + let asset: DocumentAsset = document_assets::table + .find(*asset_id) + .filter(document_assets::tenant_id.eq(claims.tenant_id)) + .first(&mut conn)?; + + drop(conn); + + let storage = state.storage_for_tenant(claims.tenant_id)?; + let disposition = asset_disposition(&asset); + + if !state.config.proxy_downloads { + let presigned_url = storage + .presign_get_object( + &asset.s3_key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + disposition.as_deref(), + ) + .await + .storage_context("failed to generate download URL")?; + + return Ok(axum::response::Redirect::temporary(&presigned_url).into_response()); + } + + proxy_storage_object( + storage, + &asset.s3_key, + disposition.as_deref(), + headers.get(header::RANGE).cloned(), + Some(asset.mime_type.as_str()), + Some(asset.id.to_string()), + ) + .await + } + } +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/trash", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 204, description = "Document deleted")), + tag = "Documents" +)] +pub async fn trash_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = DocumentsService::new(&state); + service.trash_document(&mut conn, tenant_id, document_id)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn proxy_storage_object( + storage: TenantStorage, + key: &str, + response_disposition: Option<&str>, + range_header: Option, + fallback_content_type: Option<&str>, + etag: Option, +) -> AppResult { + let url = storage + .presign_get_object( + key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + response_disposition, + ) + .await + .storage_context("failed to generate download URL")?; + + let client = reqwest::Client::new(); + let mut request = client.get(url.clone()); + if let Some(range) = range_header { + request = request.header(header::RANGE, range); + } + + let upstream = request.send().await.map_err(|err| { + tracing::error!(error = ?err, "failed to fetch document stream"); + AppError::internal("failed to fetch document stream") + })?; + + let status = + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) { + tracing::error!(status = %status, "upstream download returned error status"); + return Err(AppError::internal("failed to fetch document stream")); + } + + let mut builder = Response::builder().status(status); + + if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) { + builder = builder.header(header::CONTENT_TYPE, content_type); + } else if let Some(fallback) = fallback_content_type { + builder = builder.header(header::CONTENT_TYPE, fallback); + } + + if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) { + builder = builder.header(header::CONTENT_LENGTH, content_length); + } + + if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) { + builder = builder.header(header::CONTENT_RANGE, range); + } + + builder = builder.header("Accept-Ranges", "bytes"); + + if let Some(disposition) = response_disposition { + builder = builder.header(header::CONTENT_DISPOSITION, disposition); + } + + if let Some(etag_value) = etag { + builder = builder.header(header::ETAG, format!("\"{}\"", etag_value)); + } + + let stream = upstream + .bytes_stream() + .map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))); + let body = Body::from_stream(stream); + + builder.body(body).map_err(|err| { + tracing::error!(error = ?err, "failed to build proxied response"); + AppError::internal("failed to build proxied response") + }) +} + +#[utoipa::path( + delete, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 202, description = "Document purge scheduled")), + tag = "Documents" +)] +pub async fn delete_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = DocumentsService::new(&state); + service.delete_document(&mut conn, tenant_id, document_id)?; + + Ok(StatusCode::ACCEPTED) +} + +#[utoipa::path( + patch, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = crate::services::documents::UpdateDocumentRequest, + responses((status = 200, description = "Updated document", body = DocumentDetailResponse)), + tag = "Documents" +)] +pub async fn update_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = DocumentsService::new(&state); + let detail = service + .update_document(&mut conn, tenant_id, user_id, document_id, payload) + .await?; + ok_json(detail) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/restore", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = RestoreDocumentRequest, + responses((status = 204, description = "Document restored")), + tag = "Documents" +)] +pub async fn restore_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + let service = DocumentsService::new(&state); + service.restore_document(&mut conn, tenant_id, document_id, payload.folder_id)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + patch, + path = "/api/documents/{id}/folder", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = MoveDocumentRequest, + responses((status = 204, description = "Document moved")), + tag = "Documents" +)] +pub async fn move_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + let service = DocumentsService::new(&state); + service.move_document(&mut conn, tenant_id, document_id, payload.folder_id)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/documents/bulk/move", + request_body = BulkMoveRequest, + responses((status = 200, description = "Bulk move outcome", body = BulkMoveResponse)), + tag = "Documents" +)] +pub async fn bulk_move_documents( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let BulkMoveRequest { + document_ids, + folder_id, + } = payload; + + let service = DocumentsService::new(&state); + let updated = service.bulk_move_documents(&mut conn, tenant_id, document_ids, folder_id)?; + ok_json(BulkMoveResponse { updated }) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/correspondents", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = AssignCorrespondentsRequest, + responses((status = 204, description = "Correspondents assigned")), + tag = "Documents" +)] +pub async fn assign_correspondents( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + let service = CorrespondentsService::new(&state); + service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload)?; + no_content() +} + +#[utoipa::path( + post, + path = "/api/documents/bulk/correspondents", + request_body = BulkCorrespondentsRequest, + responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentResponse)), + tag = "Documents" +)] +pub async fn bulk_assign_correspondents( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = CorrespondentsService::new(&state); + let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?; + ok_json(response) +} + +#[utoipa::path( + delete, + path = "/api/documents/{id}/correspondents/{correspondent_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("correspondent_id" = Uuid, Path, description = "Correspondent ID") + ), + responses((status = 204, description = "Correspondent removed")), + tag = "Documents" +)] +pub async fn remove_correspondent( + State(state): State, + Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = CorrespondentsService::new(&state); + service.remove_from_document(&mut conn, tenant_id, document_id, correspondent_id)?; + no_content() +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/tags", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = AssignTagsRequest, + responses((status = 204, description = "Tags assigned")), + tag = "Documents" +)] +pub async fn assign_tags( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + let service = TagsService::new(&state); + service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload.tag_ids)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/documents/bulk/tags", + request_body = BulkTagRequest, + responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)), + tag = "Documents" +)] +pub async fn bulk_update_tags( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = TagsService::new(&state); + let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?; + ok_json(response) +} + +#[utoipa::path( + delete, + path = "/api/documents/{id}/tags/{tag_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("tag_id" = Uuid, Path, description = "Tag ID") + ), + responses((status = 204, description = "Tag removed")), + tag = "Documents" +)] +pub async fn remove_tag( + Path((document_id, tag_id)): Path<(Uuid, Uuid)>, + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = TagsService::new(&state); + service.remove_from_document(&mut conn, tenant_id, document_id, tag_id)?; + no_content() +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::documents::list_documents, + crate::routes::documents::check_document, + crate::routes::documents::upload_document, + crate::routes::documents::get_document, + crate::routes::documents::refresh_document_download, + crate::routes::documents::update_document, + crate::routes::documents::trash_document, + crate::routes::documents::delete_document, + crate::routes::documents::restore_document, + crate::routes::documents::download_with_token, + crate::routes::documents::move_document, + crate::routes::documents::assign_tags, + crate::routes::documents::remove_tag, + crate::routes::documents::bulk_move_documents, + crate::routes::documents::bulk_update_tags, + crate::routes::documents::bulk_assign_correspondents, + crate::routes::documents::assign_correspondents, + crate::routes::documents::remove_correspondent, + crate::routes::documents::reanalyze_selected_documents, + crate::routes::documents::list_document_assets, + crate::routes::documents::request_document_assets, + crate::routes::documents::get_document_asset, + crate::routes::documents::list_document_versions, + crate::routes::documents::get_document_version, + crate::routes::documents::refresh_document_version_download, + crate::routes::documents::refresh_asset_download, + ), + components(schemas( + crate::services::documents::DocumentListQuery, + crate::services::documents::DocumentStatusFilter, + crate::routes::documents::AssetRequestQuery, + crate::routes::documents::DocumentCheckQuery, + crate::routes::documents::DocumentCheckResponse, + crate::services::documents::DocumentResponse, + crate::services::documents::DocumentDetailResponse, + crate::routes::documents::DocumentMetadataUpdate, + crate::services::documents::TagResponse, + crate::routes::documents::CorrespondentAssignmentInput, + crate::routes::documents::AssignCorrespondentsRequest, + crate::routes::documents::BulkCorrespondentAction, + crate::routes::documents::BulkCorrespondentsRequest, + crate::routes::documents::BulkCorrespondentResponse, + crate::routes::documents::BulkMoveRequest, + crate::routes::documents::BulkMoveResponse, + crate::routes::documents::BulkTagAction, + crate::routes::documents::BulkTagRequest, + crate::routes::documents::BulkTagResponse, + crate::routes::documents::AssignTagsRequest, + crate::routes::documents::MoveDocumentRequest, + crate::routes::documents::BulkReanalyzeSelectionRequest, + crate::routes::documents::BulkReanalyzeResponse, + crate::routes::documents::UploadDocumentForm, + crate::documents::asset::DocumentVersionResponse, + crate::documents::asset::DocumentVersionDetailResponse, + crate::documents::asset::DocumentAssetResponse, + crate::documents::asset::DocumentAssetDetailResponse, + crate::documents::asset::DownloadLink, + crate::documents::correspondents::DocumentCorrespondentResponse, + crate::error::ApiErrorResponse, + )) +)] +pub struct DocumentsApiDoc; diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs new file mode 100644 index 0000000..2a374d2 --- /dev/null +++ b/backend/src/routes/folders.rs @@ -0,0 +1,248 @@ +use axum::{ + extract::{Json, Path, Query, State}, + http::StatusCode, +}; +use utoipa::OpenApi; +use uuid::Uuid; + +use crate::{ + auth::TenantScopedConn, + error::{AppError, AppResult}, + http::responders::{created_json, no_content, ok_json, JsonResponse}, + services::folders::{ + CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery, + FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest, + }, + state::AppState, +}; + +use crate::services::documents::DocumentResponse; + +#[derive(utoipa::ToSchema, serde::Serialize)] +pub struct FolderResponse { + pub folder: FolderInfo, +} + +#[derive(utoipa::ToSchema, serde::Serialize)] +pub struct FolderContentsResponse { + #[schema(nullable)] + pub folder: Option, + pub subfolders: Vec, + pub documents: Vec, +} + +#[utoipa::path( + get, + path = "/api/folders/{id}", + params(("id" = Uuid, Path, description = "Folder ID")), + responses((status = 200, description = "Folder detail", body = FolderResponse)), + tag = "Folders" +)] +pub async fn get_folder( + State(state): State, + Path(folder_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = FolderService::new(&state); + let folder = service.get_folder(&mut conn, tenant_id, folder_id)?; + ok_json(FolderResponse { folder }) +} + +#[utoipa::path( + post, + path = "/api/folders/path", + request_body = EnsureFolderPathRequest, + responses((status = 200, description = "Folder path ensured", body = FolderResponse)), + tag = "Folders" +)] +pub async fn ensure_folder_path( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = FolderService::new(&state); + let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?; + ok_json(FolderResponse { folder }) +} + +#[utoipa::path( + post, + path = "/api/folders", + request_body = CreateFolderRequest, + responses( + (status = 201, description = "Folder created", body = FolderResponse), + (status = 200, description = "Folder already existed", body = FolderResponse) + ), + tag = "Folders" +)] +pub async fn create_folder( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = FolderService::new(&state); + let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?; + let response = FolderResponse { folder }; + if created { + created_json(response) + } else { + ok_json(response) + } +} + +#[utoipa::path( + get, + path = "/api/folders/{id}/contents", + params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery), + responses((status = 200, description = "Folder contents", body = FolderContentsResponse)), + tag = "Folders" +)] +pub async fn list_folder_contents( + State(state): State, + Path(folder_identifier): Path, + Query(query): Query, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let FolderContentsQuery { + include_documents, + sort, + dir, + } = query; + + let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { + None + } else { + Some( + Uuid::parse_str(&folder_identifier) + .map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?, + ) + }; + + let service = FolderService::new(&state); + let FolderContentsData { + folder, + subfolders, + documents, + } = service.list_folder_contents( + &mut conn, + tenant_id, + folder_id, + sort, + dir, + include_documents, + )?; + + let documents = if include_documents { + service.hydrate_documents(&mut conn, tenant_id, user_id, documents)? + } else { + Vec::new() + }; + + ok_json(FolderContentsResponse { + folder, + subfolders, + documents, + }) +} + +#[utoipa::path( + get, + path = "/api/folders/tree", + responses((status = 200, description = "Folder hierarchy", body = [FolderTreeNode])), + tag = "Folders" +)] +pub async fn list_folder_tree( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let service = FolderService::new(&state); + let tree = service.list_folder_tree(&mut conn, tenant_id)?; + ok_json(tree) +} + +#[utoipa::path( + delete, + path = "/api/folders/{id}", + params(("id" = Uuid, Path, description = "Folder ID")), + responses((status = 204, description = "Folder deleted")), + tag = "Folders" +)] +pub async fn delete_folder( + State(state): State, + Path(folder_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?; + no_content() +} + +#[utoipa::path( + patch, + path = "/api/folders/{id}", + params(("id" = Uuid, Path, description = "Folder ID")), + request_body = UpdateFolderRequest, + responses((status = 204, description = "Folder updated")), + tag = "Folders" +)] +pub async fn update_folder( + State(state): State, + Path(folder_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?; + no_content() +} + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::folders::create_folder, + crate::routes::folders::ensure_folder_path, + crate::routes::folders::get_folder, + crate::routes::folders::list_folder_contents, + crate::routes::folders::list_folder_tree, + crate::routes::folders::delete_folder, + crate::routes::folders::update_folder + ), + components(schemas( + crate::services::folders::CreateFolderRequest, + crate::services::folders::EnsureFolderPathRequest, + crate::routes::folders::FolderResponse, + crate::services::folders::FolderInfo, + crate::services::folders::FolderContentsQuery, + crate::routes::folders::FolderContentsResponse, + crate::services::folders::FolderTreeNode, + crate::services::folders::UpdateFolderRequest + )) +)] +pub struct FoldersApiDoc; diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs new file mode 100644 index 0000000..b5759b5 --- /dev/null +++ b/backend/src/routes/health.rs @@ -0,0 +1,46 @@ +use axum::{extract::State, http::StatusCode, response::Json}; +use diesel::RunQueryDsl; +use serde_json::json; + +use crate::state::AppState; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(crate::routes::health::health_check))] +pub struct HealthApiDoc; + +#[utoipa::path( + get, + path = "/api/health", + responses((status = 200, description = "Service is healthy")), + tag = "Health" +)] +pub async fn health_check(State(state): State) -> (StatusCode, Json) { + let database_ok = match state.db_unscoped() { + Ok(mut conn) => diesel::sql_query("SELECT 1") + .execute(&mut conn) + .map(|_| true) + .unwrap_or_else(|err| { + tracing::error!(error = ?err, "health check database ping failed"); + false + }), + Err(err) => { + tracing::error!(error = ?err, "health check database connection failed"); + false + } + }; + + let status = if database_ok { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + + let payload = json!({ + "status": if database_ok { "ok" } else { "error" }, + "checks": { + "database": if database_ok { "ok" } else { "unavailable" } + } + }); + + (status, Json(payload)) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..147c024 --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1,496 @@ +use axum::http::HeaderValue; +use axum::{ + extract::DefaultBodyLimit, + middleware, + response::{Html, Json}, + routing::{delete, get, patch, post}, + Router, +}; +use std::sync::Arc; +use tower_http::{ + cors::{AllowOrigin, CorsLayer}, + trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer}, +}; +use utoipa::OpenApi; + +use crate::{ + auth::{capability_guard::RequireCapabilitiesLayer, AuthenticatedUser}, + models::ApiCapability, + openapi::ApiDoc, + state::AppState, +}; + +pub mod auth; +pub mod capability_sets; +pub mod correspondents; +pub mod documents; +pub mod folders; +pub mod health; +pub mod profile; +pub mod tags; +pub mod tenants; +pub mod webdav; + +pub fn create_router(state: AppState) -> Router<()> { + let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() { + let headers: Vec = origins + .split(',') + .filter_map(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| { + trimmed + .parse::() + .expect("invalid CORS allowed origin") + }) + }) + .collect(); + + let allow_origin = AllowOrigin::list(headers); + + CorsLayer::new() + .allow_origin(allow_origin) + .allow_methods(tower_http::cors::AllowMethods::mirror_request()) + .allow_headers(tower_http::cors::AllowHeaders::mirror_request()) + .allow_credentials(true) + } else { + CorsLayer::new() + .allow_origin(AllowOrigin::mirror_request()) + .allow_methods(tower_http::cors::AllowMethods::mirror_request()) + .allow_headers(tower_http::cors::AllowHeaders::mirror_request()) + .allow_credentials(true) + }; + + let auth_routes = Router::new() + .route("/signup/start", post(auth::signup_start)) + .route("/signup/finish", post(auth::signup_finish)) + .route("/login", post(auth::login)) + .route("/exchange-api-token", post(auth::api_token_exchange)) + .route("/refresh", post(auth::refresh)) + .route("/logout", post(auth::logout)) + .route("/select-tenant", post(auth::select_tenant)) + .route( + "/passkeys/register/start", + post(auth::passkey_register_start), + ) + .route( + "/passkeys/register/finish", + post(auth::passkey_register_finish), + ) + .route("/passkeys/login/start", post(auth::passkey_login_start)) + .route("/passkeys/login/finish", post(auth::passkey_login_finish)) + .route("/me", get(auth::me)); + + let documents_routes = Router::new() + .route( + "/check", + get(documents::check_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/", + get(documents::list_documents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/", + post(documents::upload_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ApiCapability::DocumentsUpload, + ])), + ) + .route( + "/bulk/move", + post(documents::bulk_move_documents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/bulk/tags", + post(documents::bulk_update_tags).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/bulk/correspondents", + post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/bulk/reanalyze", + post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/{id}", + get(documents::get_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/download", + post(documents::refresh_document_download).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/trash", + post(documents::trash_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/{id}", + delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/{id}", + patch(documents::update_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/assets", + get(documents::list_document_assets).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/assets", + post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/{id}/folder", + patch(documents::move_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/versions", + get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/versions/{version_id}", + get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/versions/{version_id}/download", + post(documents::refresh_document_version_download).layer( + RequireCapabilitiesLayer::all([ApiCapability::DocumentsRead]), + ), + ) + .route( + "/{id}/restore", + post(documents::restore_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/tags", + post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/tags/{tag_id}", + delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/correspondents", + post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/correspondents/{correspondent_id}", + delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ); + + let download_routes = + Router::new().route("/api/download/{token}", get(documents::download_with_token)); + + let folders_routes = Router::new() + .route( + "/", + post(folders::create_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), + ) + .route( + "/path", + post(folders::ensure_folder_path) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), + ) + .route( + "/tree", + get(folders::list_folder_tree) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), + ) + .route( + "/{id}", + get(folders::get_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), + ) + .route( + "/{id}", + delete(folders::delete_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), + ) + .route( + "/{id}", + patch(folders::update_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])), + ) + .route( + "/{id}/contents", + get(folders::list_folder_contents) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), + ); + + let tags_routes = Router::new() + .route( + "/", + get(tags::list_tags).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsRead])), + ) + .route( + "/", + post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])), + ) + .route( + "/{id}", + patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])), + ) + .route( + "/{id}", + delete(tags::delete_tag) + .layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])), + ); + + let correspondents_routes = Router::new() + .route( + "/", + get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsRead, + ])), + ) + .route( + "/", + post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsWrite, + ])), + ) + .route( + "/{id}", + patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsEdit, + ])), + ) + .route( + "/{id}", + delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsWrite, + ])), + ); + + let profile_routes = Router::new() + .route( + "/api-tokens", + get(profile::list_api_tokens) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])), + ) + .route( + "/api-tokens", + post(profile::create_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ) + .route( + "/api-tokens/{id}/regenerate", + post(profile::regenerate_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ) + .route( + "/api-tokens/{id}", + delete(profile::delete_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ) + .route( + "/passkeys", + get(profile::list_passkeys) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])), + ) + .route( + "/passkeys/{id}", + delete(profile::delete_passkey) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ); + + let capability_sets_routes = Router::new() + .route( + "/", + get(capability_sets::list_capability_sets).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsRead, + ])), + ) + .route( + "/", + post(capability_sets::create_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsWrite, + ])), + ) + .route( + "/{id}", + get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsRead, + ])), + ) + .route( + "/{id}", + patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsWrite, + ])), + ) + .route( + "/{id}", + delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsWrite, + ])), + ); + + let capabilities_routes = Router::new().route( + "/", + get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsRead, + ])), + ); + + let protected_state = state.clone(); + let assets_routes = Router::new() + .route( + "/{asset_id}", + get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{asset_id}/download", + post(documents::refresh_asset_download).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ); + + let manage_tenants_layer = RequireCapabilitiesLayer::all([ApiCapability::TenantsWrite]); + let tenants_routes = Router::new() + .route("/", get(tenants::list_tenants)) + .route("/{tenant_id}", get(tenants::get_tenant)) + .route( + "/{tenant_id}", + patch(tenants::update_tenant).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users", + get(tenants::list_tenant_users).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users/{user_id}", + get(tenants::get_tenant_user).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users/{user_id}", + patch(tenants::update_tenant_user).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users/{user_id}", + delete(tenants::delete_tenant_user).layer(manage_tenants_layer.clone()), + ); + + let protected_routes = Router::new() + .nest("/api/documents", documents_routes) + .nest("/api/folders", folders_routes) + .nest("/api/tags", tags_routes) + .nest("/api/correspondents", correspondents_routes) + .nest("/api/profile", profile_routes) + .nest("/api/capability-sets", capability_sets_routes) + .nest("/api/capabilities", capabilities_routes) + .nest("/api/assets", assets_routes) + .nest("/api/tenants", tenants_routes) + .layer(middleware::from_extractor_with_state::(protected_state)); + + let upload_limit = state.config.upload_body_limit_bytes; + + let openapi_spec = Arc::new(ApiDoc::openapi()); + let docs_router = Router::new() + .route( + "/api/docs", + get(move || async { Html(render_swagger_ui("/api/docs/openapi.json")) }), + ) + .route( + "/api/docs/openapi.json", + get({ + let spec = openapi_spec.clone(); + move || async move { Json((*spec).clone()) } + }), + ); + + Router::new() + .merge(download_routes) + .merge(protected_routes) + .merge(docs_router) + .nest("/api/auth", auth_routes) + .route("/api/health", get(health::health_check)) + .with_state(state) + .layer(cors) + .layer(DefaultBodyLimit::max( + usize::try_from(upload_limit).unwrap_or(usize::MAX), + )) + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO)) + .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)) + .on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)), + ) +} + +fn render_swagger_ui(spec_url: &str) -> String { + format!( + r#" + + + + Papercrate API Docs + + + + +
+ + + +"# + ) +} diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs new file mode 100644 index 0000000..f023079 --- /dev/null +++ b/backend/src/routes/profile.rs @@ -0,0 +1,150 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use utoipa::OpenApi; +use uuid::Uuid; + +use crate::{ + auth::{passkeys::PasskeySummary, TenantScopedConn}, + error::AppResult, + http::responders::JsonResponse, + services::profile::{ + ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService, + RevokePasskeyQuery, + }, + state::AppState, +}; + +#[utoipa::path( + get, + path = "/api/profile/passkeys", + responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])), + tag = "Profile" +)] +pub async fn list_passkeys( + State(state): State, + TenantScopedConn { + mut conn, user_id, .. + }: TenantScopedConn, +) -> AppResult>> { + ProfileService::new(&state).list_passkeys(&mut conn, user_id) +} + +#[utoipa::path( + get, + path = "/api/profile/api-tokens", + responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])), + tag = "Profile" +)] +pub async fn list_api_tokens( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id) +} + +#[utoipa::path( + post, + path = "/api/profile/api-tokens", + request_body = CreateApiTokenRequest, + responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)), + tag = "Profile" +)] +pub async fn create_api_token( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload) +} + +#[utoipa::path( + post, + path = "/api/profile/api-tokens/{id}/regenerate", + params(("id" = Uuid, Path, description = "API token ID")), + responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)), + tag = "Profile" +)] +pub async fn regenerate_api_token( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Path(token_id): Path, +) -> AppResult> { + ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id) +} + +#[utoipa::path( + delete, + path = "/api/profile/api-tokens/{id}", + params(("id" = Uuid, Path, description = "API token ID")), + responses((status = 204, description = "API token revoked")), + tag = "Profile" +)] +pub async fn delete_api_token( + State(state): State, + TenantScopedConn { + mut conn, user_id, .. + }: TenantScopedConn, + Path(token_id): Path, +) -> AppResult { + ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id) +} + +#[utoipa::path( + delete, + path = "/api/profile/passkeys/{id}", + params( + ("id" = Uuid, Path, description = "Passkey ID"), + ("reason" = Option, Query, description = "Optional reason for revoking the passkey") + ), + responses((status = 204, description = "Passkey revoked")), + tag = "Profile" +)] +pub async fn delete_passkey( + State(state): State, + TenantScopedConn { + mut conn, user_id, .. + }: TenantScopedConn, + Path(passkey_id): Path, + Query(query): Query, +) -> AppResult { + ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason) +} + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::profile::list_api_tokens, + crate::routes::profile::create_api_token, + crate::routes::profile::regenerate_api_token, + crate::routes::profile::delete_api_token, + crate::routes::profile::list_passkeys, + crate::routes::profile::delete_passkey + ), + components(schemas( + crate::models::ApiCapability, + crate::services::profile::ApiTokenResponse, + crate::services::profile::ApiTokenCreatedResponse, + crate::services::profile::CreateApiTokenRequest, + crate::services::profile::RevokePasskeyQuery, + crate::auth::passkeys::PasskeySummary + )) +)] +pub struct ProfileApiDoc; diff --git a/backend/src/routes/tags.rs b/backend/src/routes/tags.rs new file mode 100644 index 0000000..b95dd8e --- /dev/null +++ b/backend/src/routes/tags.rs @@ -0,0 +1,358 @@ +use axum::{extract::Path, http::StatusCode, Json}; +use diesel::{dsl::count_star, prelude::*}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + auth::TenantScopedConn, + error::{AppError, AppResult}, + http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt}, + models::{NewTag, Tag}, + schema::{document_tags, tags}, + utils::{ + json::deserialize_patch_field, + named_entity::{ensure_name_available, normalize_name}, + }, +}; + +#[derive(Deserialize, ToSchema)] +pub struct CreateTagRequest { + pub label: String, + #[schema(nullable)] + pub color: Option, +} + +#[derive(AsChangeset, Default)] +#[diesel(table_name = tags)] +struct UpdateTagChangeset<'a> { + label: Option<&'a str>, + color: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn update_tag_request_deserializes_null_fields() { + let request: UpdateTagRequest = serde_json::from_value(json!({ + "label": null, + "color": null + })) + .unwrap(); + assert!(matches!(request.label, Some(None))); + assert!(matches!(request.color, Some(None))); + } + + #[test] + fn update_tag_request_omitted_fields_are_none() { + let request: UpdateTagRequest = serde_json::from_value(json!({})).unwrap(); + assert!(request.label.is_none()); + assert!(request.color.is_none()); + } +} + +#[derive(Serialize, ToSchema)] +pub struct TagCatalogEntry { + pub id: Uuid, + pub label: String, + #[schema(nullable)] + pub color: Option, + pub usage_count: i64, +} + +#[derive(Debug, Default, Deserialize, ToSchema)] +pub struct UpdateTagRequest { + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable, value_type = Option)] + pub label: Option>, + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable, value_type = Option)] + pub color: Option>, +} + +#[utoipa::path( + get, + path = "/api/tags", + responses((status = 200, description = "Tags", body = [TagCatalogEntry])), + tag = "Tags" +)] +pub async fn list_tags( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let tag_list: Vec = tags::table + .filter(tags::tenant_id.eq(tenant_id)) + .order(tags::label.asc()) + .load(&mut conn)?; + + let usage_rows: Vec<(Uuid, i64)> = document_tags::table + .filter(document_tags::tenant_id.eq(tenant_id)) + .group_by(document_tags::tag_id) + .select((document_tags::tag_id, count_star())) + .load(&mut conn)?; + + let usage_map: HashMap = usage_rows.into_iter().collect(); + + let response: Vec = tag_list + .into_iter() + .map(|tag| TagCatalogEntry { + id: tag.id, + label: tag.label, + color: tag.color, + usage_count: *usage_map.get(&tag.id).unwrap_or(&0), + }) + .collect(); + + ok_json(response) +} + +#[utoipa::path( + post, + path = "/api/tags", + request_body = CreateTagRequest, + responses((status = 200, description = "Tag created", body = TagCatalogEntry)), + tag = "Tags" +)] +pub async fn create_tag( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let label = normalize_name(&payload.label, || { + AppError::bad_request("label must not be empty") + })?; + + let new_tag = NewTag { + id: Uuid::new_v4(), + label: label.clone(), + color: payload.color, + tenant_id, + }; + + match diesel::insert_into(tags::table) + .values(&new_tag) + .execute(&mut conn) + { + Ok(_) => {} + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UniqueViolation, + _, + )) => { + return Err(AppError::bad_request("tag label already exists")); + } + Err(err) => return Err(AppError::from(err)), + } + + let tag: Tag = tags::table + .find(new_tag.id) + .filter(tags::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + + ok_json(TagCatalogEntry { + id: tag.id, + label: tag.label, + color: tag.color, + usage_count: 0, + }) +} + +#[utoipa::path( + patch, + path = "/api/tags/{id}", + params(("id" = Uuid, Path, description = "Tag ID")), + request_body = UpdateTagRequest, + responses((status = 200, description = "Tag updated", body = TagCatalogEntry)), + tag = "Tags" +)] +pub async fn update_tag( + Path(tag_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let existing: Tag = tags::table + .find(tag_id) + .filter(tags::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + let UpdateTagRequest { label, color } = payload; + + if label.is_none() && color.is_none() { + let usage_count: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .select(count_star()) + .first(&mut conn)?; + return ok_json(TagCatalogEntry { + id: existing.id, + label: existing.label.clone(), + color: existing.color.clone(), + usage_count, + }); + } + + let mut new_label: Option = None; + let mut label_changed = false; + match label { + None => {} + Some(None) => { + return Err(AppError::bad_request("label cannot be null")); + } + Some(Some(value)) => { + let normalized = + normalize_name(&value, || AppError::bad_request("label must not be empty"))?; + if normalized != existing.label { + ensure_name_available( + || { + tags::table + .filter(tags::label.eq(&normalized)) + .filter(tags::id.ne(tag_id)) + .filter(tags::tenant_id.eq(tenant_id)) + .first::(&mut conn) + .optional() + }, + || AppError::bad_request("tag label already exists"), + )?; + new_label = Some(normalized); + label_changed = true; + } + } + } + + let mut color_change: Option> = None; + let mut color_changed = false; + match color { + None => {} + Some(None) => { + color_change = Some(None); + color_changed = true; + } + Some(Some(value)) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("color must not be empty")); + } + if existing.color.as_deref() != Some(trimmed) { + color_change = Some(Some(trimmed.to_string())); + color_changed = true; + } + } + } + + if !label_changed && !color_changed { + let usage_count: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .select(count_star()) + .first(&mut conn)?; + return ok_json(TagCatalogEntry { + id: existing.id, + label: existing.label.clone(), + color: existing.color.clone(), + usage_count, + }); + } + + let changeset = UpdateTagChangeset { + label: new_label.as_deref(), + color: color_change + .as_ref() + .map(|opt| opt.as_ref().map(|value| value.as_str())), + }; + + diesel::update( + tags::table + .find(tag_id) + .filter(tags::tenant_id.eq(tenant_id)), + ) + .set(&changeset) + .execute(&mut conn) + .into_app_result()? + .or_not_found()?; + + let updated: Tag = tags::table + .find(tag_id) + .filter(tags::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + let usage_count: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .select(count_star()) + .first(&mut conn)?; + + ok_json(TagCatalogEntry { + id: updated.id, + label: updated.label, + color: updated.color, + usage_count, + }) +} + +#[utoipa::path( + delete, + path = "/api/tags/{id}", + params(("id" = Uuid, Path, description = "Tag ID")), + responses((status = 204, description = "Tag deleted")), + tag = "Tags" +)] +pub async fn delete_tag( + Path(tag_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let usage: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .select(count_star()) + .first(&mut conn)?; + + if usage > 0 { + return Err(AppError::bad_request( + "cannot delete tag that is still assigned to documents", + )); + } + + diesel::delete( + tags::table + .find(tag_id) + .filter(tags::tenant_id.eq(tenant_id)), + ) + .execute(&mut conn) + .into_app_result()? + .or_not_found()?; + + no_content() +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::tags::list_tags, + crate::routes::tags::create_tag, + crate::routes::tags::update_tag, + crate::routes::tags::delete_tag + ), + components(schemas( + crate::routes::tags::CreateTagRequest, + crate::routes::tags::TagCatalogEntry, + crate::routes::tags::UpdateTagRequest + )) +)] +pub struct TagsApiDoc; diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs new file mode 100644 index 0000000..da79041 --- /dev/null +++ b/backend/src/routes/tenants.rs @@ -0,0 +1,154 @@ +use axum::extract::{Path, State}; +use axum::{http::StatusCode, Json}; +use uuid::Uuid; + +use crate::auth::{AuthenticatedUser, TenantMembershipUser}; +use crate::error::AppResult; +use crate::http::responders::JsonResponse; +use crate::services::auth::{AuthService, TenantSnippet}; +use crate::services::tenants::{ + TenantApiService, TenantUserListResponse, TenantUserSummary, UpdateTenantRequest, + UpdateTenantUserRequest, +}; +use crate::state::AppState; + +#[utoipa::path( + get, + path = "/api/tenants", + responses((status = 200, body = [TenantSnippet], description = "Tenant memberships for the current user")), + tag = "Tenants" +)] +pub async fn list_tenants( + State(state): State, + user: TenantMembershipUser, +) -> AppResult>> { + let response = AuthService::new(&state).list_tenants(user.user_id)?; + Ok(Json(response.into_inner().tenants)) +} + +#[utoipa::path( + get, + path = "/api/tenants/{tenant_id}", + params(("tenant_id" = Uuid, Path, description = "Tenant identifier")), + responses((status = 200, body = TenantSnippet, description = "Tenant details")), + tag = "Tenants" +)] +pub async fn get_tenant( + State(state): State, + Path(tenant_id): Path, + user: TenantMembershipUser, +) -> AppResult> { + AuthService::new(&state).get_tenant(user.user_id, tenant_id) +} + +#[utoipa::path( + patch, + path = "/api/tenants/{tenant_id}", + params(("tenant_id" = Uuid, Path, description = "Tenant identifier")), + request_body = UpdateTenantRequest, + responses((status = 200, body = TenantSnippet, description = "Updated tenant")), + tag = "Tenants" +)] +pub async fn update_tenant( + State(state): State, + Path(tenant_id): Path, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + TenantApiService::new(&state).update_name(user, tenant_id, payload) +} + +#[utoipa::path( + get, + path = "/api/tenants/{tenant_id}/users", + params(("tenant_id" = Uuid, Path, description = "Tenant ID")), + responses((status = 200, body = [TenantUserSummary], description = "All users for the tenant")), + tag = "Tenants" +)] +pub async fn list_tenant_users( + State(state): State, + Path(tenant_id): Path, + user: AuthenticatedUser, +) -> AppResult>> { + let response = TenantApiService::new(&state).list_users(&user, tenant_id)?; + Ok(Json(response.into_inner().users)) +} + +#[utoipa::path( + get, + path = "/api/tenants/{tenant_id}/users/{user_id}", + params( + ("tenant_id" = Uuid, Path, description = "Tenant ID"), + ("user_id" = Uuid, Path, description = "User ID") + ), + responses((status = 200, body = TenantUserSummary, description = "Tenant user details")), + tag = "Tenants" +)] +pub async fn get_tenant_user( + State(state): State, + Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>, + user: AuthenticatedUser, +) -> AppResult> { + TenantApiService::new(&state).get_user(&user, tenant_id, target_user_id) +} + +#[utoipa::path( + patch, + path = "/api/tenants/{tenant_id}/users/{user_id}", + params( + ("tenant_id" = Uuid, Path, description = "Tenant ID"), + ("user_id" = Uuid, Path, description = "User ID") + ), + request_body = UpdateTenantUserRequest, + responses((status = 200, body = TenantUserSummary, description = "Updated tenant user")), + tag = "Tenants" +)] +pub async fn update_tenant_user( + State(state): State, + Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + TenantApiService::new(&state).update_user(&user, tenant_id, target_user_id, payload) +} + +#[utoipa::path( + delete, + path = "/api/tenants/{tenant_id}/users/{user_id}", + params( + ("tenant_id" = Uuid, Path, description = "Tenant ID"), + ("user_id" = Uuid, Path, description = "User ID") + ), + responses((status = 204, description = "Membership removed")), + tag = "Tenants" +)] +pub async fn delete_tenant_user( + State(state): State, + Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>, + user: AuthenticatedUser, +) -> AppResult { + TenantApiService::new(&state).remove_user(&user, tenant_id, target_user_id)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + list_tenants, + get_tenant, + update_tenant, + list_tenant_users, + get_tenant_user, + update_tenant_user, + delete_tenant_user, + ), + components(schemas( + crate::services::auth::TenantListResponse, + crate::services::auth::TenantSnippet, + UpdateTenantRequest, + UpdateTenantUserRequest, + TenantUserListResponse, + TenantUserSummary, + )) +)] +pub struct TenantsApiDoc; diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs new file mode 100644 index 0000000..5b14b61 --- /dev/null +++ b/backend/src/routes/webdav/mod.rs @@ -0,0 +1,846 @@ +use std::time::Duration; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{header, HeaderMap, Method, StatusCode}; +use axum::response::Response; +use axum::Router; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use diesel::prelude::*; +use diesel::OptionalExtension; +use diesel::PgConnection; +use futures_util::StreamExt; +use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; +use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}; +use quick_xml::Writer; +use uuid::Uuid; + +use crate::auth::{ + api_tokens::{find_active_token_by_secret, touch_api_token}, + ensure_active_tenant_with_conn, +}; +use crate::error::{AppError, AppResult}; +use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User}; +use crate::schema::{ + document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, + folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, +}; +use crate::state::{AppState, PgPooledConnection}; +use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc}; +use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date}; + +const REALM: &str = "Papercrate WebDAV"; +const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; + +struct WebDavContext { + tenant_id: Uuid, + _user_id: Uuid, + _username: String, + conn: PgPooledConnection, +} + +pub fn create_router() -> Router { + Router::new().fallback(webdav_entrypoint) +} + +async fn webdav_entrypoint( + State(state): State, + req: axum::http::Request, +) -> Result { + let method = req.method().clone(); + let headers = req.headers().clone(); + let path = req.uri().path().trim_start_matches('/').to_string(); + + tracing::debug!(method = %method, %path, "webdav entrypoint" ); + + match method { + ref m if m == Method::OPTIONS => Ok(handle_options()), + ref m if m == Method::GET => handle_get_or_head(&state, &path, headers, Method::GET).await, + ref m if m == Method::HEAD => { + handle_get_or_head(&state, &path, headers, Method::HEAD).await + } + _ => { + if method.as_str() == "PROPFIND" { + handle_propfind(&state, &path, headers).await + } else { + Ok(method_not_allowed()) + } + } + } +} + +async fn handle_propfind( + state: &AppState, + path: &str, + headers: HeaderMap, +) -> Result { + let mut context = match authenticate(state, &headers)? { + Some(user) => user, + None => return Ok(unauthorized_response()), + }; + + let depth = match parse_depth(&headers) { + Ok(value) => value, + Err(response) => return Ok(response), + }; + + let segments = parse_segments(path)?; + + let tenant_id = context.tenant_id; + + let resources = if segments.is_empty() { + let contents = fetch_folder_contents(&mut context.conn, tenant_id, None)?; + build_resources_for_folder(None, &[], &contents, depth) + } else { + let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? { + Some(resolved) => resolved, + None => return Ok(not_found_response()), + }; + + match resolution { + ResolvedPath::Folder { folder, chain } => { + let contents = + fetch_folder_contents(&mut context.conn, tenant_id, Some(folder.id))?; + build_resources_for_folder(Some(&folder), &chain, &contents, depth) + } + ResolvedPath::Document { + document, + version, + chain, + } => build_resources_for_document(&chain, &document, &version), + } + }; + + let body = render_multistatus(&resources).map_err(|err| { + tracing::error!(error = ?err, "failed to render WebDAV response"); + AppError::internal("failed to render WebDAV response") + })?; + + let response = Response::builder() + .status(multi_status()) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(body)) + .expect("valid response"); + + Ok(response) +} + +async fn handle_get_or_head( + state: &AppState, + path: &str, + headers: HeaderMap, + method: Method, +) -> Result { + let mut context = match authenticate(state, &headers)? { + Some(user) => user, + None => return Ok(unauthorized_response()), + }; + + let tenant_id = context.tenant_id; + let segments = parse_segments(path)?; + if segments.is_empty() { + return Ok(method_not_allowed()); + } + + let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? { + Some(resolved) => resolved, + None => return Ok(not_found_response()), + }; + + let (document, version, chain) = match resolution { + ResolvedPath::Document { + document, + version, + chain, + } => (document, version, chain), + _ => return Ok(method_not_allowed()), + }; + + stream_document(state, &document, &version, &chain, headers, method).await +} + +fn handle_options() -> Response { + Response::builder() + .status(StatusCode::OK) + .header("DAV", "1,2") + .header(header::ALLOW, "OPTIONS, PROPFIND, GET, HEAD") + .header("Accept-Ranges", "bytes") + .body(Body::empty()) + .expect("valid OPTIONS response") +} + +fn method_not_allowed() -> Response { + Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::empty()) + .expect("valid response") +} + +fn not_found_response() -> Response { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .expect("valid response") +} + +fn unauthorized_response() -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + header::WWW_AUTHENTICATE, + format!("Basic realm=\"{REALM}\", charset=\"UTF-8\""), + ) + .body(Body::empty()) + .expect("valid response") +} + +fn multi_status() -> StatusCode { + StatusCode::from_u16(207).expect("valid multi-status") +} + +fn parse_depth(headers: &HeaderMap) -> Result { + match headers.get("Depth") { + None => Ok(1), + Some(value) => match value.to_str() { + Ok("0") => Ok(0), + Ok("1") => Ok(1), + Ok("infinity") => Err(Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::empty()) + .expect("valid response")), + _ => Err(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::empty()) + .expect("valid response")), + }, + } +} + +fn parse_segments(path: &str) -> AppResult> { + if path.trim_matches('/').is_empty() { + return Ok(vec![]); + } + + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| { + percent_decode_str(segment) + .decode_utf8() + .map(|cow| cow.into_owned()) + .map_err(|_| AppError::bad_request("invalid UTF-8 in path")) + }) + .collect::, _>>()?; + + Ok(segments) +} + +fn fetch_folder_contents( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Option, +) -> AppResult { + let folder = match folder_id { + Some(id) => Some( + folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn)?, + ), + None => None, + }; + + let subfolders: Vec = match folder_id { + Some(id) => folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .filter(folders_dsl::parent_id.eq(Some(id))) + .order(folders_dsl::name.asc()) + .load(conn)?, + None => folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .filter(folders_dsl::parent_id.is_null()) + .order(folders_dsl::name.asc()) + .load(conn)?, + }; + + let mut docs_query = documents_dsl::documents + .filter(documents_dsl::deleted_at.is_null()) + .filter(documents_dsl::tenant_id.eq(tenant_id)) + .into_boxed(); + + docs_query = match folder_id { + Some(id) => docs_query.filter(documents_dsl::folder_id.eq(Some(id))), + None => docs_query.filter(documents_dsl::folder_id.is_null()), + }; + + let documents: Vec = docs_query + .order(documents_dsl::created_at.desc()) + .load(conn)?; + + let version_ids: Vec = documents.iter().map(|doc| doc.current_version_id).collect(); + let versions: Vec = if version_ids.is_empty() { + Vec::new() + } else { + document_versions_dsl::document_versions + .filter(document_versions_dsl::id.eq_any(&version_ids)) + .load(conn)? + }; + + let mut version_map = versions + .into_iter() + .map(|version| (version.id, version)) + .collect::>(); + + let mut entries = Vec::with_capacity(documents.len()); + for document in documents { + if let Some(version) = version_map.remove(&document.current_version_id) { + entries.push(DocumentEntry { document, version }); + } + } + + Ok(WebDavFolderContents { + _folder: folder, + subfolders, + documents: entries, + }) +} + +async fn stream_document( + state: &AppState, + document: &Document, + version: &DocumentVersion, + _chain: &[String], + headers: HeaderMap, + method: Method, +) -> Result { + let range_header = headers.get(header::RANGE).cloned(); + + let storage = state.storage_for_tenant(document.tenant_id)?; + + let url = storage + .presign_get_object( + &version.s3_key, + Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS), + None, + ) + .await + .storage_context("failed to presign document download")?; + + let client = reqwest::Client::new(); + let mut request = client.request(method.clone(), url.clone()); + + if let Some(range) = range_header.clone() { + request = request.header(header::RANGE, range.clone()); + } + + let upstream = request.send().await.map_err(|err| { + tracing::error!(error = ?err, "failed to fetch document stream"); + AppError::internal("failed to fetch document stream") + })?; + + let status = + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + + if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) { + tracing::error!(status = %status, "upstream download returned error status"); + return Err(AppError::internal("failed to fetch document stream")); + } + + let mut builder = Response::builder().status(status); + + if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) { + builder = builder.header(header::CONTENT_TYPE, content_type); + } else if let Some(ref typ) = document.mime_type { + builder = builder.header(header::CONTENT_TYPE, typ); + } + + if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) { + builder = builder.header(header::CONTENT_LENGTH, content_length); + } + + if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) { + builder = builder.header(header::CONTENT_RANGE, range); + } + + builder = builder.header("Accept-Ranges", "bytes"); + + if let Some(disposition) = inline_content_disposition(&document.filename) { + builder = builder.header(header::CONTENT_DISPOSITION, disposition); + } + + builder = builder.header(header::ETAG, format!("\"{}\"", version.id)); + + if method == Method::HEAD { + return builder.body(Body::empty()).map_err(|err| { + tracing::error!(error = ?err, "failed to build WebDAV response"); + AppError::internal("failed to build WebDAV response") + }); + } + + let stream = upstream + .bytes_stream() + .map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))); + let body = Body::from_stream(stream); + + builder.body(body).map_err(|err| { + tracing::error!(error = ?err, "failed to build WebDAV response"); + AppError::internal("failed to build WebDAV response") + }) +} + +fn authenticate(state: &AppState, headers: &HeaderMap) -> Result, AppError> { + tracing::debug!("webdav authenticate invoked"); + let authorization = match headers.get(header::AUTHORIZATION) { + Some(value) => match value.to_str() { + Ok(header) if header.starts_with("Basic ") => { + tracing::debug!("authorization header present"); + &header[6..] + } + Ok(other) => { + tracing::warn!(header = %other, "non-basic authorization header"); + return Ok(None); + } + Err(err) => { + tracing::warn!(error = %err, "invalid authorization header"); + return Ok(None); + } + }, + None => { + tracing::debug!("no authorization header"); + return Ok(None); + } + }; + + let decoded = match BASE64.decode(authorization) { + Ok(bytes) => bytes, + Err(err) => { + tracing::warn!(error = %err, "failed to decode basic credentials"); + return Ok(None); + } + }; + + let credential_str = match String::from_utf8(decoded) { + Ok(value) => value, + Err(err) => { + tracing::warn!(error = %err, "invalid utf-8 basic credentials"); + return Ok(None); + } + }; + + let (presented_username, secret) = match credential_str.split_once(':') { + Some((username, secret)) if !username.is_empty() => (username, secret), + _ => return Ok(None), + }; + + tracing::debug!(presented_username = %presented_username, "attempting webdav login"); + let mut conn = state.db_unscoped()?; + + let token = match find_active_token_by_secret( + &mut conn, + None, + secret, + Some(ApiCapability::WebdavRead), + )? { + Some(token) => token, + None => { + tracing::warn!(presented_username = %presented_username, "webdav token invalid or expired"); + return Ok(None); + } + }; + + let user: User = match users_dsl::users.find(token.user_id).first(&mut conn) { + Ok(user) => user, + Err(diesel::result::Error::NotFound) => { + tracing::warn!( + presented_username = %presented_username, + user_id = %token.user_id, + "webdav token user missing" + ); + return Ok(None); + } + Err(err) => return Err(AppError::from(err)), + }; + + apply_user_guc(&mut conn, user.id)?; + + let membership_exists = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) + .select(memberships_dsl::tenant_id) + .first::(&mut conn) + .optional()?; + + clear_user_guc(&mut conn)?; + + let tenant_id = match membership_exists { + Some(id) => id, + None => { + tracing::warn!( + presented_username = %presented_username, + username = %user.username, + tenant_id = %token.tenant_id, + "webdav token tenant membership missing" + ); + return Ok(None); + } + }; + + if let Err(err) = ensure_active_tenant_with_conn(&mut conn, tenant_id) { + tracing::warn!(tenant_id = %tenant_id, error = ?err, "webdav tenant not active"); + return Ok(None); + } + + apply_tenant_guc(&mut conn, tenant_id)?; + touch_api_token(&mut conn, token.id)?; + + tracing::debug!( + presented_username = %presented_username, + username = %user.username, + tenant_id = %tenant_id, + token_id = %token.id, + "webdav token login success" + ); + Ok(Some(WebDavContext { + tenant_id, + _user_id: user.id, + _username: user.username, + conn, + })) +} + +fn build_resources_for_folder( + folder: Option<&Folder>, + chain: &[String], + contents: &WebDavFolderContents, + depth: u8, +) -> Vec { + let mut resources = Vec::new(); + + let display_name = folder + .map(|folder| folder.name.clone()) + .unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string())); + + let href = build_href(chain, true); + let last_modified = folder.map(|folder| to_http_date(folder.updated_at)); + + resources.push(DavResource { + href, + display_name, + is_collection: true, + content_length: None, + mime_type: None, + last_modified, + }); + + if depth == 0 { + return resources; + } + + for subfolder in &contents.subfolders { + let mut child_chain = chain.to_vec(); + child_chain.push(subfolder.name.clone()); + resources.push(DavResource { + href: build_href(&child_chain, true), + display_name: subfolder.name.clone(), + is_collection: true, + content_length: None, + mime_type: None, + last_modified: Some(to_http_date(subfolder.updated_at)), + }); + } + + for entry in &contents.documents { + let mut child_chain = chain.to_vec(); + child_chain.push(entry.document.filename.clone()); + resources.push(document_to_resource( + &child_chain, + &entry.document, + &entry.version, + )); + } + + resources +} + +fn build_resources_for_document( + chain: &[String], + document: &Document, + version: &DocumentVersion, +) -> Vec { + vec![document_to_resource(chain, document, version)] +} + +fn document_to_resource( + chain: &[String], + document: &Document, + version: &DocumentVersion, +) -> DavResource { + let href = build_href(chain, false); + + DavResource { + href, + display_name: document.title.clone(), + is_collection: false, + content_length: Some(version.size_bytes), + mime_type: document.mime_type.clone(), + last_modified: Some(to_http_date(document.updated_at)), + } +} + +fn build_href(names: &[String], is_collection: bool) -> String { + if names.is_empty() { + return "/".to_string(); + } + + let encoded = names + .iter() + .map(|name| utf8_percent_encode(name, NON_ALPHANUMERIC).to_string()) + .collect::>(); + + let mut path = format!("/{}", encoded.join("/")); + if is_collection && !path.ends_with('/') { + path.push('/'); + } + path +} + +fn render_multistatus(resources: &[DavResource]) -> Result, quick_xml::Error> { + let mut writer = Writer::new(Vec::new()); + writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; + + let mut multistatus = BytesStart::new("D:multistatus"); + multistatus.push_attribute(("xmlns:D", "DAV:")); + writer.write_event(Event::Start(multistatus))?; + + for resource in resources { + writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + writer.write_event(Event::Start(BytesStart::new("D:href")))?; + writer.write_event(Event::Text(BytesText::new(&resource.href)))?; + writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + + writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + writer.write_event(Event::Text(BytesText::new(&resource.display_name)))?; + writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + + writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + if resource.is_collection { + writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + } + writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + + if let Some(length) = resource.content_length { + writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + writer.write_event(Event::Text(BytesText::new(&length.to_string())))?; + writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + } + + if let Some(content_type) = &resource.mime_type { + writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + writer.write_event(Event::Text(BytesText::new(content_type)))?; + writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + } + + if let Some(last_modified) = &resource.last_modified { + writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + writer.write_event(Event::Text(BytesText::new(last_modified)))?; + writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + } + + writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + + writer.write_event(Event::Start(BytesStart::new("D:status")))?; + writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + writer.write_event(Event::End(BytesEnd::new("D:status")))?; + + writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + writer.write_event(Event::End(BytesEnd::new("D:response")))?; + } + + writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Ok(writer.into_inner()) +} + +struct WebDavFolderContents { + _folder: Option, + subfolders: Vec, + documents: Vec, +} + +struct DocumentEntry { + document: Document, + version: DocumentVersion, +} + +struct DavResource { + href: String, + display_name: String, + is_collection: bool, + content_length: Option, + mime_type: Option, + last_modified: Option, +} +enum ResolvedPath { + Folder { + folder: Folder, + chain: Vec, + }, + Document { + document: Document, + version: DocumentVersion, + chain: Vec, + }, +} + +fn resolve_path( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + segments: &[String], +) -> AppResult> { + let mut parent_id: Option = None; + let mut chain: Vec = Vec::new(); + let mut current_folder: Option = None; + + for (index, segment) in segments.iter().enumerate() { + let is_last = index == segments.len() - 1; + + if let Some(folder) = find_folder_by_name(conn, tenant_id, parent_id, segment)? { + chain.push(folder.name.clone()); + if is_last { + return Ok(Some(ResolvedPath::Folder { folder, chain })); + } + parent_id = Some(folder.id); + current_folder = Some(folder); + continue; + } + + if is_last { + if let Some((document, version)) = + find_document_by_filename(conn, tenant_id, parent_id, segment)? + { + chain.push(document.filename.clone()); + return Ok(Some(ResolvedPath::Document { + document, + version, + chain, + })); + } + } + + if let Ok(uuid) = Uuid::parse_str(segment) { + if let Some(folder) = find_folder_by_id(conn, tenant_id, uuid)? { + if folder.parent_id != parent_id { + return Ok(None); + } + chain.push(folder.name.clone()); + if is_last { + return Ok(Some(ResolvedPath::Folder { folder, chain })); + } + parent_id = Some(folder.id); + current_folder = Some(folder); + continue; + } + + if let Some((document, version)) = find_document_by_id(conn, tenant_id, uuid)? { + if document.folder_id != parent_id { + return Ok(None); + } + chain.push(document.filename.clone()); + return Ok(Some(ResolvedPath::Document { + document, + version, + chain, + })); + } + } + + return Ok(None); + } + + Ok(current_folder.map(|folder| ResolvedPath::Folder { folder, chain })) +} + +fn find_folder_by_name( + conn: &mut PgConnection, + tenant_id: Uuid, + parent_id: Option, + name: &str, +) -> AppResult> { + let mut query = folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .into_boxed(); + + query = match parent_id { + Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))), + None => query.filter(folders_dsl::parent_id.is_null()), + }; + + Ok(query + .filter(folders_dsl::name.eq(name)) + .first::(conn) + .optional()?) +} + +fn find_folder_by_id( + conn: &mut PgConnection, + tenant_id: Uuid, + folder_id: Uuid, +) -> AppResult> { + Ok(folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .find(folder_id) + .first::(conn) + .optional()?) +} + +fn find_document_by_filename( + conn: &mut PgConnection, + tenant_id: Uuid, + parent_id: Option, + filename: &str, +) -> AppResult> { + let mut query = documents_dsl::documents + .filter(documents_dsl::deleted_at.is_null()) + .filter(documents_dsl::tenant_id.eq(tenant_id)) + .filter(documents_dsl::filename.eq(filename)) + .into_boxed(); + + query = match parent_id { + Some(parent) => query.filter(documents_dsl::folder_id.eq(Some(parent))), + None => query.filter(documents_dsl::folder_id.is_null()), + }; + + if let Some(document) = query.first::(conn).optional()? { + let version = document_versions_dsl::document_versions + .find(document.current_version_id) + .first::(conn)?; + return Ok(Some((document, version))); + } + + Ok(None) +} + +fn find_document_by_id( + conn: &mut PgConnection, + tenant_id: Uuid, + document_id: Uuid, +) -> AppResult> { + if let Some(document) = documents_dsl::documents + .filter(documents_dsl::deleted_at.is_null()) + .filter(documents_dsl::tenant_id.eq(tenant_id)) + .find(document_id) + .first::(conn) + .optional()? + { + let version = document_versions_dsl::document_versions + .find(document.current_version_id) + .first::(conn)?; + return Ok(Some((document, version))); + } + + Ok(None) +} diff --git a/backend/src/s3.rs b/backend/src/s3.rs new file mode 100644 index 0000000..9791146 --- /dev/null +++ b/backend/src/s3.rs @@ -0,0 +1,34 @@ +use anyhow::{anyhow, Context, Result}; +use s3::{bucket::Bucket, creds::Credentials, region::Region}; + +use crate::config::AppConfig; + +pub fn build_bucket(config: &AppConfig) -> Result { + let region = if let Some(endpoint) = &config.aws_endpoint_url { + Region::Custom { + region: config.aws_region.clone(), + endpoint: endpoint.clone(), + } + } else { + config + .aws_region + .parse::() + .context("invalid AWS region")? + }; + + let credentials = if let (Some(access_key), Some(secret_key)) = ( + config.aws_access_key_id.as_deref(), + config.aws_secret_access_key.as_deref(), + ) { + Credentials::new(Some(access_key), Some(secret_key), None, None, None) + .context("failed to create static AWS credentials")? + } else { + Credentials::default().context("failed to load AWS credentials")? + }; + + let bucket = Bucket::new(&config.s3_bucket, region, credentials) + .map_err(|err| anyhow!("failed to create S3 bucket client: {err}"))?; + let bucket = bucket.with_path_style(); + + Ok(*bucket) +} diff --git a/backend/src/schema.rs b/backend/src/schema.rs new file mode 100644 index 0000000..c21d80e --- /dev/null +++ b/backend/src/schema.rs @@ -0,0 +1,334 @@ +// @generated automatically by Diesel CLI. + +pub mod sql_types { + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "magic_token_kind"))] + pub struct MagicTokenKind; + + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "tenant_status"))] + pub struct TenantStatus; + + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "api_capability"))] + pub struct ApiCapability; +} + +diesel::table! { + correspondents (id) { + id -> Uuid, + #[max_length = 255] + name -> Varchar, + metadata -> Jsonb, + created_at -> Timestamptz, + updated_at -> Timestamptz, + tenant_id -> Uuid, + } +} + +diesel::table! { + document_assets (id) { + id -> Uuid, + document_version_id -> Uuid, + asset_type -> Text, + mime_type -> Text, + metadata -> Jsonb, + created_at -> Timestamptz, + s3_key -> Text, + tenant_id -> Uuid, + } +} + +diesel::table! { + document_correspondents (document_id, correspondent_id) { + document_id -> Uuid, + correspondent_id -> Uuid, + assigned_at -> Timestamptz, + assigned_by -> Nullable, + tenant_id -> Uuid, + } +} + +diesel::table! { + document_tags (document_id, tag_id) { + document_id -> Uuid, + tag_id -> Uuid, + assigned_at -> Timestamptz, + assigned_by -> Nullable, + tenant_id -> Uuid, + } +} + +diesel::table! { + document_versions (id) { + id -> Uuid, + document_id -> Uuid, + version_number -> Int4, + #[max_length = 500] + s3_key -> Varchar, + size_bytes -> Int8, + #[max_length = 64] + checksum -> Varchar, + created_at -> Timestamptz, + metadata -> Jsonb, + tenant_id -> Uuid, + } +} + +diesel::table! { + documents (id) { + id -> Uuid, + #[max_length = 255] + filename -> Varchar, + #[max_length = 255] + original_name -> Varchar, + #[max_length = 100] + mime_type -> Nullable, + folder_id -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + deleted_at -> Nullable, + metadata -> Jsonb, + issued_at -> Nullable, + #[max_length = 255] + title -> Varchar, + current_version_id -> Uuid, + tenant_id -> Uuid, + } +} + +diesel::table! { + folders (id) { + id -> Uuid, + #[max_length = 255] + name -> Varchar, + parent_id -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + tenant_id -> Uuid, + } +} + +diesel::table! { + jobs (id) { + id -> Uuid, + job_type -> Text, + payload -> Jsonb, + status -> Text, + attempts -> Int4, + run_after -> Timestamptz, + last_error -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + tenant_id -> Nullable, + result -> Nullable, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::MagicTokenKind; + + magic_tokens (id) { + id -> Uuid, + user_id -> Uuid, + kind -> MagicTokenKind, + token_hash -> Varchar, + metadata -> Jsonb, + expires_at -> Timestamptz, + max_uses -> Nullable, + used_count -> Int4, + created_at -> Timestamptz, + created_by -> Nullable, + last_used_at -> Nullable, + } +} + +diesel::table! { + user_sessions (id) { + id -> Uuid, + user_id -> Uuid, + token_hash -> Text, + issued_at -> Timestamptz, + expires_at -> Timestamptz, + revoked_at -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + tenant_id -> Uuid, + } +} + +diesel::table! { + tags (id) { + id -> Uuid, + #[max_length = 100] + label -> Varchar, + #[max_length = 7] + color -> Nullable, + created_at -> Timestamptz, + tenant_id -> Uuid, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::TenantStatus; + + tenants (id) { + id -> Uuid, + name -> Text, + storage_root -> Nullable, + quickwit_index -> Nullable, + config -> Jsonb, + created_at -> Timestamptz, + updated_at -> Timestamptz, + status -> TenantStatus, + created_by -> Nullable, + } +} + +diesel::table! { + user_memberships (id) { + id -> Uuid, + user_id -> Uuid, + tenant_id -> Uuid, + created_at -> Timestamptz, + updated_at -> Timestamptz, + capability_set_id -> Nullable, + } +} + +diesel::table! { + user_passkeys (id) { + id -> Uuid, + user_id -> Uuid, + credential_id -> Bytea, + public_key -> Bytea, + credential -> Jsonb, + sign_count -> Int8, + transports -> Array>, + aaguid -> Nullable, + nickname -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + last_used_at -> Nullable, + revoked_at -> Nullable, + revoked_by -> Nullable, + revoked_reason -> Nullable, + } +} + +diesel::table! { + users (id) { + id -> Uuid, + #[max_length = 100] + username -> Varchar, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + webauthn_challenges (id) { + id -> Uuid, + user_id -> Nullable, + purpose -> Text, + challenge -> Bytea, + state -> Bytea, + created_at -> Timestamptz, + expires_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + + capability_sets (id) { + id -> Uuid, + tenant_id -> Uuid, + slug -> Text, + cap_version -> Int4, + is_system -> Bool, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::ApiCapability; + + capability_set_capabilities (capability_set_id, capability) { + capability_set_id -> Uuid, + capability -> ApiCapability, + } +} + +diesel::table! { + api_tokens (id) { + id -> Uuid, + user_id -> Uuid, + tenant_id -> Uuid, + token_prefix -> Text, + token_hash -> Text, + label -> Nullable, + created_at -> Timestamptz, + last_used_at -> Nullable, + expires_at -> Nullable, + revoked_at -> Nullable, + capability_set_id -> Uuid, + } +} + +diesel::joinable!(correspondents -> tenants (tenant_id)); +diesel::joinable!(capability_set_capabilities -> capability_sets (capability_set_id)); +diesel::joinable!(capability_sets -> tenants (tenant_id)); +diesel::joinable!(document_assets -> document_versions (document_version_id)); +diesel::joinable!(document_assets -> tenants (tenant_id)); +diesel::joinable!(document_correspondents -> correspondents (correspondent_id)); +diesel::joinable!(document_correspondents -> documents (document_id)); +diesel::joinable!(document_correspondents -> tenants (tenant_id)); +diesel::joinable!(document_correspondents -> users (assigned_by)); +diesel::joinable!(document_tags -> documents (document_id)); +diesel::joinable!(document_tags -> tags (tag_id)); +diesel::joinable!(document_tags -> tenants (tenant_id)); +diesel::joinable!(document_tags -> users (assigned_by)); +diesel::joinable!(document_versions -> tenants (tenant_id)); +diesel::joinable!(documents -> folders (folder_id)); +diesel::joinable!(documents -> tenants (tenant_id)); +diesel::joinable!(folders -> tenants (tenant_id)); +diesel::joinable!(jobs -> tenants (tenant_id)); +diesel::joinable!(magic_tokens -> users (user_id)); +diesel::joinable!(user_sessions -> tenants (tenant_id)); +diesel::joinable!(user_sessions -> users (user_id)); +diesel::joinable!(tags -> tenants (tenant_id)); +diesel::joinable!(user_memberships -> capability_sets (capability_set_id)); +diesel::joinable!(user_memberships -> tenants (tenant_id)); +diesel::joinable!(user_memberships -> users (user_id)); +diesel::joinable!(user_passkeys -> users (user_id)); +diesel::joinable!(webauthn_challenges -> users (user_id)); +diesel::joinable!(api_tokens -> tenants (tenant_id)); +diesel::joinable!(api_tokens -> capability_sets (capability_set_id)); +diesel::joinable!(api_tokens -> users (user_id)); + +diesel::allow_tables_to_appear_in_same_query!( + api_tokens, + correspondents, + capability_set_capabilities, + capability_sets, + document_assets, + document_correspondents, + document_tags, + document_versions, + documents, + folders, + jobs, + magic_tokens, + user_sessions, + tags, + tenants, + user_memberships, + user_passkeys, + users, + webauthn_challenges, +); diff --git a/backend/src/services/auth.rs b/backend/src/services/auth.rs new file mode 100644 index 0000000..bb82af6 --- /dev/null +++ b/backend/src/services/auth.rs @@ -0,0 +1,884 @@ +use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use chrono::{Duration as ChronoDuration, TimeZone, Utc}; +use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension}; +use rand::{rngs::OsRng, TryRngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use utoipa::ToSchema; +use uuid::Uuid; +use webauthn_rs::prelude::RegisterPublicKeyCredential; + +use crate::auth::{ + api_tokens::{find_active_token_by_secret, touch_api_token}, + capability_sets::load_capability_set, + jwt::{AccessTokenContext, PrincipalKind}, + passkeys::{ + AuthenticationChallengeResponse, PasskeyLoginFinishPayload, + PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, + }, + AuthenticatedUser, +}; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{ok_json, JsonResponse}; +use crate::models::{ + MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership, + UserSession, +}; +use crate::schema::{ + magic_tokens::dsl as magic_dsl, + tenants::dsl as tenant_dsl, + user_memberships::dsl as memberships_dsl, + user_passkeys::dsl as passkey_dsl, + user_sessions::{self, dsl as session_dsl}, + users::dsl, +}; +use crate::state::{AppState, PgPooledConnection}; +use crate::tenants::{ + apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc, + clear_user_session_hash, +}; +use crate::utils::text::normalize_identifier; + +pub const SESSION_COOKIE_NAME: &str = "refresh_token"; + +#[derive(Deserialize, ToSchema)] +pub struct LoginRequest { + #[serde(default)] + pub username: String, + #[serde(default)] + #[schema(nullable)] + pub password: Option, + #[serde(default)] + #[schema(nullable)] + pub magic_token: Option, + #[serde(default)] + #[schema(nullable)] + pub preferred_tenant_id: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ApiTokenExchangeRequest { + pub api_token: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct LoginResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: i64, + pub tenant: TenantSnippet, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct TenantSnippet { + pub id: Uuid, + pub name: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct TenantSelectionResponse { + pub access_token: String, + pub tenants: Vec, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct TenantListResponse { + pub tenants: Vec, +} + +#[derive(Deserialize, ToSchema)] +pub struct TenantSelectionRequest { + pub tenant_id: Uuid, +} + +#[derive(Deserialize, ToSchema)] +pub struct SignupStartRequest { + pub username: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SignupStartResponse { + pub signup_token: String, + pub challenge: RegistrationChallengeResponse, +} + +#[derive(Deserialize, ToSchema)] +pub struct SignupFinishRequest { + pub signup_token: String, + #[schema(value_type = Object)] + pub credential: RegisterPublicKeyCredential, + #[schema(nullable)] + pub nickname: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(untagged)] +pub enum LoginResponseVariants { + Token(LoginResponse), + Selection(TenantSelectionResponse), +} + +pub struct AuthService<'a> { + state: &'a AppState, +} + +impl<'a> AuthService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn login(&self, payload: LoginRequest) -> AppResult { + let magic_token = payload + .magic_token + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + + if magic_token.is_none() { + if payload.password.is_some() { + return Err(AppError::bad_request( + "password authentication is no longer supported", + )); + } + + return Err(AppError::bad_request( + "magic_token is required for passwordless login", + )); + } + + let token_value = magic_token.unwrap(); + + let mut conn = self.state.db_unscoped()?; + let username_hint = payload.username.trim(); + let preferred_tenant_id = payload.preferred_tenant_id; + + self.magic_token_login( + &mut conn, + token_value, + (!username_hint.is_empty()).then_some(username_hint), + preferred_tenant_id, + ) + } + + pub fn exchange_api_token( + &self, + payload: ApiTokenExchangeRequest, + ) -> AppResult> { + let secret = payload.api_token.trim(); + if secret.is_empty() { + return Err(AppError::bad_request("api_token must not be empty")); + } + + let mut conn = self.state.db_unscoped()?; + + let token = find_active_token_by_secret(&mut conn, None, secret, None)? + .ok_or_else(AppError::unauthorized)?; + + let user: User = dsl::users.find(token.user_id).first(&mut conn)?; + + apply_user_guc(&mut conn, user.id)?; + let membership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) + .first::(&mut conn) + .optional()?; + clear_user_guc(&mut conn)?; + + let membership = membership.ok_or_else(AppError::unauthorized)?; + + let membership_capability_set = membership.capability_set_id.ok_or_else(|| { + AppError::new( + StatusCode::FORBIDDEN, + "membership has no capability set assigned", + ) + })?; + + let token_capability_set = load_capability_set(&mut conn, token.capability_set_id)?; + let _membership_set = load_capability_set(&mut conn, membership_capability_set)?; + + apply_tenant_guc(&mut conn, token.tenant_id)?; + touch_api_token(&mut conn, token.id)?; + + let access_token = self + .state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: token.tenant_id, + username: user.username.clone(), + principal_kind: PrincipalKind::ApiToken, + principal_id: token.id, + capability_set_id: token_capability_set.id, + cap_version: token_capability_set.cap_version, + }) + .map_err(AppError::from)?; + + let tenant_name: String = tenant_dsl::tenants + .find(token.tenant_id) + .select(tenant_dsl::name) + .first(&mut conn) + .map_err(AppError::from)?; + + ok_json(LoginResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: self.state.config.jwt_expiry_minutes * 60, + tenant: TenantSnippet { + id: token.tenant_id, + name: tenant_name, + }, + }) + } + + pub fn signup_start( + &self, + payload: SignupStartRequest, + ) -> AppResult> { + let username = normalize_username(&payload.username)?; + + let mut conn = self.state.db_unscoped()?; + let exists: bool = dsl::users + .filter(dsl::username.eq(&username)) + .first::(&mut conn) + .optional()? + .is_some(); + if exists { + return Err(AppError::conflict("username already exists")); + } + + let user_id = Uuid::new_v4(); + let challenge = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))? + .start_signup_registration(&mut conn, user_id, username.as_str())?; + + let signup_token = self + .state + .jwt + .generate_signup_token(user_id, challenge.challenge_id, username.clone()) + .map_err(AppError::from)?; + + ok_json(SignupStartResponse { + signup_token, + challenge, + }) + } + + pub fn signup_finish(&self, payload: SignupFinishRequest) -> AppResult { + let claims = self + .state + .jwt + .verify_signup_token(&payload.signup_token) + .map_err(|_| AppError::unauthorized())?; + + let mut conn = self.state.db_unscoped()?; + + let exists: bool = dsl::users + .filter(dsl::username.eq(&claims.username)) + .first::(&mut conn) + .optional()? + .is_some(); + if exists { + return Err(AppError::conflict("username already exists")); + } + + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let prepared_passkey = service.consume_signup_challenge( + &mut conn, + claims.challenge_id, + &payload.credential, + )?; + + let state_clone = self.state.clone(); + let response = conn.transaction::(|conn| { + insert_user(conn, claims.sub, &claims.username)?; + + let tenant = state_clone.tenants.create_tenant_with_conn( + conn, + &claims.username, + None, + None, + TenantStatus::Creating, + &[claims.sub], + Some(claims.sub), + )?; + + let passkey_insert = + prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone()); + + diesel::insert_into(passkey_dsl::user_passkeys) + .values(&passkey_insert) + .execute(conn) + .map_err(AppError::from)?; + + let user: User = dsl::users.find(claims.sub).first(conn)?; + self.issue_session(conn, &user, tenant.id) + })?; + + Ok(response) + } + + pub fn refresh(&self, refresh_value: &str) -> AppResult { + let hashed = hash_session_token(refresh_value); + let mut conn = self.state.db_unscoped()?; + let now = Utc::now(); + let now_naive = now.naive_utc(); + + apply_user_session_hash(&mut conn, &hashed)?; + let token = match session_dsl::user_sessions + .filter(session_dsl::token_hash.eq(&hashed)) + .filter(session_dsl::revoked_at.is_null()) + .filter(session_dsl::expires_at.gt(now_naive)) + .first::(&mut conn) + { + Ok(token) => token, + Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()), + Err(err) => return Err(AppError::from(err)), + }; + + clear_user_session_hash(&mut conn)?; + apply_tenant_guc(&mut conn, token.tenant_id)?; + clear_user_guc(&mut conn)?; + + diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id))) + .set(( + session_dsl::revoked_at.eq(now_naive), + session_dsl::updated_at.eq(now_naive), + )) + .execute(&mut conn)?; + + let user: User = dsl::users + .find(token.user_id) + .first(&mut conn) + .map_err(AppError::from)?; + + self.issue_session(&mut conn, &user, token.tenant_id) + } + + pub fn select_tenant(&self, token: &str, tenant_id: Uuid) -> AppResult { + let user_id = match self.state.jwt.verify_tenant_selector_token(token) { + Ok(claims) => claims.sub, + Err(_) => self + .state + .jwt + .verify_token(token) + .map(|claims| claims.sub) + .map_err(|_| AppError::unauthorized())?, + }; + + let mut conn = self.state.db_unscoped()?; + apply_user_guc(&mut conn, user_id)?; + + let membership_exists = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .select(memberships_dsl::tenant_id) + .first::(&mut conn) + .optional()?; + + clear_user_guc(&mut conn)?; + + if membership_exists.is_none() { + return Err(AppError::unauthorized()); + } + + let user: User = dsl::users + .find(user_id) + .first(&mut conn) + .map_err(AppError::from)?; + + self.issue_session(&mut conn, &user, tenant_id) + } + + pub fn logout( + &self, + conn: &mut PgPooledConnection, + user: &AuthenticatedUser, + refresh_cookie: Option<&str>, + ) -> AppResult<(HeaderMap, StatusCode)> { + let now = Utc::now().naive_utc(); + + let revoked = if let Some(value) = refresh_cookie { + let hashed = hash_session_token(value); + diesel::update( + session_dsl::user_sessions + .filter(session_dsl::token_hash.eq(hashed)) + .filter(session_dsl::user_id.eq(user.user_id)) + .filter(session_dsl::revoked_at.is_null()), + ) + .set(( + session_dsl::revoked_at.eq(now), + session_dsl::updated_at.eq(now), + )) + .execute(conn)? + } else { + 0 + }; + + if revoked == 0 { + diesel::update( + session_dsl::user_sessions + .filter(session_dsl::user_id.eq(user.user_id)) + .filter(session_dsl::tenant_id.eq(user.tenant_id)) + .filter(session_dsl::revoked_at.is_null()), + ) + .set(( + session_dsl::revoked_at.eq(now), + session_dsl::updated_at.eq(now), + )) + .execute(conn)?; + } + + let mut headers = HeaderMap::new(); + headers.insert(SET_COOKIE, build_clear_session_cookie(self.state)); + Ok((headers, StatusCode::NO_CONTENT)) + } + + pub fn list_tenants(&self, user_id: Uuid) -> AppResult> { + let mut conn = self.state.db_unscoped()?; + apply_user_guc(&mut conn, user_id)?; + + let tenant_ids: Vec = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .select(memberships_dsl::tenant_id) + .load(&mut conn)?; + + clear_user_guc(&mut conn)?; + + let mut tenants = Vec::with_capacity(tenant_ids.len()); + for tenant_id in tenant_ids { + let (name, status): (String, TenantStatus) = tenant_dsl::tenants + .find(tenant_id) + .select((tenant_dsl::name, tenant_dsl::status)) + .first(&mut conn) + .map_err(AppError::from)?; + if status != TenantStatus::Active { + continue; + } + tenants.push(TenantSnippet { + id: tenant_id, + name, + }); + } + + ok_json(TenantListResponse { tenants }) + } + + pub fn get_tenant( + &self, + user_id: Uuid, + tenant_id: Uuid, + ) -> AppResult> { + let mut conn = self.state.db_unscoped()?; + apply_user_guc(&mut conn, user_id)?; + + let is_member: bool = diesel::select(diesel::dsl::exists( + memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)), + )) + .get_result(&mut conn)?; + + clear_user_guc(&mut conn)?; + + if !is_member { + return Err(AppError::not_found()); + } + + let (name, status): (String, TenantStatus) = tenant_dsl::tenants + .find(tenant_id) + .select((tenant_dsl::name, tenant_dsl::status)) + .first(&mut conn) + .map_err(AppError::from)?; + + if status != TenantStatus::Active { + return Err(AppError::not_found()); + } + + ok_json(TenantSnippet { + id: tenant_id, + name, + }) + } + + pub fn passkey_register_start( + &self, + user: AuthenticatedUser, + ) -> AppResult> { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.state.db_unscoped()?; + let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; + let challenge = service.start_registration(&mut conn, ¤t_user)?; + ok_json(challenge) + } + + pub fn passkey_register_finish( + &self, + user: AuthenticatedUser, + payload: PasskeyRegistrationFinishPayload, + ) -> AppResult> { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.state.db_unscoped()?; + let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; + + let PasskeyRegistrationFinishPayload { + challenge_id, + credential, + nickname, + } = payload; + + let passkey = service.finish_registration( + &mut conn, + ¤t_user, + challenge_id, + credential, + nickname, + )?; + + ok_json(PasskeySummary::from(passkey)) + } + + pub fn passkey_login_start( + &self, + username: &str, + ) -> AppResult> { + let username = normalize_username(username)?; + + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.state.db_unscoped()?; + let user: User = dsl::users + .filter(dsl::username.eq(&username)) + .first(&mut conn)?; + + let challenge = service.start_authentication(&mut conn, &user)?; + ok_json(challenge) + } + + pub fn passkey_login_finish(&self, payload: PasskeyLoginFinishPayload) -> AppResult { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.state.db_unscoped()?; + let (user, _passkey, auth_result) = + service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?; + + if !auth_result.user_verified() { + return Err(AppError::unauthorized()); + } + + self.complete_login(&mut conn, &user, None) + } + + fn complete_login( + &self, + conn: &mut PgConnection, + user: &User, + preferred_tenant_id: Option, + ) -> AppResult { + apply_user_guc(conn, user.id)?; + let tenant_ids: Vec = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .select(memberships_dsl::tenant_id) + .load(conn)?; + clear_user_guc(conn)?; + + if tenant_ids.is_empty() { + return Err(AppError::unauthorized()); + } + + let mut active_tenants = Vec::new(); + for tenant_id in tenant_ids { + let (name, status): (String, TenantStatus) = tenant_dsl::tenants + .find(tenant_id) + .select((tenant_dsl::name, tenant_dsl::status)) + .first(conn) + .map_err(AppError::from)?; + if status == TenantStatus::Active { + active_tenants.push((tenant_id, name)); + } + } + + if active_tenants.is_empty() { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "no active tenants available", + )); + } + + if let Some(preferred_id) = preferred_tenant_id { + if active_tenants.iter().any(|(id, _)| *id == preferred_id) { + return self.issue_session(conn, user, preferred_id); + } + } + + if active_tenants.len() == 1 { + return self.issue_session(conn, user, active_tenants[0].0); + } + + let selection_token = self + .state + .jwt + .generate_tenant_selector_token(user.id) + .map_err(AppError::from)?; + + let tenants = active_tenants + .into_iter() + .map(|(id, name)| TenantSnippet { id, name }) + .collect(); + + let response = ok_json(LoginResponseVariants::Selection(TenantSelectionResponse { + access_token: selection_token, + tenants, + }))?; + + Ok(response.into_response()) + } + + fn magic_token_login( + &self, + conn: &mut PgConnection, + token_value: &str, + username_hint: Option<&str>, + preferred_tenant_id: Option, + ) -> AppResult { + if token_value.is_empty() { + return Err(AppError::bad_request("magic_token must not be empty")); + } + + let token_hash = hash_magic_token(token_value); + let now = Utc::now(); + let now_naive = now.naive_utc(); + + conn.transaction::(|conn| { + let magic = magic_dsl::magic_tokens + .filter(magic_dsl::token_hash.eq(&token_hash)) + .filter(magic_dsl::expires_at.gt(now_naive)) + .first::(conn) + .map_err(|err| match err { + diesel::result::Error::NotFound => AppError::unauthorized(), + _ => AppError::from(err), + })?; + + if let Some(limit) = magic.max_uses { + if magic.used_count >= limit { + return Err(AppError::unauthorized()); + } + } + + match magic.kind { + MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {} + } + + let user: User = dsl::users + .find(magic.user_id) + .first(conn) + .map_err(AppError::from)?; + + if let Some(expected) = username_hint { + if expected != user.username { + return Err(AppError::unauthorized()); + } + } + + diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id))) + .set(( + magic_dsl::used_count.eq(magic.used_count + 1), + magic_dsl::last_used_at.eq(Some(now_naive)), + )) + .execute(conn)?; + + self.complete_login(conn, &user, preferred_tenant_id) + }) + } + + fn issue_session( + &self, + conn: &mut PgConnection, + user: &User, + tenant_id: Uuid, + ) -> AppResult { + crate::auth::ensure_active_tenant_with_conn(conn, tenant_id)?; + apply_tenant_guc(conn, tenant_id)?; + clear_user_guc(conn)?; + clear_user_session_hash(conn)?; + + let membership: UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .first(conn)?; + + let capability_set_id = membership.capability_set_id.ok_or_else(|| { + AppError::new( + StatusCode::FORBIDDEN, + "membership has no capability set assigned", + ) + })?; + + let capability_set = load_capability_set(conn, capability_set_id)?; + + let now = Utc::now(); + let session_id = Uuid::new_v4(); + let access_token = self + .state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version: capability_set.cap_version, + }) + .map_err(AppError::from)?; + + let tenant_name: String = tenant_dsl::tenants + .find(tenant_id) + .select(tenant_dsl::name) + .first(conn) + .map_err(AppError::from)?; + + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); + let refresh_expires_at = + now + ChronoDuration::days(self.state.config.refresh_token_expiry_days); + + let new_session = NewUserSession { + id: session_id, + user_id: user.id, + token_hash: session_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + tenant_id, + }; + + diesel::insert_into(user_sessions::table) + .values(&new_session) + .execute(conn)?; + + let json = ok_json(LoginResponseVariants::Token(LoginResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: self.state.config.jwt_expiry_minutes * 60, + tenant: TenantSnippet { + id: tenant_id, + name: tenant_name, + }, + }))?; + + let mut response = json.into_response(); + + response.headers_mut().insert( + SET_COOKIE, + build_session_cookie(self.state, &session_value, refresh_expires_at), + ); + + Ok(response) + } +} + +fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> { + let new_user = NewUser { + id, + username: username.to_string(), + }; + + diesel::insert_into(dsl::users) + .values(&new_user) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +fn hash_session_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn hash_magic_token(token: &str) -> String { + hash_session_token(token) +} + +fn generate_session_token() -> String { + let mut bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut bytes) + .expect("failed to read random bytes"); + hex::encode(bytes) +} + +fn build_cookie( + state: &AppState, + token: Option<&str>, + expires_at: Option>, + max_age: i64, +) -> HeaderValue { + let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token.unwrap_or(""))]; + parts.push("Path=/".into()); + parts.push("HttpOnly".into()); + parts.push("SameSite=Strict".into()); + parts.push(format!("Max-Age={}", max_age)); + if let Some(expires) = expires_at { + parts.push(format!("Expires={}", expires.to_rfc2822())); + } + if state.config.refresh_cookie_secure { + parts.push("Secure".into()); + } + if let Some(domain) = &state.config.refresh_cookie_domain { + parts.push(format!("Domain={}", domain)); + } + + HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie") +} + +fn build_session_cookie( + state: &AppState, + token: &str, + expires_at: chrono::DateTime, +) -> HeaderValue { + let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds(); + build_cookie(state, Some(token), Some(expires_at), max_age) +} + +fn build_clear_session_cookie(state: &AppState) -> HeaderValue { + let epoch = Utc.timestamp_opt(0, 0).single().unwrap(); + build_cookie(state, None, Some(epoch), 0) +} + +fn normalize_username(value: &str) -> AppResult { + normalize_identifier( + value, + 100, + "username must not be empty", + "username must not exceed 100 characters", + Some("username may only contain printable characters"), + |ch| !ch.is_control(), + ) +} diff --git a/backend/src/services/capability_sets.rs b/backend/src/services/capability_sets.rs new file mode 100644 index 0000000..6c1a105 --- /dev/null +++ b/backend/src/services/capability_sets.rs @@ -0,0 +1,293 @@ +use axum::http::StatusCode; +use chrono::Utc; +use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + auth::capability_sets::{ + compute_slug, create_capability_set as create_capability_set_record, is_system_slug, + load_capabilities_for_set, normalize_capabilities, refresh_capability_set, + }, + error::{AppError, AppResult}, + http::responders::{ + created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt, + }, + models::{ApiCapability, CapabilitySet}, + schema::{ + api_tokens, + capability_sets::{self, dsl as cs_dsl}, + user_memberships, + }, + utils::text::normalize_identifier, +}; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct CapabilitySetResponse { + pub id: Uuid, + pub slug: String, + pub is_system: bool, + pub cap_version: i32, + pub capabilities: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateCapabilitySetRequest { + #[serde(default)] + #[serde(rename = "slug")] + pub slug: Option, + pub capabilities: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateCapabilitySetRequest { + #[serde(default)] + #[serde(rename = "slug")] + pub slug: Option, + #[serde(default)] + pub capabilities: Option>, +} + +pub struct CapabilitySetService; + +impl CapabilitySetService { + pub fn new() -> Self { + Self + } + + pub fn list( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + ) -> AppResult>> { + let sets = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .order(cs_dsl::slug.asc()) + .load::(conn)?; + + let mut responses = Vec::with_capacity(sets.len()); + for set in sets { + let capabilities = load_capabilities_for_set(conn, set.id)?; + responses.push(to_response(set, capabilities)); + } + + ok_json(responses) + } + + pub fn list_capabilities(&self) -> AppResult>> { + let capabilities = ApiCapability::variants() + .iter() + .map(|value| value.parse::().expect("valid capability")) + .collect(); + ok_json(capabilities) + } + + pub fn get( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + id: Uuid, + ) -> AppResult> { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .into_app_result()?; + + let capabilities = load_capabilities_for_set(conn, set.id)?; + ok_json(to_response(set, capabilities)) + } + + pub fn create( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + payload: CreateCapabilitySetRequest, + ) -> AppResult> { + let original_caps = payload.capabilities; + let normalized_caps = normalize_capabilities(original_caps.clone())?; + if normalized_caps.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let slug = if let Some(raw) = payload.slug { + let normalized = normalize_slug(&raw)?; + if is_system_slug(&normalized) { + return Err(AppError::conflict("slug is reserved")); + } + normalized + } else { + let generated = compute_slug(&normalized_caps); + if is_system_slug(&generated) { + return Err(AppError::conflict( + "capabilities match a reserved system capability set", + )); + } + generated + }; + + let set = create_capability_set_record(conn, tenant_id, &slug, original_caps)?; + let response = to_response(set, normalized_caps); + + created_json(response) + } + + pub fn update( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + id: Uuid, + payload: UpdateCapabilitySetRequest, + ) -> AppResult> { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .into_app_result()?; + + if set.is_system { + if payload.slug.is_some() || payload.capabilities.is_some() { + return Err(AppError::conflict( + "system capability sets cannot be modified", + )); + } + let capabilities = load_capabilities_for_set(conn, set.id)?; + return ok_json(to_response(set, capabilities)); + } + + let set = conn.transaction::(|conn| { + let mut working = set.clone(); + + if let Some(slug) = &payload.slug { + let normalized = normalize_slug(slug)?; + if is_system_slug(&normalized) { + return Err(AppError::conflict("slug is reserved")); + } + + if cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(&normalized)) + .filter(cs_dsl::id.ne(working.id)) + .first::(conn) + .optional() + .into_app_result()? + .is_some() + { + return Err(AppError::conflict("slug already exists")); + } + + diesel::update(cs_dsl::capability_sets.find(working.id)) + .set(( + cs_dsl::slug.eq(&normalized), + cs_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .into_app_result()?; + + working.slug = normalized; + } + + if let Some(capabilities) = &payload.capabilities { + let normalized = normalize_capabilities(capabilities.clone())?; + if normalized.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let updated = refresh_capability_set(conn, &working, &normalized)?; + working = updated; + } + + capability_sets::table + .find(working.id) + .first::(conn) + .into_app_result() + })?; + + let capabilities = load_capabilities_for_set(conn, set.id)?; + ok_json(to_response(set, capabilities)) + } + + pub fn delete( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + id: Uuid, + ) -> AppResult { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .into_app_result()?; + + if set.is_system { + return Err(AppError::conflict( + "system capability sets cannot be deleted", + )); + } + + let in_use_memberships: i64 = user_memberships::table + .filter(user_memberships::capability_set_id.eq(Some(set.id))) + .count() + .get_result(conn)?; + + if in_use_memberships > 0 { + return Err(AppError::conflict( + "capability set is assigned to user memberships", + )); + } + + let in_use_tokens: i64 = api_tokens::table + .filter(api_tokens::capability_set_id.eq(set.id)) + .count() + .get_result(conn)?; + + if in_use_tokens > 0 { + return Err(AppError::conflict( + "capability set is assigned to API tokens", + )); + } + + diesel::delete(cs_dsl::capability_sets.find(set.id)) + .execute(conn) + .into_app_result()? + .or_not_found()?; + + no_content() + } +} + +fn normalize_slug(value: &str) -> AppResult { + let base = normalize_identifier( + value, + 64, + "slug must not be empty", + "slug must not exceed 64 characters", + Some("slug may only contain alphanumeric characters, hyphen, underscore, or whitespace"), + |ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch.is_whitespace(), + )?; + + let mut normalized = String::with_capacity(base.len()); + for ch in base.chars() { + if ch.is_whitespace() { + normalized.push('-'); + } else { + normalized.push(ch.to_ascii_lowercase()); + } + } + + if normalized.is_empty() { + return Err(AppError::bad_request("slug must not be empty")); + } + + Ok(normalized) +} + +fn to_response(set: CapabilitySet, capabilities: Vec) -> CapabilitySetResponse { + CapabilitySetResponse { + id: set.id, + slug: set.slug, + is_system: set.is_system, + cap_version: set.cap_version, + capabilities, + } +} diff --git a/backend/src/services/correspondents.rs b/backend/src/services/correspondents.rs new file mode 100644 index 0000000..5d84d52 --- /dev/null +++ b/backend/src/services/correspondents.rs @@ -0,0 +1,261 @@ +use chrono::Utc; +use diesel::{dsl::not, prelude::*, Connection}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::documents::correspondents::{ + insert_document_correspondents, normalize_correspondent_ids, +}; +use crate::error::{AppError, AppResult}; +use crate::schema::{document_correspondents, documents}; +use crate::services::helpers::load_active_document; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::db::validate_bulk_ids; + +#[derive(Serialize, ToSchema)] +pub struct BulkCorrespondentResponse { + pub assigned: usize, + pub removed: usize, +} + +#[derive(Deserialize, ToSchema)] +pub struct CorrespondentAssignmentInput { + pub correspondent_id: Uuid, +} + +#[derive(Deserialize, ToSchema)] +pub struct AssignCorrespondentsRequest { + pub assignments: Vec, + #[serde(default)] + #[schema(default = false)] + pub replace: bool, +} + +#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum BulkCorrespondentAction { + Add, + Remove, +} + +fn default_bulk_correspondent_action() -> BulkCorrespondentAction { + BulkCorrespondentAction::Add +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkCorrespondentsRequest { + pub document_ids: Vec, + pub assignments: Vec, + #[serde(default = "default_bulk_correspondent_action")] + pub action: BulkCorrespondentAction, +} + +pub struct CorrespondentsService<'a> { + _state: &'a AppState, +} + +impl<'a> CorrespondentsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { _state: state } + } + + pub fn assign_to_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + request: &AssignCorrespondentsRequest, + ) -> AppResult<()> { + if request.assignments.is_empty() { + return Err(AppError::bad_request("assignments must not be empty")); + } + + let raw_ids: Vec = request + .assignments + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + + conn.transaction::<_, AppError, _>(|conn| { + let document = load_active_document(conn, tenant_id, document_id)?; + + let mut updated = false; + if request.replace { + let base = document_correspondents::table + .filter(document_correspondents::document_id.eq(document_id)) + .filter(document_correspondents::tenant_id.eq(tenant_id)); + + let removed = if correspondent_ids.is_empty() { + diesel::delete(base).execute(conn)? + } else { + diesel::delete(base.filter(not( + document_correspondents::correspondent_id.eq_any(&correspondent_ids), + ))) + .execute(conn)? + }; + + if removed > 0 { + updated = true; + } + } + + let inserted = insert_document_correspondents( + conn, + tenant_id, + document.id, + user_id, + &correspondent_ids, + )?; + + if inserted > 0 { + updated = true; + } + + if updated && inserted == 0 { + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(()) + }) + } + + pub fn bulk_update( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + mut payload: BulkCorrespondentsRequest, + ) -> AppResult { + if payload.assignments.is_empty() { + return Err(AppError::bad_request("assignments must not be empty")); + } + + validate_bulk_ids(&mut payload.document_ids, "document_ids")?; + + let raw_ids: Vec = payload + .assignments + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + + let action = payload.action; + + conn.transaction::<_, AppError, _>(|conn| { + let docs: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&payload.document_ids)) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if docs.len() != payload.document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if docs.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request( + "cannot assign correspondents to deleted documents", + )); + } + + match action { + BulkCorrespondentAction::Add => { + let mut assigned_total = 0; + for (doc_id, _) in &docs { + assigned_total += insert_document_correspondents( + conn, + tenant_id, + *doc_id, + user_id, + &correspondent_ids, + )?; + } + + Ok(BulkCorrespondentResponse { + assigned: assigned_total, + removed: 0, + }) + } + BulkCorrespondentAction::Remove => { + if correspondent_ids.is_empty() { + return Ok(BulkCorrespondentResponse { + assigned: 0, + removed: 0, + }); + } + + let removed = diesel::delete( + document_correspondents::table + .filter( + document_correspondents::document_id.eq_any(&payload.document_ids), + ) + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .filter( + document_correspondents::correspondent_id + .eq_any(&correspondent_ids), + ), + ) + .execute(conn)?; + + if removed > 0 { + diesel::update( + documents::table + .filter(documents::id.eq_any(&payload.document_ids)) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(BulkCorrespondentResponse { + assigned: 0, + removed, + }) + } + } + }) + } + + pub fn remove_from_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + correspondent_id: Uuid, + ) -> AppResult<()> { + load_active_document(conn, tenant_id, document_id)?; + + let deleted = diesel::delete( + document_correspondents::table + .filter(document_correspondents::document_id.eq(document_id)) + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .filter(document_correspondents::correspondent_id.eq(correspondent_id)), + ) + .execute(conn)?; + + if deleted == 0 { + return Err(AppError::not_found()); + } + + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + + Ok(()) + } +} diff --git a/backend/src/services/documents.rs b/backend/src/services/documents.rs new file mode 100644 index 0000000..d4fdaf2 --- /dev/null +++ b/backend/src/services/documents.rs @@ -0,0 +1,1618 @@ +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, Duration as ChronoDuration, NaiveDateTime, Utc}; +use diesel::{ + dsl::{exists, not, sql}, + prelude::*, + result::DatabaseErrorKind, + sql_types::Text, + Connection, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use tracing::{debug, error, info, warn}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::documents::{ + asset::{ + build_download_link, derive_document_title, filename_with_retained_extension, + to_asset_detail_response, to_version_response, DocumentAssetDetailResponse, + DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse, + DownloadLink, + }, + correspondents::{ + insert_document_correspondents, normalize_correspondent_ids, DocumentCorrespondentResponse, + }, + folders::ensure_folder_exists_on_conn, + metadata::merge_document_metadata, + ordering::{ordering_clauses, DocumentSortField, SortDirection}, + relations::load_tags_and_correspondents, + search::quickwit_search, + tags::assign_tags as assign_tags_to_document, +}; +use crate::error::{AppError, AppResult}; +use crate::jobs::{enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_PURGE_DOCUMENT}; +use crate::models::{ + Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentVersion, Tag, +}; +use crate::schema::{ + document_assets, document_correspondents, document_tags, document_versions, documents, folders, +}; +use crate::services::{ + correspondents::CorrespondentAssignmentInput, folders::gather_descendant_folder_ids, + helpers::load_active_document, +}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::{ + db::validate_bulk_ids, error::StorageResultExt, http::inline_content_disposition, + json::classify_nullable, json::NullableValue, setops::intersect_option_sets, + setops::load_linked_doc_ids, storage_paths::document_version_object_key, time::to_iso, +}; + +#[derive(Deserialize, IntoParams, ToSchema, Clone)] +#[into_params(parameter_in = Query)] +pub struct DocumentListQuery { + #[schema(nullable)] + pub folder_id: Option, + #[serde(default)] + #[schema(nullable)] + pub include_descendants: Option, + pub query: Option, + pub tags: Option, + pub correspondents: Option, + #[serde(default = "default_document_status_filter")] + #[schema(default = "active")] + pub status: DocumentStatusFilter, + #[serde(default)] + #[schema(default = "title")] + pub sort: DocumentSortField, + #[serde(default)] + #[schema(default = "asc")] + pub dir: SortDirection, +} + +fn default_document_status_filter() -> DocumentStatusFilter { + DocumentStatusFilter::Active +} + +#[derive(Clone, Copy, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum DocumentStatusFilter { + Active, + Deleted, + All, +} + +#[derive(Serialize, ToSchema)] +pub struct TagResponse { + pub id: Uuid, + pub label: String, + #[schema(nullable)] + pub color: Option, +} + +impl From for TagResponse { + fn from(tag: Tag) -> Self { + Self { + id: tag.id, + label: tag.label, + color: tag.color, + } + } +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentCheckResponse { + pub exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub document_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub version_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub version_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub created_at: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentResponse { + pub id: Uuid, + pub filename: String, + pub title: String, + pub original_name: String, + #[schema(nullable)] + pub mime_type: Option, + #[schema(nullable)] + pub folder_id: Option, + pub created_at: String, + pub updated_at: String, + #[schema(nullable)] + pub deleted_at: Option, + #[schema(nullable)] + pub issued_at: Option, + #[schema(value_type = Object)] + pub metadata: Value, + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub correspondents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub current_version: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentDetailResponse { + pub document: DocumentResponse, +} + +#[derive(Deserialize, ToSchema)] +pub struct DocumentMetadataUpdate { + #[schema(value_type = Object)] + pub value: Value, + #[serde(default)] + #[schema(default = false)] + pub replace: bool, +} + +#[derive(Default, AsChangeset)] +#[diesel(table_name = documents)] +struct DocumentUpdateChangeset { + title: Option, + filename: Option, + issued_at: Option>, + metadata: Option, + updated_at: Option, +} + +struct DocumentUpdatePlan { + changeset: DocumentUpdateChangeset, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateDocumentRequest { + #[serde(default)] + pub title: Option, + #[serde(default)] + #[schema(nullable, value_type = Option)] + pub issued_at: Option, + #[serde(default)] + #[schema(nullable, value_type = Object)] + pub metadata: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkMoveRequest { + pub document_ids: Vec, + #[schema(nullable)] + pub folder_id: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkMoveResponse { + pub updated: usize, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkReanalyzeSelectionRequest { + pub document_ids: Vec, + #[serde(default = "default_true")] + #[schema(default = true)] + pub force: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkReanalyzeResponse { + pub queued: usize, +} + +fn default_true() -> bool { + true +} + +pub struct DocumentUploadRequest { + pub bytes: Vec, + pub original_name: String, + pub mime_type: Option, + pub folder_id: Option, + pub metadata: Value, + pub title_override: Option, + pub tag_ids: Vec, + pub correspondents: Vec, + pub issued_at_override: Option, + pub skip_if_existing: bool, +} + +pub enum DocumentUploadOutcome { + Created(DocumentDetailResponse), + Reused(DocumentDetailResponse), +} + +pub struct DocumentsService<'a> { + state: &'a AppState, +} + +impl<'a> DocumentsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + fn build_document_update_plan( + document: &Document, + payload: &Map, + ) -> AppResult { + let title = match payload.get("title") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => return Err(AppError::bad_request("title must be a string")), + }; + + let issued_at_class = + classify_nullable(payload.get("issued_at")).map_err(AppError::bad_request)?; + + let metadata = match payload.get("metadata") { + None | Some(Value::Null) => None, + Some(value) => Some( + serde_json::from_value::(value.clone()).map_err(|err| { + AppError::bad_request(format!("invalid metadata payload: {err}")) + })?, + ), + }; + + let mut changes = DocumentUpdateChangeset::default(); + let mut has_changes = false; + + if let Some(ref candidate) = title { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("title must not be empty")); + } + if trimmed != document.title { + let new_title = trimmed.to_string(); + let new_filename = filename_with_retained_extension(&new_title, &document.filename); + changes.title = Some(new_title); + if new_filename != document.filename { + changes.filename = Some(new_filename); + } + has_changes = true; + } + } + + match issued_at_class { + NullableValue::Omitted => {} + NullableValue::Null => { + if document.issued_at.is_some() { + changes.issued_at = Some(None); + has_changes = true; + } + } + NullableValue::String(raw) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("issued_at must not be empty")); + } + let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| { + let msg = format!("issued_at must be an RFC3339 timestamp: {err}"); + AppError::bad_request(msg) + })?; + let normalized = Some(parsed.naive_utc()); + if document.issued_at != normalized { + changes.issued_at = Some(normalized); + has_changes = true; + } + } + } + + if let Some(metadata_update) = metadata { + let next_metadata = if metadata_update.replace { + metadata_update.value + } else { + merge_document_metadata(document.metadata.clone(), metadata_update.value)? + }; + + if document.metadata != next_metadata { + changes.metadata = Some(next_metadata); + has_changes = true; + } + } + + if !has_changes { + return Err(AppError::bad_request("no changes provided")); + } + + Ok(DocumentUpdatePlan { + changeset: changes, + }) + } + + pub async fn get_document_detail( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + ) -> AppResult { + let doc = load_active_document(conn, tenant_id, document_id)?; + + let current_version: DocumentVersion = document_versions::table + .find(doc.current_version_id) + .first(conn)?; + + let tags_and_correspondents = load_tags_and_correspondents(conn, &[document_id])?; + let (tags, correspondents) = tags_and_correspondents + .get(&document_id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let assets = self.load_asset_responses(conn, tenant_id, current_version.id, user_id)?; + let download = build_download_link(self.state, &doc, current_version.id, user_id)?; + let current_version_data = Some((to_version_response(current_version), assets, download)); + + let response = + self.to_document_response(user_id, doc, tags, correspondents, current_version_data)?; + + Ok(DocumentDetailResponse { document: response }) + } + + pub fn check_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + checksum_hex: &str, + ) -> AppResult { + let record: Option<(Document, DocumentVersion)> = documents::table + .inner_join( + document_versions::table + .on(document_versions::id.eq(documents::current_version_id)), + ) + .filter(documents::tenant_id.eq(tenant_id)) + .filter(document_versions::checksum.eq(checksum_hex)) + .select((documents::all_columns, document_versions::all_columns)) + .first::<(Document, DocumentVersion)>(conn) + .optional()?; + + if let Some((document, version)) = record { + Ok(DocumentCheckResponse { + exists: true, + document_id: Some(document.id), + title: Some(document.title), + filename: Some(document.filename), + version_id: Some(version.id), + version_number: Some(version.version_number), + created_at: Some(to_iso(document.created_at)), + }) + } else { + Ok(DocumentCheckResponse { + exists: false, + document_id: None, + title: None, + filename: None, + version_id: None, + version_number: None, + created_at: None, + }) + } + } + + pub async fn list_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + params: DocumentListQuery, + ) -> AppResult> { + let DocumentListQuery { + folder_id, + include_descendants, + query, + tags, + correspondents, + status, + sort, + dir, + } = params; + + let mut docs_query = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .into_boxed(); + + match status { + DocumentStatusFilter::Active => { + docs_query = docs_query.filter(documents::deleted_at.is_null()); + } + DocumentStatusFilter::Deleted => { + docs_query = docs_query.filter(documents::deleted_at.is_not_null()); + } + DocumentStatusFilter::All => {} + } + + let search_text = query + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let tags_param = tags + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let correspondents_param = correspondents + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let include_descendants = include_descendants.unwrap_or(true); + + match (folder_id, include_descendants) { + (Some(folder_id), true) => { + let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?; + docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids)); + } + (Some(folder_id), false) => { + docs_query = docs_query.filter(documents::folder_id.eq(Some(folder_id))); + } + (None, false) => { + docs_query = docs_query.filter(documents::folder_id.is_null()); + } + (None, true) => {} + } + + let mut filter_ids: Option> = None; + let mut quickwit_order: Option> = None; + + if let Some(query_str) = search_text.as_ref() { + debug!(query = %query_str, "performing hybrid document search"); + + // 1. Quickwit Search + let endpoint = self + .state + .config + .quickwit_endpoint + .as_ref() + .ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?; + let tenant = self.state.tenants.get_by_id(tenant_id)?; + let index = tenant + .quickwit_index + .as_ref() + .ok_or_else(|| AppError::internal("quickwit index not configured for tenant"))?; + + let quickwit_ids = quickwit_search(endpoint, index, tenant_id, query_str) + .await + .map_err(|err| { + error!(error = ?err, "quickwit search failed"); + AppError::internal("quickwit search failed") + })?; + + // 2. Postgres Title Search + let postgres_ids: Vec = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .filter(documents::deleted_at.is_null()) + .filter(documents::title.ilike(format!("%{}%", query_str))) + .select(documents::id) + .load(conn)?; + + // 3. Combine Results + let mut combined_ids = quickwit_ids.clone(); + let quickwit_set: HashSet = quickwit_ids.iter().cloned().collect(); + + for id in postgres_ids { + if !quickwit_set.contains(&id) { + combined_ids.push(id); + } + } + + if combined_ids.is_empty() { + return Ok(Vec::new()); + } + + quickwit_order = Some(combined_ids.clone()); + let set: HashSet = combined_ids.into_iter().collect(); + filter_ids = intersect_option_sets(filter_ids, set); + } + + if let Some(tags_param) = tags_param.as_ref() { + if tags_param.trim().eq_ignore_ascii_case("none") { + let docs_without_tags: Vec = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .filter(documents::deleted_at.is_null()) + .filter(not(exists( + document_tags::table.filter(document_tags::document_id.eq(documents::id)), + ))) + .select(documents::id) + .load(conn)?; + + let docs_set: HashSet = docs_without_tags.into_iter().collect(); + + if docs_set.is_empty() { + return Ok(Vec::new()); + } + + filter_ids = intersect_option_sets(filter_ids, docs_set); + } else { + let tag_ids: Result, _> = tags_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = tag_ids { + if !ids.is_empty() { + let matching_doc_ids = load_linked_doc_ids(conn, &ids, |conn, tag_id| { + let docs_for_tag: Vec = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .select(document_tags::document_id) + .load(conn) + .map_err(AppError::from)?; + Ok(docs_for_tag.into_iter().collect()) + })?; + + if matching_doc_ids.is_empty() { + return Ok(Vec::new()); + } + + filter_ids = intersect_option_sets(filter_ids, matching_doc_ids); + } + } + } + } + + if let Some(correspondents_param) = correspondents_param.as_ref() { + let correspondent_ids: Result, _> = correspondents_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = correspondent_ids { + if !ids.is_empty() { + let matching_doc_ids = + load_linked_doc_ids(conn, &ids, |conn, correspondent_id| { + let docs_for_correspondent: Vec = document_correspondents::table + .filter( + document_correspondents::correspondent_id.eq(correspondent_id), + ) + .select(document_correspondents::document_id) + .load(conn) + .map_err(AppError::from)?; + Ok(docs_for_correspondent.into_iter().collect()) + })?; + + if matching_doc_ids.is_empty() { + return Ok(Vec::new()); + } + + filter_ids = intersect_option_sets(filter_ids, matching_doc_ids); + } + } + } + + if let Some(filter_ids) = filter_ids { + docs_query = docs_query.filter(documents::id.eq_any(filter_ids)); + } + + let (primary_sql, secondary_sql) = ordering_clauses(sort, dir); + docs_query = docs_query.order(sql::(primary_sql)); + if let Some(second) = secondary_sql { + docs_query = docs_query.then_order_by(sql::(second)); + } + + let docs: Vec = docs_query.load(conn)?; + let mut responses = self.hydrate_documents(conn, tenant_id, user_id, docs)?; + + if let Some(order) = quickwit_order { + let order_map: HashMap = order + .into_iter() + .enumerate() + .map(|(idx, id)| (id, idx)) + .collect(); + responses.sort_by_key(|doc| order_map.get(&doc.id).copied().unwrap_or(usize::MAX)); + } + + Ok(responses) + } + + pub fn hydrate_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + docs: Vec, + ) -> AppResult> { + if docs.is_empty() { + return Ok(Vec::new()); + } + + let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); + let mut relations = load_tags_and_correspondents(conn, &doc_ids)?; + let mut doc_to_version: HashMap = HashMap::with_capacity(doc_ids.len()); + let mut version_ids: Vec = Vec::with_capacity(doc_ids.len()); + for doc in &docs { + doc_to_version.insert(doc.id, doc.current_version_id); + version_ids.push(doc.current_version_id); + } + + version_ids.sort(); + version_ids.dedup(); + + let versions: Vec = document_versions::table + .filter(document_versions::id.eq_any(&version_ids)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .load(conn)?; + + let mut version_map: HashMap = HashMap::new(); + for version in versions { + version_map.insert(version.id, version); + } + + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq_any(&version_ids)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .order(( + document_assets::document_version_id.asc(), + document_assets::created_at.asc(), + )) + .load(conn)?; + + let mut assets_by_version: HashMap> = HashMap::new(); + for asset in assets { + let version_id = asset.document_version_id; + let response = self.asset_response(asset, tenant_id, user_id)?; + assets_by_version + .entry(version_id) + .or_default() + .push(response); + } + + docs.into_iter() + .map(|doc| { + let (tags, correspondents) = relations + .remove(&doc.id) + .unwrap_or_else(|| (Vec::new(), Vec::new())); + let current_version = doc_to_version + .get(&doc.id) + .and_then(|version_id| version_map.remove(version_id)) + .map(|version| -> AppResult<_> { + let assets = assets_by_version.remove(&version.id).unwrap_or_default(); + let download = build_download_link(self.state, &doc, version.id, user_id)?; + Ok((to_version_response(version), assets, download)) + }) + .transpose()?; + self.to_document_response(user_id, doc, tags, correspondents, current_version) + }) + .collect() + } + + pub async fn upload_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + request: DocumentUploadRequest, + ) -> AppResult { + let DocumentUploadRequest { + bytes, + original_name, + mime_type, + folder_id, + metadata, + title_override, + tag_ids, + correspondents, + issued_at_override, + skip_if_existing, + } = request; + + if let Some(folder) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, folder)?; + } + + let doc_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let version_number = 1; + let derived_title = title_override + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .unwrap_or_else(|| derive_document_title(&original_name)); + let stored_filename = filename_with_retained_extension(&derived_title, &original_name); + + let checksum = Sha256::digest(&bytes); + let checksum_hex = hex::encode(checksum); + let size_bytes = bytes.len() as i64; + let s3_key = document_version_object_key(doc_id, version_number, version_id); + + if let Some(reused) = self + .try_reuse_existing_document( + conn, + tenant_id, + user_id, + &checksum_hex, + issued_at_override, + skip_if_existing, + &tag_ids, + &correspondents, + ) + .await? + { + return Ok(DocumentUploadOutcome::Reused(reused)); + } + + let content_disposition = inline_content_disposition(&stored_filename); + + let storage = self.state.storage_for_tenant(tenant_id)?; + + storage + .put_object( + &s3_key, + bytes.clone(), + mime_type.clone(), + content_disposition.clone(), + ) + .await + .storage_context("failed to store document")?; + + let metadata_value = if metadata.is_null() { + Value::Object(Default::default()) + } else { + metadata + }; + + let (document, version) = match conn.transaction(|conn| { + let new_document = NewDocument { + id: doc_id, + filename: stored_filename.clone(), + original_name: original_name.clone(), + mime_type: mime_type.clone(), + folder_id, + current_version_id: version_id, + metadata: metadata_value.clone(), + issued_at: issued_at_override, + title: derived_title.clone(), + tenant_id, + }; + diesel::insert_into(documents::table) + .values(&new_document) + .execute(conn)?; + + let new_version = NewDocumentVersion { + id: version_id, + document_id: doc_id, + version_number, + s3_key: s3_key.clone(), + size_bytes, + checksum: checksum_hex.clone(), + metadata: Value::Object(Default::default()), + tenant_id, + }; + + diesel::insert_into(document_versions::table) + .values(&new_version) + .execute(conn)?; + + let document: Document = documents::table.find(doc_id).first(conn)?; + let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?; + + Ok::<_, diesel::result::Error>((document, version)) + }) { + Ok(result) => result, + Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { + return Err(AppError::conflict( + "another document in this folder already uses that filename", + ) + .with_code("duplicate_filename")) + } + Err(err) => return Err(AppError::from(err)), + }; + + let detail = { + assign_tags_to_document(conn, tenant_id, &document, &tag_ids, Some(user_id))?; + + if !correspondents.is_empty() { + let raw_ids: Vec = correspondents + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + insert_document_correspondents( + conn, + tenant_id, + document.id, + user_id, + &correspondent_ids, + )?; + } + + let tags_and_correspondents = load_tags_and_correspondents(conn, &[doc_id])?; + let (tags, correspondents) = tags_and_correspondents + .get(&doc_id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let download = build_download_link(self.state, &document, version.id, user_id)?; + + DocumentDetailResponse { + document: self.to_document_response( + user_id, + document, + tags, + correspondents, + Some((to_version_response(version.clone()), Vec::new(), download)), + )?, + } + }; + + if let Err(err) = enqueue_job( + conn, + tenant_id, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": doc_id, + "document_version_id": version.id, + "force": false, + }), + None, + ) { + warn!(document_id = %doc_id, error = %err, "failed to enqueue analyze job"); + } + + Ok(DocumentUploadOutcome::Created(detail)) + } + + pub fn request_document_assets( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + force: bool, + ) -> AppResult<()> { + let document = load_active_document(conn, tenant_id, document_id)?; + + enqueue_job( + conn, + tenant_id, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": document_id, + "document_version_id": document.current_version_id, + "force": force, + }), + None, + ) + .map_err(|err| { + error!(error = ?err, "failed to enqueue analyze job"); + AppError::internal("failed to enqueue analyze job") + })?; + + Ok(()) + } + + pub fn reanalyze_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_ids: &mut Vec, + force: bool, + ) -> AppResult { + validate_bulk_ids(document_ids, "document_ids")?; + + let targets: Vec<(Uuid, Uuid)> = documents::table + .filter(documents::id.eq_any(&*document_ids)) + .filter(documents::deleted_at.is_null()) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::current_version_id)) + .load(conn)?; + + if targets.len() != document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + let mut queued = 0usize; + for (document_id, version_id) in targets { + enqueue_job( + conn, + tenant_id, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": document_id, + "document_version_id": version_id, + "force": force, + }), + None, + ) + .map_err(|err| { + error!(error = ?err, "failed to enqueue analyze job"); + AppError::internal("failed to enqueue analyze job") + })?; + queued += 1; + } + + Ok(queued) + } + + pub async fn list_document_assets( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + ) -> AppResult> { + let document = load_active_document(conn, tenant_id, document_id)?; + + let version_id = document.current_version_id; + let assets = document_assets::table + .filter(document_assets::document_version_id.eq(version_id)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .order(document_assets::created_at.asc()) + .load::(conn)?; + + let mut responses = Vec::with_capacity(assets.len()); + for asset in assets { + responses.push(self.asset_response(asset, tenant_id, user_id)?); + } + + Ok(responses) + } + + pub async fn get_document_asset( + &self, + mut conn: PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + asset_id: Uuid, + ) -> AppResult { + let asset: DocumentAsset = match document_assets::table + .find(asset_id) + .filter(document_assets::tenant_id.eq(tenant_id)) + .first(&mut conn) + .optional()? + { + Some(asset) => asset, + None => return Err(AppError::not_found()), + }; + + drop(conn); + + let download = self.asset_download_link(asset.id, tenant_id, user_id)?; + + Ok(to_asset_detail_response(asset, Some(download))) + } + + pub async fn get_document_download_link( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + ) -> AppResult { + let document = load_active_document(conn, tenant_id, document_id)?; + let version: DocumentVersion = document_versions::table + .find(document.current_version_id) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(conn)?; + + build_download_link(self.state, &document, version.id, user_id) + } + + pub async fn get_document_version_download_link( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + version_id: Uuid, + ) -> AppResult { + let document = load_active_document(conn, tenant_id, document_id)?; + + let version: Option = document_versions::table + .find(version_id) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(conn) + .optional()?; + + let Some(version) = version else { + return Err(AppError::not_found()); + }; + + build_download_link(self.state, &document, version.id, user_id) + } + + pub async fn get_asset_download_link( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + asset_id: Uuid, + ) -> AppResult { + let asset: Option = document_assets::table + .find(asset_id) + .filter(document_assets::tenant_id.eq(tenant_id)) + .first(conn) + .optional()?; + + let Some(asset) = asset else { + return Err(AppError::not_found()); + }; + + self.asset_download_link(asset.id, tenant_id, user_id) + } + + pub fn list_document_versions( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult> { + load_active_document(conn, tenant_id, document_id)?; + + let versions: Vec = document_versions::table + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .order(document_versions::version_number.asc()) + .load(conn)?; + + Ok(versions.into_iter().map(to_version_response).collect()) + } + + pub async fn get_document_version( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + version_id: Uuid, + ) -> AppResult { + let document = load_active_document(conn, tenant_id, document_id)?; + + let version: DocumentVersion = document_versions::table + .find(version_id) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(conn)?; + + let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?; + let download = build_download_link(self.state, &document, version.id, user_id)?; + let version_core = to_version_response(version); + + Ok(DocumentVersionDetailResponse { + version: version_core, + assets, + download, + }) + } + + pub fn trash_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult<()> { + conn.transaction::<_, AppError, _>(|conn| { + let document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .for_update() + .first(conn) + .optional()? + .ok_or_else(AppError::not_found)?; + + if document.deleted_at.is_some() { + return Err(AppError::conflict("document already trashed")); + } + + let now = Utc::now().naive_utc(); + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::deleted_at.eq(Some(now)), + documents::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(()) + }) + } + + pub fn delete_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult<()> { + conn.transaction::<_, AppError, _>(|conn| { + let document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .for_update() + .first::(conn) + .optional()? + .ok_or_else(AppError::not_found)?; + + if document.deleted_at.is_none() { + return Err(AppError::conflict( + "document must be trashed before permanent deletion", + )); + } + + let payload = json!({ "document_id": document_id }); + match enqueue_job(conn, tenant_id, JOB_PURGE_DOCUMENT, payload, None) { + Ok(_) => Ok(()), + Err(JobQueueError::Database(diesel::result::Error::DatabaseError( + DatabaseErrorKind::UniqueViolation, + _, + ))) => Ok(()), + Err(JobQueueError::Database(err)) => Err(AppError::from(err)), + } + }) + } + + pub async fn update_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + payload: Value, + ) -> AppResult { + let mut document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + if document.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + let payload_obj = payload + .as_object() + .ok_or_else(|| AppError::bad_request("request body must be a JSON object"))?; + let DocumentUpdatePlan { mut changeset } = + Self::build_document_update_plan(&document, payload_obj)?; + + let now = Utc::now().naive_utc(); + changeset.updated_at = Some(now); + + let target = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)); + + let update_result = diesel::update(target).set(&changeset); + + match update_result.execute(conn) { + Ok(_) => {} + Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { + return Err(AppError::conflict( + "another document in this folder already uses that filename", + ) + .with_code("duplicate_filename")); + } + Err(err) => return Err(AppError::from(err)), + } + + document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + let current_version: DocumentVersion = document_versions::table + .find(document.current_version_id) + .first(conn)?; + + let tags_and_correspondents = load_tags_and_correspondents(conn, &[document_id])?; + let version_id = current_version.id; + let assets = self.load_asset_responses(conn, tenant_id, version_id, user_id)?; + let version_response = to_version_response(current_version); + let download = build_download_link(self.state, &document, version_id, user_id)?; + let (tags, correspondents) = tags_and_correspondents + .get(&document_id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let document_response = self.to_document_response( + user_id, + document, + tags, + correspondents, + Some((version_response, assets, download)), + )?; + + Ok(DocumentDetailResponse { + document: document_response, + }) + } + + pub fn restore_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + folder_id: Option, + ) -> AppResult<()> { + let mut document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + if document.deleted_at.is_none() { + return Ok(()); + } + + if let Some(folder_id) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, folder_id)?; + document.folder_id = Some(folder_id); + } else if let Some(existing_folder) = document.folder_id { + let exists: bool = diesel::select(exists( + folders::table + .filter(folders::id.eq(existing_folder)) + .filter(folders::tenant_id.eq(tenant_id)), + )) + .get_result(conn)?; + + if !exists { + document.folder_id = None; + } + } + + let now = Utc::now().naive_utc(); + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::deleted_at.eq::>(None), + documents::folder_id.eq(document.folder_id), + documents::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(()) + } + + pub fn move_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + folder_id: Option, + ) -> AppResult<()> { + if let Some(folder_id) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, folder_id)?; + } + + let now = Utc::now().naive_utc(); + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::folder_id.eq(folder_id), + documents::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(()) + } + + pub fn bulk_move_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + mut document_ids: Vec, + folder_id: Option, + ) -> AppResult { + if document_ids.is_empty() { + return Err(AppError::bad_request("document_ids must not be empty")); + } + + document_ids.sort(); + document_ids.dedup(); + + if let Some(target_folder) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, target_folder)?; + } + + let existing: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&document_ids)) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if existing.len() != document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if existing.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request("cannot move deleted documents")); + } + + let now = Utc::now().naive_utc(); + let updated = match diesel::update( + documents::table + .filter(documents::id.eq_any(&document_ids)) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::folder_id.eq(folder_id), + documents::updated_at.eq(now), + )) + .execute(conn) + { + Ok(value) => value, + Err(diesel::result::Error::DatabaseError(kind, info)) => { + error!( + ?kind, + detail = ?info.details(), + constraint = info.constraint_name(), + tenant_id = %tenant_id, + target_folder = folder_id.map(|id| id.to_string()), + "bulk move update failed" + ); + let message = info + .constraint_name() + .map(|name| format!("constraint {name} prevented moving documents")) + .unwrap_or_else(|| "unable to move documents due to a constraint".to_string()); + return Err(AppError::conflict(message)); + } + Err(err) => { + error!( + ?err, + tenant_id = %tenant_id, + target_folder = folder_id.map(|id| id.to_string()), + "bulk move update failed" + ); + return Err(AppError::from(err)); + } + }; + + Ok(updated) + } + + fn to_document_response( + &self, + _user_id: Uuid, + doc: Document, + tags: Vec, + correspondents: Vec, + current_version: Option<( + DocumentVersionResponse, + Vec, + DownloadLink, + )>, + ) -> AppResult { + let current_version = match current_version { + Some((version, assets, download)) => Some(DocumentVersionDetailResponse { + version, + assets, + download, + }), + None => None, + }; + + Ok(DocumentResponse { + id: doc.id, + filename: doc.filename, + title: doc.title, + original_name: doc.original_name, + mime_type: doc.mime_type, + folder_id: doc.folder_id, + created_at: to_iso(doc.created_at), + updated_at: to_iso(doc.updated_at), + deleted_at: doc.deleted_at.map(to_iso), + issued_at: doc.issued_at.map(to_iso), + metadata: doc.metadata, + tags: tags.into_iter().map(TagResponse::from).collect(), + correspondents, + current_version, + }) + } + + async fn try_reuse_existing_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + checksum_hex: &str, + issued_at_override: Option, + skip_if_existing: bool, + tag_ids: &[Uuid], + correspondents: &[CorrespondentAssignmentInput], + ) -> AppResult> { + let existing = documents::table + .inner_join( + document_versions::table + .on(document_versions::id.eq(documents::current_version_id)), + ) + .filter(documents::tenant_id.eq(tenant_id)) + .filter(document_versions::checksum.eq(checksum_hex)) + .select((documents::all_columns, document_versions::all_columns)) + .first::<(Document, DocumentVersion)>(conn) + .optional()?; + + let Some((mut document, version)) = existing else { + return Ok(None); + }; + + if skip_if_existing { + info!( + document_id = %document.id, + checksum = %checksum_hex, + "upload rejected because document already exists", + ); + + let mut message = String::from("a document with the same contents already exists"); + let mut details = json!({ + "conflict_document_id": document.id, + }); + + if let Some(deleted_at) = document.deleted_at { + message.push_str(". Note: the existing document is currently in the trash."); + if let Some(obj) = details.as_object_mut() { + obj.insert("conflict_document_in_trash".to_string(), Value::Bool(true)); + obj.insert( + "conflict_document_deleted_at".to_string(), + Value::String(to_iso(deleted_at)), + ); + } + } + + return Err(AppError::conflict(message) + .with_code("duplicate_document") + .with_details(details)); + } + + if let Some(issued_at) = issued_at_override { + if document.issued_at != Some(issued_at) { + diesel::update( + documents::table + .find(document.id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::issued_at.eq(Some(issued_at)), + documents::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + document.issued_at = Some(issued_at); + } + } + + assign_tags_to_document(conn, tenant_id, &document, tag_ids, Some(user_id))?; + + if !correspondents.is_empty() { + let raw_ids: Vec = correspondents + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + insert_document_correspondents( + conn, + tenant_id, + document.id, + user_id, + &correspondent_ids, + )?; + } + + if document.deleted_at.is_some() { + let now = Utc::now().naive_utc(); + diesel::update(documents::table.find(document.id)) + .set(( + documents::deleted_at.eq(None::), + documents::updated_at.eq(now), + )) + .execute(conn)?; + document.deleted_at = None; + document.updated_at = now; + } + + let relations = load_tags_and_correspondents(conn, &[document.id])?; + let (tags, correspondents_list) = relations + .get(&document.id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?; + let download = build_download_link(self.state, &document, version.id, user_id)?; + let version_response = to_version_response(version.clone()); + + info!( + document_id = %document.id, + checksum = %checksum_hex, + "upload deduplicated existing document" + ); + + let detail = DocumentDetailResponse { + document: self.to_document_response( + user_id, + document, + tags, + correspondents_list, + Some((version_response, assets, download)), + )?, + }; + + Ok(Some(detail)) + } + + fn asset_download_link( + &self, + asset_id: Uuid, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult { + let token = self + .state + .jwt + .generate_asset_download_token(asset_id, user_id, tenant_id) + .map_err(|err| { + error!(error = ?err, "failed to issue asset download token"); + AppError::internal("failed to issue asset download token") + })?; + + let expires_at = Utc::now() + .checked_add_signed(ChronoDuration::minutes( + self.state.config.download_token_expiry_minutes, + )) + .ok_or_else(|| AppError::internal("failed to compute download expiry"))? + .timestamp_millis(); + + Ok(DownloadLink { + url: format!("/api/download/{token}"), + expires_at, + }) + } + + fn asset_response( + &self, + asset: DocumentAsset, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult { + let download = self.asset_download_link(asset.id, tenant_id, user_id)?; + + Ok(DocumentAssetResponse { + id: asset.id, + asset_type: asset.asset_type, + mime_type: asset.mime_type, + metadata: asset.metadata, + download: Some(download), + }) + } + + fn load_asset_responses( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + version_id: Uuid, + user_id: Uuid, + ) -> AppResult> { + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq(version_id)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .order(document_assets::created_at.asc()) + .load(conn)?; + + assets + .into_iter() + .map(|asset| self.asset_response(asset, tenant_id, user_id)) + .collect() + } +} diff --git a/backend/src/services/folders.rs b/backend/src/services/folders.rs new file mode 100644 index 0000000..b64f798 --- /dev/null +++ b/backend/src/services/folders.rs @@ -0,0 +1,635 @@ +use std::collections::HashMap; + +use diesel::pg::PgConnection; +use diesel::{ + dsl::{exists, sql}, + prelude::*, + sql_types::Text, + Connection, +}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection}; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{IntoAppResult, RowsAffectedExt}; +use crate::models::{Document, Folder, NewFolder}; +use crate::schema::{documents, folders}; +use crate::services::documents::{DocumentResponse, DocumentsService}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::{json::deserialize_patch_field, text::normalize_identifier, time::to_iso}; + +const MAX_FOLDER_NAME_LEN: usize = 255; + +#[derive(Deserialize, ToSchema)] +pub struct CreateFolderRequest { + pub name: String, + #[schema(nullable)] + pub parent_id: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct EnsureFolderPathRequest { + #[schema(nullable)] + pub parent_id: Option, + pub segments: Vec, +} + +#[derive(Serialize, ToSchema, Clone, Debug)] +pub struct FolderInfo { + pub id: Uuid, + pub name: String, + #[schema(nullable)] + pub parent_id: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Serialize, ToSchema)] +pub struct FolderTreeNode { + pub id: Uuid, + pub name: String, + #[schema(nullable)] + pub parent_id: Option, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub children: Vec, +} + +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] +pub struct FolderContentsQuery { + #[serde(default = "default_include_documents")] + #[schema(default = true)] + pub include_documents: bool, + #[serde(default)] + #[schema(default = "title")] + pub sort: DocumentSortField, + #[serde(default)] + #[schema(default = "asc")] + pub dir: SortDirection, +} + +#[derive(Default, Deserialize, ToSchema)] +pub struct UpdateFolderRequest { + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable, value_type = Option)] + pub parent_id: Option>, + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable)] + pub name: Option>, +} + +pub struct FolderContentsData { + pub folder: Option, + pub subfolders: Vec, + pub documents: Vec, +} + +pub struct FolderService<'a> { + state: &'a AppState, +} + +impl<'a> FolderService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn get_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Uuid, + ) -> AppResult { + let folder: Folder = folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)?; + Ok(folder_to_info(folder)) + } + + pub fn ensure_folder_path( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + payload: EnsureFolderPathRequest, + ) -> AppResult { + if payload.segments.is_empty() { + return Err(AppError::bad_request("segments must not be empty")); + } + + let folder = conn.transaction::(|conn| { + let mut current_parent = payload.parent_id; + let mut last_folder: Option = None; + + for raw_name in &payload.segments { + let name = normalize_folder_name(raw_name, "folder names must not be empty")?; + + let existing: Option = if let Some(parent_id) = current_parent { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + } else { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + }; + + let folder = if let Some(folder) = existing { + folder + } else { + let new_folder = NewFolder { + id: Uuid::new_v4(), + name: name.clone(), + parent_id: current_parent, + tenant_id, + }; + + let inserted_id: Option = diesel::insert_into(folders::table) + .values(&new_folder) + .on_conflict_do_nothing() + .returning(folders::id) + .get_result(conn) + .optional()?; + + if let Some(id) = inserted_id { + folders::table + .find(id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)? + } else if let Some(parent_id) = current_parent { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn)? + } else { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn)? + } + }; + + current_parent = Some(folder.id); + last_folder = Some(folder); + } + + last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path")) + })?; + + Ok(folder_to_info(folder)) + } + + pub fn create_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + payload: CreateFolderRequest, + ) -> AppResult<(FolderInfo, bool)> { + let name = normalize_folder_name(&payload.name, "name must not be empty")?; + + let existing: Option = if let Some(parent_id) = payload.parent_id { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + } else { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + }; + + let (folder, created) = if let Some(folder) = existing { + (folder, false) + } else { + let new_folder = NewFolder { + id: Uuid::new_v4(), + name: name.clone(), + parent_id: payload.parent_id, + tenant_id, + }; + + let inserted_id: Option = diesel::insert_into(folders::table) + .values(&new_folder) + .on_conflict_do_nothing() + .returning(folders::id) + .get_result(conn) + .optional()?; + + if let Some(id) = inserted_id { + ( + folders::table + .find(id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)?, + true, + ) + } else if let Some(parent_id) = payload.parent_id { + ( + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn)?, + false, + ) + } else { + ( + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn)?, + false, + ) + } + }; + + Ok((folder_to_info(folder), created)) + } + + pub fn list_folder_contents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Option, + sort: DocumentSortField, + dir: SortDirection, + include_documents: bool, + ) -> AppResult { + let folder = match folder_id { + Some(id) => Some(folder_to_info( + folders::table + .find(id) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn)?, + )), + None => None, + }; + + let child_folders: Vec = if let Some(parent_id) = folder_id { + folders::table + .filter(folders::parent_id.eq(parent_id)) + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(conn)? + } else { + folders::table + .filter(folders::parent_id.is_null()) + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(conn)? + }; + + let subfolders = child_folders.into_iter().map(folder_to_info).collect(); + + let documents = if include_documents { + let mut docs_query = documents::table + .filter(documents::deleted_at.is_null()) + .filter(documents::tenant_id.eq(tenant_id)) + .into_boxed(); + + let (primary_sql, secondary_sql) = ordering_clauses(sort, dir); + docs_query = docs_query.order(sql::(primary_sql)); + if let Some(second) = secondary_sql { + docs_query = docs_query.then_order_by(sql::(second)); + } + + if let Some(current_folder) = folder_id { + docs_query + .filter(documents::folder_id.eq(current_folder)) + .load::(conn)? + } else { + docs_query + .filter(documents::folder_id.is_null()) + .load::(conn)? + } + } else { + Vec::new() + }; + + Ok(FolderContentsData { + folder, + subfolders, + documents, + }) + } + + pub fn list_folder_tree( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + ) -> AppResult> { + let folders: Vec = folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(conn)?; + + let mut node_map: HashMap = HashMap::with_capacity(folders.len()); + let mut children_map: HashMap> = HashMap::new(); + let mut roots: Vec = Vec::new(); + + for folder in folders { + let id = folder.id; + let parent_id = folder.parent_id; + let node = FolderTreeNode { + id, + name: folder.name, + parent_id, + created_at: to_iso(folder.created_at), + updated_at: to_iso(folder.updated_at), + children: Vec::new(), + }; + + if let Some(parent) = parent_id { + children_map.entry(parent).or_default().push(id); + } else { + roots.push(id); + } + + node_map.insert(id, node); + } + + fn build_node( + id: Uuid, + nodes: &HashMap, + child_map: &HashMap>, + ) -> FolderTreeNode { + let mut node = nodes.get(&id).cloned().expect("folder node must exist"); + + if let Some(children) = child_map.get(&id) { + node.children = children + .iter() + .map(|child_id| build_node(*child_id, nodes, child_map)) + .collect(); + } + + node + } + + let tree = roots + .iter() + .map(|root_id| build_node(*root_id, &node_map, &children_map)) + .collect(); + + Ok(tree) + } + + pub fn delete_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Uuid, + ) -> AppResult<()> { + conn.transaction::<_, AppError, _>(|conn| { + folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn)?; + + let has_child_folders: bool = diesel::select(exists( + folders::table + .filter(folders::parent_id.eq(Some(folder_id))) + .filter(folders::tenant_id.eq(tenant_id)), + )) + .get_result(conn)?; + + if has_child_folders { + return Err(AppError::bad_request( + "folder must be empty before deletion", + )); + } + + let has_documents: bool = diesel::select(exists( + documents::table + .filter(documents::folder_id.eq(Some(folder_id))) + .filter(documents::tenant_id.eq(tenant_id)) + .filter(documents::deleted_at.is_null()), + )) + .get_result(conn)?; + + if has_documents { + return Err(AppError::bad_request( + "folder must be empty before deletion", + )); + } + + diesel::delete( + folders::table + .filter(folders::id.eq(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)), + ) + .execute(conn) + .into_app_result()? + .or_not_found()?; + + Ok(()) + }) + } + + pub fn update_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Uuid, + payload: UpdateFolderRequest, + ) -> AppResult<()> { + conn.transaction::<_, AppError, _>(|conn| { + let folder: Folder = folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)?; + + let mut next_parent = folder.parent_id; + let mut parent_changed = false; + match payload.parent_id { + None => {} + Some(None) => { + if folder.parent_id.is_some() { + parent_changed = true; + } + next_parent = None; + } + Some(Some(parent_id)) => { + if parent_id == folder_id { + return Err(AppError::bad_request("folder cannot be its own parent")); + } + + folders::table + .find(parent_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn)?; + + if folder.parent_id != Some(parent_id) { + let descendant_ids = + gather_descendant_folder_ids(conn, tenant_id, folder_id)?; + if descendant_ids.contains(&parent_id) { + return Err(AppError::bad_request( + "cannot move folder into itself or a descendant", + )); + } + parent_changed = true; + } + + next_parent = Some(parent_id); + } + } + + let mut new_name = folder.name.clone(); + let mut name_changed = false; + match payload.name { + None => {} + Some(None) => { + return Err(AppError::bad_request("name cannot be null")); + } + Some(Some(value)) => { + let normalized = normalize_folder_name(&value, "name must not be empty")?; + + if normalized != folder.name { + new_name = normalized; + name_changed = true; + } + } + } + + if !parent_changed && !name_changed { + return Ok(()); + } + + let conflict = if let Some(parent_id) = next_parent { + folders::table + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&new_name)) + .filter(folders::id.ne(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn) + .optional()? + } else { + folders::table + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&new_name)) + .filter(folders::id.ne(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn) + .optional()? + }; + + if conflict.is_some() { + return Err(AppError::bad_request( + "a folder with the same name already exists in the target", + )); + } + + diesel::update( + folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)), + ) + .set(( + folders::parent_id.eq(next_parent), + folders::name.eq(&new_name), + )) + .execute(conn) + .into_app_result()? + .or_not_found()?; + + Ok(()) + }) + } + + pub fn hydrate_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + docs: Vec, + ) -> AppResult> { + DocumentsService::new(self.state).hydrate_documents(conn, tenant_id, user_id, docs) + } +} + +pub fn gather_descendant_folder_ids( + conn: &mut PgConnection, + tenant_id: Uuid, + folder_id: Uuid, +) -> AppResult> { + let mut ids = vec![folder_id]; + let mut queue = vec![folder_id]; + + while let Some(current) = queue.pop() { + let child_ids: Vec = folders::table + .filter(folders::parent_id.eq(Some(current))) + .filter(folders::tenant_id.eq(tenant_id)) + .select(folders::id) + .load(conn)?; + queue.extend(child_ids.iter().copied()); + ids.extend(child_ids); + } + + Ok(ids) +} + +fn folder_to_info(folder: Folder) -> FolderInfo { + FolderInfo { + id: folder.id, + name: folder.name, + parent_id: folder.parent_id, + created_at: to_iso(folder.created_at), + updated_at: to_iso(folder.updated_at), + } +} + +fn normalize_folder_name(value: &str, empty_message: &str) -> AppResult { + normalize_identifier( + value, + MAX_FOLDER_NAME_LEN, + empty_message, + "folder name must not exceed 255 characters", + Some("folder name may only contain printable characters"), + |ch| !ch.is_control(), + ) +} + +const fn default_include_documents() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::UpdateFolderRequest; + use serde_json::json; + + #[test] + fn update_folder_request_deserializes_null_parent() { + let req: UpdateFolderRequest = + serde_json::from_value(json!({ "parent_id": null })).unwrap(); + assert!(matches!(req.parent_id, Some(None))); + } + + #[test] + fn update_folder_request_deserializes_absent_parent() { + let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap(); + assert!(req.parent_id.is_none()); + } + + #[test] + fn update_folder_request_deserializes_null_name() { + let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap(); + assert!(matches!(req.name, Some(None))); + } +} diff --git a/backend/src/services/helpers.rs b/backend/src/services/helpers.rs new file mode 100644 index 0000000..50c0052 --- /dev/null +++ b/backend/src/services/helpers.rs @@ -0,0 +1,25 @@ +use diesel::prelude::*; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::models::Document; +use crate::schema::documents; +use crate::state::PgPooledConnection; + +/// Load a document that belongs to the tenant and is not soft-deleted. +pub fn load_active_document( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, +) -> AppResult { + let doc: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + if doc.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + Ok(doc) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..d8a266b --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,9 @@ +pub mod auth; +pub mod capability_sets; +pub mod correspondents; +pub mod documents; +pub mod folders; +pub mod helpers; +pub mod profile; +pub mod tags; +pub mod tenants; diff --git a/backend/src/services/profile.rs b/backend/src/services/profile.rs new file mode 100644 index 0000000..b5ea4dc --- /dev/null +++ b/backend/src/services/profile.rs @@ -0,0 +1,222 @@ +use axum::http::StatusCode; +use chrono::{DateTime, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::auth::{ + api_tokens::{ + create_api_token as issue_token, list_api_tokens as load_tokens, + regenerate_api_token as rotate_token, revoke_api_token as revoke_token, + }, + capability_sets::load_capability_set, + passkeys::PasskeySummary, +}; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{created_json, no_content, ok_json, JsonResponse}; +use crate::models::ApiToken; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::time::to_iso; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ApiTokenResponse { + pub id: Uuid, + pub tenant_id: Uuid, + #[schema(nullable)] + pub label: Option, + pub capability_set_id: Uuid, + pub created_at: String, + #[schema(nullable)] + pub last_used_at: Option, + #[schema(nullable)] + pub expires_at: Option, + #[schema(nullable)] + pub revoked_at: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ApiTokenCreatedResponse { + pub token: String, + pub token_info: ApiTokenResponse, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateApiTokenRequest { + #[schema(nullable)] + pub label: Option, + #[schema(nullable)] + pub expires_at: Option, + pub capability_set_id: Uuid, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct RevokePasskeyQuery { + #[serde(default)] + #[schema(nullable)] + pub reason: Option, +} + +pub struct ProfileService<'a> { + state: &'a AppState, +} + +impl<'a> ProfileService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn list_passkeys( + &self, + conn: &mut PgPooledConnection, + user_id: Uuid, + ) -> AppResult>> { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let passkeys = service.list_for_user(conn, user_id)?; + ok_json(passkeys) + } + + pub fn list_api_tokens( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult>> { + let tokens = load_tokens(conn, user_id, Some(tenant_id))?; + let responses = tokens.into_iter().map(api_token_to_response).collect(); + ok_json(responses) + } + + pub fn create_api_token( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + payload: CreateApiTokenRequest, + ) -> AppResult> { + let expires_at = payload + .expires_at + .as_ref() + .map(|value| parse_timestamp(value)) + .transpose()?; + + let capability_set_id = + validate_capability_set(conn, tenant_id, payload.capability_set_id)?; + + let issued = issue_token( + conn, + user_id, + tenant_id, + payload.label.clone(), + expires_at, + capability_set_id, + )?; + + let token_info = api_token_to_response(issued.record); + + let response = ApiTokenCreatedResponse { + token: issued.token, + token_info, + }; + + created_json(response) + } + + pub fn regenerate_api_token( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + token_id: Uuid, + ) -> AppResult> { + let issued = rotate_token(conn, token_id, user_id, Some(tenant_id))?; + let token_info = api_token_to_response(issued.record); + ok_json(ApiTokenCreatedResponse { + token: issued.token, + token_info, + }) + } + + pub fn delete_api_token( + &self, + conn: &mut PgPooledConnection, + user_id: Uuid, + token_id: Uuid, + ) -> AppResult { + revoke_token(conn, token_id, user_id)?; + no_content() + } + + pub fn delete_passkey( + &self, + conn: &mut PgPooledConnection, + user_id: Uuid, + passkey_id: Uuid, + reason: Option, + ) -> AppResult { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let active_count = service.active_passkey_count(conn, user_id)?; + if active_count <= 1 { + return Err(AppError::bad_request( + "cannot revoke the last remaining passkey", + )); + } + + service.revoke_passkey(conn, user_id, passkey_id, reason)?; + no_content() + } +} + +fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { + let ApiToken { + id, + tenant_id, + label, + created_at, + last_used_at, + expires_at, + revoked_at, + capability_set_id, + .. + } = token; + + ApiTokenResponse { + id, + tenant_id, + label, + capability_set_id, + created_at: to_iso(created_at), + last_used_at: last_used_at.map(to_iso), + expires_at: expires_at.map(to_iso), + revoked_at: revoked_at.map(to_iso), + } +} + +fn parse_timestamp(value: &str) -> AppResult { + let dt = DateTime::parse_from_rfc3339(value) + .map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?; + Ok(dt.naive_utc()) +} + +fn validate_capability_set( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + capability_set_id: Uuid, +) -> AppResult { + let set = load_capability_set(conn, capability_set_id)?; + if set.tenant_id != tenant_id { + return Err(AppError::bad_request( + "capability set does not belong to the tenant", + )); + } + Ok(set.id) +} diff --git a/backend/src/services/tags.rs b/backend/src/services/tags.rs new file mode 100644 index 0000000..595464c --- /dev/null +++ b/backend/src/services/tags.rs @@ -0,0 +1,166 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::documents::tags::assign_tags as assign_tags_to_document; +use crate::error::{AppError, AppResult}; +use crate::models::{Document, NewDocumentTag, Tag}; +use crate::schema::{document_tags, documents, tags}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::db::validate_bulk_ids; + +#[derive(Deserialize, ToSchema)] +pub struct AssignTagsRequest { + pub tag_ids: Vec, +} + +#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BulkTagAction { + Add, + Remove, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkTagRequest { + pub document_ids: Vec, + pub tag_ids: Vec, + pub action: BulkTagAction, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkTagResponse { + pub added: usize, + pub removed: usize, +} + +pub struct TagsService<'a> { + _state: &'a AppState, +} + +impl<'a> TagsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { _state: state } + } + + pub fn assign_to_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + tag_ids: &[Uuid], + ) -> AppResult<()> { + if tag_ids.is_empty() { + return Err(AppError::bad_request("tag_ids must not be empty")); + } + + let document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + assign_tags_to_document(conn, tenant_id, &document, tag_ids, Some(user_id))?; + Ok(()) + } + + pub fn bulk_update( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + mut payload: BulkTagRequest, + ) -> AppResult { + validate_bulk_ids(&mut payload.document_ids, "document_ids")?; + validate_bulk_ids(&mut payload.tag_ids, "tag_ids")?; + + let docs: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&payload.document_ids)) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if docs.len() != payload.document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if docs.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request( + "cannot assign or remove tags from deleted documents", + )); + } + + let existing_tags: Vec = tags::table + .filter(tags::id.eq_any(&payload.tag_ids)) + .filter(tags::tenant_id.eq(tenant_id)) + .load(conn)?; + + if existing_tags.len() != payload.tag_ids.len() { + return Err(AppError::bad_request("one or more tags do not exist")); + } + + match payload.action { + BulkTagAction::Add => { + let mut inserts = + Vec::with_capacity(payload.document_ids.len() * payload.tag_ids.len()); + for doc_id in &payload.document_ids { + for tag_id in &payload.tag_ids { + inserts.push(NewDocumentTag { + document_id: *doc_id, + tag_id: *tag_id, + assigned_by: Some(user_id), + tenant_id, + }); + } + } + + let added = if inserts.is_empty() { + 0 + } else { + diesel::insert_into(document_tags::table) + .values(&inserts) + .on_conflict_do_nothing() + .execute(conn)? + }; + + Ok(BulkTagResponse { added, removed: 0 }) + } + BulkTagAction::Remove => { + let removed = diesel::delete( + document_tags::table + .filter(document_tags::document_id.eq_any(&payload.document_ids)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .filter(document_tags::tag_id.eq_any(&payload.tag_ids)), + ) + .execute(conn)?; + + Ok(BulkTagResponse { added: 0, removed }) + } + } + } + + pub fn remove_from_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + tag_id: Uuid, + ) -> AppResult<()> { + let deleted = diesel::delete( + document_tags::table + .filter(document_tags::document_id.eq(document_id)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .filter(document_tags::tag_id.eq(tag_id)), + ) + .execute(conn)?; + + if deleted == 0 { + return Err(AppError::not_found()); + } + + Ok(()) + } +} diff --git a/backend/src/services/tenants.rs b/backend/src/services/tenants.rs new file mode 100644 index 0000000..42109e5 --- /dev/null +++ b/backend/src/services/tenants.rs @@ -0,0 +1,260 @@ +use chrono::Utc; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::auth::AuthenticatedUser; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{ok_json, JsonResponse}; +use crate::models::ApiCapability; +use crate::schema::{ + capability_sets::dsl as cs_dsl, user_memberships::dsl as memberships_dsl, + user_sessions::dsl as session_dsl, users::dsl as users_dsl, +}; +use crate::state::{AppState, PgPooledConnection}; + +#[derive(Deserialize, ToSchema)] +pub struct UpdateTenantRequest { + #[schema(example = "Acme Inc.")] + pub name: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateTenantUserRequest { + #[schema(example = "a2f1bc73-4c90-4bb9-9da9-1c5d04be12ac")] + pub capability_set_id: Uuid, +} + +#[derive(Serialize, ToSchema)] +#[schema(example = json!({ + "user_id": "11111111-2222-3333-4444-555555555555", + "username": "cfo", + "capability_set_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "capability_set_slug": "owner" +}))] +pub struct TenantUserSummary { + pub user_id: Uuid, + pub username: String, + pub capability_set_id: Option, + pub capability_set_slug: Option, +} + +#[derive(Serialize, ToSchema)] +#[schema(example = json!({ + "users": [ + { + "user_id": "11111111-2222-3333-4444-555555555555", + "username": "alice", + "capability_set_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "capability_set_slug": "owner" + }, + { + "user_id": "66666666-7777-8888-9999-000000000000", + "username": "bob", + "capability_set_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + "capability_set_slug": "user" + } + ] +}))] +pub struct TenantUserListResponse { + pub users: Vec, +} + +pub struct TenantApiService<'a> { + state: &'a AppState, +} + +impl<'a> TenantApiService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn update_name( + &self, + user: AuthenticatedUser, + tenant_id: Uuid, + payload: UpdateTenantRequest, + ) -> AppResult> { + self.ensure_can_manage(&user, tenant_id)?; + let tenant = self.state.tenants.update_name(tenant_id, &payload.name)?; + ok_json(crate::services::auth::TenantSnippet { + id: tenant.id, + name: tenant.name, + }) + } + + pub fn list_users( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + ) -> AppResult> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + let rows: Vec<(Uuid, String, Option, Option)> = + memberships_dsl::user_memberships + .inner_join(users_dsl::users.on(users_dsl::id.eq(memberships_dsl::user_id))) + .left_join( + cs_dsl::capability_sets + .on(cs_dsl::id.nullable().eq(memberships_dsl::capability_set_id)), + ) + .select(( + users_dsl::id, + users_dsl::username, + memberships_dsl::capability_set_id, + cs_dsl::slug.nullable(), + )) + .order(users_dsl::username.asc()) + .load(&mut conn)?; + + let users = rows + .into_iter() + .map( + |(user_id, username, capability_set_id, capability_set_slug)| TenantUserSummary { + user_id, + username, + capability_set_id, + capability_set_slug, + }, + ) + .collect(); + + ok_json(TenantUserListResponse { users }) + } + + pub fn get_user( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + target_user_id: Uuid, + ) -> AppResult> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + let summary = self.load_membership_summary(&mut conn, tenant_id, target_user_id)?; + ok_json(summary) + } + + pub fn update_user( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + target_user_id: Uuid, + payload: UpdateTenantUserRequest, + ) -> AppResult> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + + let capability_set_id = self.resolve_capability_set_id(&mut conn, tenant_id, &payload)?; + + let updated = diesel::update( + memberships_dsl::user_memberships + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .filter(memberships_dsl::user_id.eq(target_user_id)), + ) + .set(( + memberships_dsl::capability_set_id.eq(Some(capability_set_id)), + memberships_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(&mut conn)?; + + if updated == 0 { + return Err(AppError::not_found()); + } + + let summary = self.load_membership_summary(&mut conn, tenant_id, target_user_id)?; + ok_json(summary) + } + + pub fn remove_user( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + target_user_id: Uuid, + ) -> AppResult<()> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + + let removed = diesel::delete( + memberships_dsl::user_memberships + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .filter(memberships_dsl::user_id.eq(target_user_id)), + ) + .execute(&mut conn)?; + + if removed == 0 { + return Err(AppError::not_found()); + } + + diesel::delete( + session_dsl::user_sessions + .filter(session_dsl::tenant_id.eq(tenant_id)) + .filter(session_dsl::user_id.eq(target_user_id)), + ) + .execute(&mut conn)?; + + Ok(()) + } + + fn ensure_can_manage(&self, user: &AuthenticatedUser, tenant_id: Uuid) -> AppResult<()> { + if user.tenant_id != tenant_id { + return Err(AppError::forbidden("cannot manage another tenant")); + } + + if !user.capabilities.contains(&ApiCapability::TenantsWrite) { + return Err(AppError::forbidden("missing tenants:write capability")); + } + + Ok(()) + } + + fn resolve_capability_set_id( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + payload: &UpdateTenantUserRequest, + ) -> AppResult { + let exists = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::id.eq(payload.capability_set_id)) + .select(cs_dsl::id) + .first::(conn) + .optional()?; + exists.ok_or_else(AppError::not_found) + } + + fn load_membership_summary( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult { + let row = memberships_dsl::user_memberships + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .filter(memberships_dsl::user_id.eq(user_id)) + .inner_join(users_dsl::users.on(users_dsl::id.eq(memberships_dsl::user_id))) + .left_join( + cs_dsl::capability_sets + .on(cs_dsl::id.nullable().eq(memberships_dsl::capability_set_id)), + ) + .select(( + users_dsl::id, + users_dsl::username, + memberships_dsl::capability_set_id, + cs_dsl::slug.nullable(), + )) + .first::<(Uuid, String, Option, Option)>(conn) + .optional()?; + + match row { + Some((user_id, username, capability_set_id, capability_set_slug)) => { + Ok(TenantUserSummary { + user_id, + username, + capability_set_id, + capability_set_slug, + }) + } + None => Err(AppError::not_found()), + } + } +} diff --git a/backend/src/state.rs b/backend/src/state.rs new file mode 100644 index 0000000..484fcbe --- /dev/null +++ b/backend/src/state.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use diesel::{ + pg::PgConnection, + r2d2::{ConnectionManager, PooledConnection}, +}; +use uuid::Uuid; + +use crate::{ + auth::{jwt::JwtService, passkeys::PasskeyService}, + config::AppConfig, + db::PgPool, + error::{AppError, AppResult}, + issued_at::IssuedAtSettings, + storage::{ObjectStorage, TenantStorage}, + tenants::{apply_tenant_guc, clear_tenant_context, clear_user_guc, TenantService}, +}; + +pub type PgPooledConnection = PooledConnection>; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + pub config: Arc, + storage: Arc, + pub jwt: JwtService, + pub tenants: TenantService, + pub passkeys: Option, + issued_at: Arc, +} + +impl AppState { + pub async fn initialize( + config: AppConfig, + pool_size_override: Option, + ) -> anyhow::Result { + let pool_size = pool_size_override.unwrap_or(config.database_max_pool_size); + let pool = crate::db::init_pool_with_size(&config.database_url, pool_size)?; + let bucket = crate::s3::build_bucket(&config)?; + let storage = Arc::new(crate::storage::S3Storage::new(bucket)); + let jwt = crate::auth::jwt::JwtService::from_config(&config)?; + + Ok(Self::new(pool, config, storage, jwt)) + } + + pub fn new( + pool: PgPool, + config: AppConfig, + storage: Arc, + jwt: JwtService, + ) -> Self { + let issued_at = Arc::new(IssuedAtSettings::from_config(&config)); + let config = Arc::new(config); + let tenants = TenantService::new(pool.clone()); + + let passkeys = match PasskeyService::try_new(&config) { + Ok(service) => service, + Err(err) => { + tracing::warn!(error = ?err, "passkey service disabled due to configuration"); + None + } + }; + + Self { + pool, + config, + storage, + jwt, + tenants, + passkeys, + issued_at, + } + } + + pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult { + debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant"); + let mut conn = self.db_unscoped()?; + let conn_ptr = &*conn as *const _; + tracing::trace!(target = "db_pool", ?conn_ptr, tenant_id = %tenant_id, "apply tenant context"); + apply_tenant_guc(&mut conn, tenant_id)?; + clear_user_guc(&mut conn)?; + Ok(conn) + } + + pub(crate) fn db_unscoped(&self) -> AppResult { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + let conn_ptr = &*conn as *const _; + tracing::trace!(target = "db_pool", ?conn_ptr, "acquired connection"); + clear_tenant_context(&mut conn)?; + Ok(conn) + } + + pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult { + let tenant = self.tenants.get_by_id(tenant_id)?; + TenantStorage::new(self.storage.clone(), &tenant).map_err(|err| { + tracing::error!(error = ?err, "tenant storage error"); + AppError::internal("tenant storage error") + }) + } + + pub fn issued_at_settings(&self) -> Arc { + self.issued_at.clone() + } +} diff --git a/backend/src/storage.rs b/backend/src/storage.rs new file mode 100644 index 0000000..c70291b --- /dev/null +++ b/backend/src/storage.rs @@ -0,0 +1,195 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use s3::bucket::Bucket; + +use crate::models::Tenant; + +#[async_trait] +pub trait ObjectStorage: Send + Sync + 'static { + async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + content_disposition: Option, + ) -> Result<()>; + + async fn presign_get_object( + &self, + key: &str, + expires_in: Duration, + response_content_disposition: Option<&str>, + ) -> Result; + + async fn get_object(&self, key: &str) -> Result>; + + async fn get_object_range(&self, key: &str, start: u64, end: Option) -> Result>; + + async fn delete_object(&self, key: &str) -> Result<()>; +} + +pub struct S3Storage { + bucket: Bucket, +} + +impl S3Storage { + pub fn new(bucket: Bucket) -> Self { + Self { bucket } + } + + fn default_content_type(content_type: Option) -> String { + content_type.unwrap_or_else(|| "application/octet-stream".to_string()) + } +} + +#[async_trait] +impl ObjectStorage for S3Storage { + async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + content_disposition: Option, + ) -> Result<()> { + let mut builder = self + .bucket + .put_object_builder(key, &bytes) + .with_content_type(Self::default_content_type(content_type)); + + if let Some(disposition) = content_disposition { + builder = builder + .with_content_disposition(disposition) + .context("invalid content disposition header")?; + } + + builder + .execute() + .await + .context("failed to upload object to S3")?; + + Ok(()) + } + + async fn presign_get_object( + &self, + key: &str, + expires_in: Duration, + response_content_disposition: Option<&str>, + ) -> Result { + let expiry_secs = + u32::try_from(expires_in.as_secs()).context("presign expiry exceeds u32 range")?; + + let mut queries = HashMap::new(); + if let Some(value) = response_content_disposition { + queries.insert( + "response-content-disposition".to_string(), + value.to_string(), + ); + } + + self.bucket + .presign_get(key, expiry_secs, (!queries.is_empty()).then_some(queries)) + .await + .context("failed to generate presigned download URL") + } + + async fn get_object(&self, key: &str) -> Result> { + let data = self + .bucket + .get_object(key) + .await + .context("failed to download object from S3")?; + Ok(data.into_bytes().to_vec()) + } + + async fn get_object_range(&self, key: &str, start: u64, end: Option) -> Result> { + let data = self + .bucket + .get_object_range(key, start, end) + .await + .context("failed to download ranged object from S3")?; + Ok(data.into_bytes().to_vec()) + } + + async fn delete_object(&self, key: &str) -> Result<()> { + self.bucket + .delete_object(key) + .await + .context("failed to delete object from S3")?; + Ok(()) + } +} +#[derive(Clone)] +pub struct TenantStorage { + inner: Arc, + root: String, +} + +impl TenantStorage { + pub fn new(inner: Arc, tenant: &Tenant) -> Result { + let root = tenant + .storage_root + .as_ref() + .ok_or_else(|| anyhow!("tenant {} missing storage_root", tenant.id))? + .to_owned(); + + Ok(Self { inner, root }) + } + + fn qualify(&self, key: &str) -> String { + format!("{}{}", self.root, key) + } + + pub fn root_prefix(&self) -> &str { + &self.root + } + + pub async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + content_disposition: Option, + ) -> Result<()> { + let qualified = self.qualify(key); + self.inner + .put_object(&qualified, bytes, content_type, content_disposition) + .await + } + + pub async fn presign_get_object( + &self, + key: &str, + expires_in: Duration, + response_content_disposition: Option<&str>, + ) -> Result { + let qualified = self.qualify(key); + self.inner + .presign_get_object(&qualified, expires_in, response_content_disposition) + .await + } + + pub async fn get_object(&self, key: &str) -> Result> { + let qualified = self.qualify(key); + self.inner.get_object(&qualified).await + } + + pub async fn get_object_range( + &self, + key: &str, + start: u64, + end: Option, + ) -> Result> { + let qualified = self.qualify(key); + self.inner.get_object_range(&qualified, start, end).await + } + + pub async fn delete_object(&self, key: &str) -> Result<()> { + let qualified = self.qualify(key); + self.inner.delete_object(&qualified).await + } +} diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs new file mode 100644 index 0000000..8e8cf69 --- /dev/null +++ b/backend/src/tenants.rs @@ -0,0 +1,245 @@ +use diesel::{pg::PgConnection, prelude::*, sql_types::Text}; +use serde_json::json; +use uuid::Uuid; + +use crate::{ + db::PgPool, + error::{AppError, AppResult}, + jobs::{enqueue_job, JOB_PROVISION_TENANT}, + models::{Tenant, TenantStatus}, + schema::tenants::dsl, + utils::text::normalize_identifier, +}; + +pub struct TenantRepository; + +impl TenantRepository { + pub fn get_by_id(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult { + dsl::tenants.find(tenant_id).first(conn).map_err(Into::into) + } + + pub fn get_by_name(conn: &mut PgConnection, name: &str) -> AppResult { + dsl::tenants + .filter(dsl::name.eq(name)) + .first(conn) + .map_err(Into::into) + } + + pub fn update_name(conn: &mut PgConnection, tenant_id: Uuid, name: &str) -> AppResult { + diesel::update(dsl::tenants.find(tenant_id)) + .set(dsl::name.eq(name)) + .execute(conn)?; + Self::get_by_id(conn, tenant_id) + } +} + +#[derive(Clone)] +pub struct TenantService { + pool: PgPool, +} + +impl TenantService { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub fn get_by_id(&self, tenant_id: Uuid) -> AppResult { + let tenant = self.load(|conn| TenantRepository::get_by_id(conn, tenant_id))?; + Ok(tenant) + } + + pub fn get_by_name(&self, name: &str) -> AppResult { + let name_owned = name.to_owned(); + let tenant = self.load(|conn| TenantRepository::get_by_name(conn, &name_owned))?; + Ok(tenant) + } + + pub fn create_tenant( + &self, + name: &str, + storage_root: Option<&str>, + quickwit_index: Option<&str>, + status: TenantStatus, + initial_members: &[Uuid], + created_by: Option, + ) -> AppResult { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + self.create_tenant_with_conn( + &mut conn, + name, + storage_root, + quickwit_index, + status, + initial_members, + created_by, + ) + } + + fn load(&self, loader: F) -> AppResult + where + F: FnOnce(&mut PgConnection) -> AppResult, + { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + let tenant = loader(&mut conn)?; + Ok(tenant) + } + pub fn create_tenant_with_conn( + &self, + conn: &mut PgConnection, + name: &str, + storage_root: Option<&str>, + quickwit_index: Option<&str>, + status: TenantStatus, + initial_members: &[Uuid], + created_by: Option, + ) -> AppResult { + let name = name.trim(); + if name.is_empty() { + return Err(AppError::bad_request("tenant name must not be empty")); + } + + let name = normalize_tenant_name(name)?; + + let id = Uuid::new_v4(); + let storage_root = normalize_storage_root(storage_root, id); + let quickwit_index = normalize_quickwit_index(quickwit_index, id); + + diesel::insert_into(dsl::tenants) + .values(( + dsl::id.eq(id), + dsl::name.eq(&name), + dsl::storage_root.eq(Some(storage_root.clone())), + dsl::quickwit_index.eq(Some(quickwit_index.clone())), + dsl::config.eq(json!({})), + dsl::status.eq(status), + dsl::created_by.eq(created_by), + )) + .execute(conn)?; + + if status == TenantStatus::Creating { + let payload = json!({ + "members": initial_members, + }); + + enqueue_job(conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| { + tracing::error!(error = ?err, tenant_id = %id, "failed to enqueue tenant provisioning job"); + AppError::internal("failed to enqueue tenant provisioning job") + })?; + } + + TenantRepository::get_by_id(conn, id) + } + + pub fn update_name(&self, tenant_id: Uuid, name: &str) -> AppResult { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + + let normalized = normalize_tenant_name(name)?; + TenantRepository::update_name(&mut conn, tenant_id, &normalized) + } +} + +pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, false)") + .bind::(tenant_id.to_string()) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn apply_user_guc(conn: &mut PgConnection, user_id: Uuid) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_id', $1, false)") + .bind::(user_id.to_string()) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query( + "SELECT \ + set_config('papercrate.tenant_id', '', false), \ + set_config('papercrate.user_id', '', false), \ + set_config('papercrate.user_session_hash', '', false), \ + set_config('papercrate.api_token_prefix', '', false)", + ) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_user_guc(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_id', '', false)") + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn apply_user_session_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_session_hash', $1, false)") + .bind::(hash) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_user_session_hash(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_session_hash', '', false)") + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', $1, false)") + .bind::(prefix) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +fn normalize_tenant_name(value: &str) -> AppResult { + normalize_identifier( + value, + 255, + "tenant name must not be empty", + "tenant name must not exceed 255 characters", + Some("tenant name may only contain printable characters"), + |ch| !ch.is_control(), + ) +} + +pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)") + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String { + match raw.map(str::trim) { + Some(root) if !root.is_empty() => { + let mut owned = root.to_owned(); + if !owned.ends_with('/') { + owned.push('/'); + } + owned + } + _ => format!("tenants/{tenant_id}/"), + } +} + +fn normalize_quickwit_index(raw: Option<&str>, tenant_id: Uuid) -> String { + match raw.map(str::trim) { + Some(value) if !value.is_empty() => value.to_owned(), + _ => format!("documents-{tenant_id}"), + } +} diff --git a/backend/src/test_support/mod.rs b/backend/src/test_support/mod.rs new file mode 100644 index 0000000..50b8f38 --- /dev/null +++ b/backend/src/test_support/mod.rs @@ -0,0 +1,935 @@ +use std::collections::HashMap; +use std::env; +use std::sync::Arc; +use std::time::Duration; + +use crate::auth::capability_sets::{ + ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities, + webdav_capabilities, +}; +use crate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind}; +use crate::config::AppConfig; +use crate::db::{self, PgPool}; +use crate::migrations::MIGRATIONS; +use crate::models::{ + Job, NewUser, NewUserMembership, NewUserPasskey, NewUserSession, Tenant, TenantStatus, User, + UserMembership, +}; +use crate::routes; +use crate::schema::user_sessions::dsl as session_dsl; +use crate::state::AppState; +use crate::storage::ObjectStorage; +use anyhow::{anyhow, ensure, Context, Result}; +use async_trait::async_trait; +use axum::body::Body; +use axum::http::{header, Method, Request}; +use axum::Router; +use chrono::{Duration as ChronoDuration, Utc}; +use diesel::connection::SimpleConnection; +use diesel::prelude::*; +use diesel::OptionalExtension; +use diesel::PgConnection; +use diesel_migrations::MigrationHarness; +use http_body_util::BodyExt; +use once_cell::sync::Lazy; +use rand::{rngs::OsRng, TryRngCore}; +use serde::Serialize; +use serde_json::{self, json}; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use tower::util::ServiceExt; +use uuid::Uuid; + +const RESET_DATABASE_SQL: &str = "DROP SCHEMA IF EXISTS tenant CASCADE;\n\ + DROP SCHEMA IF EXISTS shared CASCADE;\n\ + DROP SCHEMA IF EXISTS public CASCADE;\n\ + CREATE SCHEMA public;\n\ + GRANT ALL ON SCHEMA public TO public;"; + +static DB_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +const TEST_TENANT_NAME: &str = "test_tenant"; + +#[derive(Clone, Copy, Debug)] +pub enum TestUserRole { + Owner, + Member, + WebDav, +} + +#[derive(Clone)] +pub struct StoredObject { + pub key: String, + pub bytes: Vec, + pub content_type: Option, + pub content_disposition: Option, +} + +#[derive(Default)] +pub struct FakeStorage { + objects: Mutex>, +} + +#[async_trait] +impl ObjectStorage for FakeStorage { + async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + content_disposition: Option, + ) -> Result<()> { + let stored = StoredObject { + key: key.to_string(), + bytes, + content_type, + content_disposition, + }; + let mut guard = self.objects.lock().await; + guard.insert(stored.key.clone(), stored); + Ok(()) + } + + async fn presign_get_object( + &self, + key: &str, + expires_in: Duration, + _response_content_disposition: Option<&str>, + ) -> Result { + let guard = self.objects.lock().await; + ensure!(guard.contains_key(key), "object {key} missing"); + Ok(format!( + "https://fake-storage/{key}?expires_in={}", + expires_in.as_secs() + )) + } + + async fn get_object(&self, key: &str) -> Result> { + let guard = self.objects.lock().await; + guard + .get(key) + .map(|obj| obj.bytes.clone()) + .ok_or_else(|| anyhow!("object {key} missing")) + } + + async fn get_object_range( + &self, + key: &str, + start: u64, + end: Option, + ) -> Result> { + let guard = self.objects.lock().await; + let bytes = guard + .get(key) + .map(|obj| obj.bytes.clone()) + .ok_or_else(|| anyhow!("object {key} missing"))?; + + let start_idx = start as usize; + let end_idx = end.map(|idx| idx.saturating_add(1) as usize).unwrap_or(bytes.len()); + if start_idx >= bytes.len() { + return Ok(Vec::new()); + } + Ok(bytes[start_idx..end_idx.min(bytes.len())].to_vec()) + } + + async fn delete_object(&self, key: &str) -> Result<()> { + let mut guard = self.objects.lock().await; + guard.remove(key); + Ok(()) + } +} + +impl FakeStorage { + pub async fn get(&self, key: &str) -> Option { + let guard = self.objects.lock().await; + guard.get(key).cloned() + } + + pub async fn object_count(&self) -> usize { + let guard = self.objects.lock().await; + guard.len() + } + + pub async fn object_count_with_prefix(&self, prefix: &str) -> usize { + let guard = self.objects.lock().await; + guard.keys().filter(|key| key.starts_with(prefix)).count() + } + + pub async fn contains_key(&self, key: &str) -> bool { + let guard = self.objects.lock().await; + guard.contains_key(key) + } + + pub async fn keys_with_prefix(&self, prefix: &str) -> Vec { + let guard = self.objects.lock().await; + guard + .keys() + .filter(|key| key.starts_with(prefix)) + .cloned() + .collect() + } +} + +pub struct TestApp { + pub state: AppState, + router: Router, + storage: Arc, +} + +impl TestApp { + pub async fn new() -> Result { + Self::with_config(|_| {}).await + } + + pub async fn with_config(configure: F) -> Result + where + F: FnOnce(&mut AppConfig), + { + let database_url = env::var("TEST_DATABASE_URL") + .context("TEST_DATABASE_URL must be set for integration tests")?; + + let mut config = AppConfig { + database_url: database_url.clone(), + migrations_database_url: None, + database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE, + server_host: "127.0.0.1".to_string(), + server_port: 0, + webdav_host: "127.0.0.1".to_string(), + webdav_port: 0, + jwt_secret: "test-secret".to_string(), + jwt_issuer: "test-issuer".to_string(), + jwt_audience: "test-audience".to_string(), + jwt_expiry_minutes: 60, + download_token_audience: "test-download".to_string(), + download_token_expiry_minutes: 60, + refresh_token_expiry_days: 30, + refresh_cookie_secure: false, + refresh_cookie_domain: None, + cors_allowed_origin: None, + proxy_downloads: false, + aws_endpoint_url: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_region: "us-east-1".to_string(), + s3_bucket: "test-bucket".to_string(), + quickwit_endpoint: None, + quickwit_index: None, + worker_max_document_bytes: 200 * 1024 * 1024, + upload_body_limit_bytes: 128 * 1024 * 1024, + service_timezone: "UTC".to_string(), + issued_at_date_order: "DMY".to_string(), + issued_at_filename_date_order: None, + issued_at_date_parser_locales: Vec::new(), + issued_at_ignore_dates: Vec::new(), + webauthn_rp_id: Some("localhost".to_string()), + webauthn_origin: Some("http://localhost".to_string()), + webauthn_rp_name: "Papercrate".to_string(), + }; + + configure(&mut config); + + let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; + prepare_database(&pool).await?; + + let storage = Arc::new(FakeStorage::default()); + let storage_for_state: Arc = storage.clone(); + let jwt = JwtService::from_config(&config)?; + let state = AppState::new(pool.clone(), config, storage_for_state, jwt); + let router = routes::create_router(state.clone()); + + let app = Self { + state, + router, + storage, + }; + + app.ensure_default_tenant().await?; + + Ok(app) + } + + pub async fn cleanup(&self) -> Result<()> { + let pool = self.state.pool.clone(); + let _ = tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = pool + .get() + .map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?; + truncate_all(&mut conn)?; + Ok(()) + }) + .await + .context("cleanup task panicked")?; + + self.ensure_default_tenant().await?; + Ok(()) + } + + pub async fn tenant_id(&self) -> Result { + self.ensure_default_tenant().await + } + + pub fn storage(&self) -> Arc { + self.storage.clone() + } + + pub async fn storage_key_for(&self, key: &str) -> Result { + self.ensure_default_tenant().await?; + let tenant = self + .state + .tenants + .get_by_name(TEST_TENANT_NAME) + .map_err(|err| anyhow!("default tenant not found: {:?}", err))?; + let root = tenant + .storage_root + .clone() + .ok_or_else(|| anyhow!("default tenant missing storage root"))?; + Ok(format!("{}{}", root, key)) + } + + pub async fn insert_user(&self, username: &str, role: TestUserRole) -> Result { + let username = username.to_string(); + let tenant_id = self.ensure_default_tenant().await?; + let user_id = self + .with_conn(move |conn| { + let user = NewUser { + id: Uuid::new_v4(), + username, + }; + diesel::insert_into(crate::schema::users::table) + .values(&user) + .execute(conn) + .context("failed to insert user")?; + + let capabilities = match role { + TestUserRole::Owner => owner_capabilities(), + TestUserRole::Member => user_capabilities(), + TestUserRole::WebDav => webdav_capabilities(), + }; + + let capability_set = ensure_capability_set(conn, tenant_id, capabilities) + .map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id: user.id, + tenant_id, + capability_set_id: Some(capability_set.id), + }; + + diesel::insert_into(crate::schema::user_memberships::table) + .values(&membership) + .execute(conn) + .context("failed to insert user membership")?; + Ok(user.id) + }) + .await?; + + Ok(user_id) + } + + pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result { + let passkey_id = Uuid::new_v4(); + let nickname = nickname.map(|value| value.to_string()); + self.with_conn(move |conn| { + let credential_id = passkey_id.as_bytes().to_vec(); + let public_key = passkey_id.as_bytes().iter().copied().collect::>(); + let passkey = NewUserPasskey { + id: passkey_id, + user_id, + credential_id, + public_key, + credential: json!({ "dummy": passkey_id.to_string() }), + sign_count: 0, + transports: vec![Some("usb".to_string())], + aaguid: None, + nickname, + }; + + diesel::insert_into(crate::schema::user_passkeys::table) + .values(&passkey) + .execute(conn) + .context("failed to insert passkey")?; + + Ok(passkey_id) + }) + .await + } + + async fn ensure_default_tenant(&self) -> Result { + let name_value = TEST_TENANT_NAME.to_string(); + let quickwit_enabled = self.state.config.quickwit_endpoint.is_some(); + let tenant_id = self + .with_conn(move |conn| { + use crate::schema::tenants::dsl as tenants_dsl; + + let existing = tenants_dsl::tenants + .filter(tenants_dsl::name.eq(&name_value)) + .first::(conn) + .optional() + .context("failed to load default tenant")?; + + let tenant_id = if let Some(current) = existing { + let desired_root = current + .storage_root + .clone() + .filter(|root| root.ends_with('/')) + .unwrap_or_else(|| format!("test-tenants/{}/", current.id)); + + if current.storage_root.as_deref() != Some(desired_root.as_str()) { + diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id))) + .set(tenants_dsl::storage_root.eq(Some(desired_root))) + .execute(conn) + .context("failed to update default tenant storage root")?; + } + + current.id + } else { + let new_id = Uuid::new_v4(); + let root = format!("test-tenants/{}/", new_id); + let quickwit_value = if quickwit_enabled { + Some(format!("documents-{}", new_id)) + } else { + None + }; + + diesel::insert_into(tenants_dsl::tenants) + .values(( + tenants_dsl::id.eq(new_id), + tenants_dsl::name.eq(&name_value), + tenants_dsl::storage_root.eq(Some(root)), + tenants_dsl::quickwit_index.eq(quickwit_value), + tenants_dsl::status.eq(TenantStatus::Active), + )) + .execute(conn) + .context("failed to insert default tenant")?; + + new_id + }; + + Ok(tenant_id) + }) + .await?; + + let mut conn = self + .state + .db_for_tenant(tenant_id) + .map_err(|err| anyhow!("failed to scope tenant connection: {err:?}"))?; + + ensure_capability_set(&mut conn, tenant_id, owner_capabilities()) + .map_err(|err| anyhow!("ensure owner capability set: {err:?}"))?; + ensure_capability_set(&mut conn, tenant_id, user_capabilities()) + .map_err(|err| anyhow!("ensure user capability set: {err:?}"))?; + ensure_capability_set(&mut conn, tenant_id, readonly_capabilities()) + .map_err(|err| anyhow!("ensure readonly capability set: {err:?}"))?; + ensure_capability_set(&mut conn, tenant_id, webdav_capabilities()) + .map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?; + + Ok(tenant_id) + } + + pub async fn login_token(&self, username: &str, _password: &str) -> Result { + let (access_token, _, _) = self.create_session(username).await?; + Ok(access_token) + } + + pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> { + let username = username.to_string(); + let state = self.state.clone(); + self.with_conn(move |conn| { + use crate::schema::capability_sets::dsl as capability_sets_dsl; + use crate::schema::tenants::dsl as tenants_dsl; + use crate::schema::user_memberships::dsl as memberships_dsl; + use crate::schema::users::dsl as users_dsl; + + let user: User = users_dsl::users + .filter(users_dsl::username.eq(&username)) + .first(conn)?; + + let membership: UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .first(conn)?; + + let tenant: Tenant = tenants_dsl::tenants + .find(membership.tenant_id) + .first(conn)?; + + let capability_set_id = membership + .capability_set_id + .ok_or_else(|| anyhow!("membership missing capability set"))?; + + let cap_version = capability_sets_dsl::capability_sets + .find(capability_set_id) + .select(capability_sets_dsl::cap_version) + .first::(conn)?; + + let now = Utc::now(); + let session_id = Uuid::new_v4(); + let access_token = state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: tenant.id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version, + }) + .map_err(|err| anyhow!(err))?; + + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); + let refresh_expires_at = + now + ChronoDuration::days(state.config.refresh_token_expiry_days); + + let new_session = NewUserSession { + id: session_id, + user_id: user.id, + token_hash: session_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + tenant_id: tenant.id, + }; + + diesel::insert_into(session_dsl::user_sessions) + .values(&new_session) + .execute(conn)?; + + let cookie = format!("refresh_token={session_value}"); + Ok((access_token, cookie, tenant.id)) + }) + .await + } + + pub async fn clear_jobs(&self) -> Result<()> { + self.with_conn(|conn| { + use crate::schema::jobs::dsl::jobs as jobs_table; + diesel::delete(jobs_table) + .execute(conn) + .context("failed to clear jobs")?; + Ok(()) + }) + .await + } + + pub async fn jobs_by_type(&self, ty: &str) -> Result> { + let ty = ty.to_string(); + self.with_conn(move |conn| { + use crate::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table}; + let rows = jobs_table + .filter(job_type_col.eq(&ty)) + .load::(conn) + .context("failed to load jobs")?; + Ok(rows) + }) + .await + } + + pub async fn post_json( + &self, + path: &str, + payload: &T, + token: Option<&str>, + ) -> Result> { + self.post_json_with_cookie(path, payload, token, None).await + } + + pub async fn post_json_with_cookie( + &self, + path: &str, + payload: &T, + token: Option<&str>, + cookie: Option<&str>, + ) -> Result> { + let body = serde_json::to_vec(payload)?; + let mut builder = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json"); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } + let request = builder.body(Body::from(body))?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn patch_json( + &self, + path: &str, + payload: &T, + token: Option<&str>, + ) -> Result> { + let body = serde_json::to_vec(payload)?; + let mut builder = Request::builder() + .method(Method::PATCH) + .uri(path) + .header("content-type", "application/json"); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + let request = builder.body(Body::from(body))?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn get(&self, path: &str, token: Option<&str>) -> Result> { + let mut builder = Request::builder().method(Method::GET).uri(path); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + let request = builder.body(Body::empty())?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn delete(&self, path: &str, token: Option<&str>) -> Result> { + let builder = Request::builder().method(Method::DELETE).uri(path); + let builder = if let Some(token) = token { + builder.header("authorization", format!("Bearer {token}")) + } else { + builder + }; + let request = builder.body(Body::empty())?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn upload_document( + &self, + path: &str, + filename: &str, + content_type: &str, + data: &[u8], + folder_id: Option, + token: &str, + ) -> Result> { + let extras = UploadExtras::empty(); + self.upload_document_with_extras( + path, + filename, + content_type, + data, + folder_id, + extras, + token, + ) + .await + } + + pub async fn upload_document_with_options( + &self, + path: &str, + filename: &str, + content_type: &str, + data: &[u8], + folder_id: Option, + title: Option<&str>, + metadata_json: Option<&str>, + token: &str, + ) -> Result> { + let extras = UploadExtras { + title, + metadata_json, + tag_ids_json: None, + correspondents_json: None, + issued_at: None, + skip_existing: None, + }; + self.upload_document_with_extras( + path, + filename, + content_type, + data, + folder_id, + extras, + token, + ) + .await + } + + pub async fn upload_document_with_extras( + &self, + path: &str, + filename: &str, + content_type: &str, + data: &[u8], + folder_id: Option, + extras: UploadExtras<'_>, + token: &str, + ) -> Result> { + let boundary = format!("boundary-{}", Uuid::new_v4()); + let mut body = Vec::new(); + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend( + format!( + "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n", + filename + ) + .as_bytes(), + ); + body.extend(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes()); + body.extend(data); + body.extend(b"\r\n"); + + if let Some(folder) = folder_id { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"folder_id\"\r\n\r\n"); + body.extend(folder.to_string().as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(title_value) = extras.title { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n"); + body.extend(title_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(metadata_value) = extras.metadata_json { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n"); + body.extend(metadata_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(tag_ids_value) = extras.tag_ids_json { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n"); + body.extend(tag_ids_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(correspondents_value) = extras.correspondents_json { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n"); + body.extend(correspondents_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(issued_at_value) = extras.issued_at { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n"); + body.extend(issued_at_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(skip_flag) = extras.skip_existing { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\n"); + body.extend(if skip_flag { + b"true".as_ref() + } else { + b"false".as_ref() + }); + body.extend(b"\r\n"); + } + + body.extend(format!("--{boundary}--\r\n").as_bytes()); + + let builder = Request::builder() + .method(Method::POST) + .uri(path) + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .header("authorization", format!("Bearer {token}")); + + let request = builder.body(Body::from(body))?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn with_conn(&self, f: F) -> Result + where + F: FnOnce(&mut PgConnection) -> Result + Send + 'static, + T: Send + 'static, + { + let pool = self.state.pool.clone(); + tokio::task::spawn_blocking(move || { + let mut conn = pool + .get() + .map_err(|err| anyhow!("failed to get database connection: {err}"))?; + f(&mut conn) + }) + .await + .context("connection task panicked")? + } +} + +pub struct UploadExtras<'a> { + pub title: Option<&'a str>, + pub metadata_json: Option<&'a str>, + pub tag_ids_json: Option<&'a str>, + pub correspondents_json: Option<&'a str>, + pub issued_at: Option<&'a str>, + pub skip_existing: Option, +} + +impl<'a> UploadExtras<'a> { + pub fn empty() -> Self { + Self { + title: None, + metadata_json: None, + tag_ids_json: None, + correspondents_json: None, + issued_at: None, + skip_existing: None, + } + } +} + +pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> { + DB_LOCK.lock().await +} + +pub async fn body_to_vec(body: Body) -> Result> { + let collected = body + .collect() + .await + .map_err(|err| anyhow!("failed to read response body: {err}"))?; + Ok(collected.to_bytes().to_vec()) +} + +#[cfg(test)] +mod helper_tests { + use super::*; + + #[tokio::test] + async fn create_session_and_login_token_provide_access() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "helper-login"; + let password = "irrelevant"; + app.insert_user(username, TestUserRole::Owner).await?; + + let (access, refresh, refresh_id) = app.create_session(username).await?; + assert!(!access.is_empty(), "access token should not be empty"); + assert!(!refresh.is_empty(), "refresh token should not be empty"); + assert_ne!( + refresh_id, + Uuid::nil(), + "refresh token id should be assigned" + ); + + let bearer = app.login_token(username, password).await?; + assert!(!bearer.is_empty(), "login_token must yield bearer"); + + app.cleanup().await?; + Ok(()) + } + + #[tokio::test] + async fn insert_passkey_and_upload_with_options_succeeds() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + let username = "helper-passkey"; + let password = "unused"; + let user_id = app.insert_user(username, TestUserRole::Owner).await?; + + let passkey_id = app.insert_passkey(user_id, Some("Laptop")).await?; + assert_ne!(passkey_id, Uuid::nil()); + + let bearer = app.login_token(username, password).await?; + let response = app + .upload_document_with_options( + "/api/documents", + "helper.txt", + "text/plain", + b"helper-content", + None, + Some("Helper Note"), + Some("{\"category\":\"note\"}"), + &bearer, + ) + .await?; + assert!(response.status().is_success()); + + app.cleanup().await?; + Ok(()) + } +} + +async fn prepare_database(pool: &PgPool) -> Result<()> { + let pool = pool.clone(); + tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = pool + .get() + .map_err(|err| anyhow!("failed to acquire connection: {err}"))?; + conn.batch_execute(RESET_DATABASE_SQL) + .map_err(|err| anyhow!("failed to reset schema: {err}"))?; + conn.batch_execute("DROP TABLE IF EXISTS __diesel_schema_migrations;") + .map_err(|err| anyhow!("failed to drop diesel schema table: {err}"))?; + conn.run_pending_migrations(MIGRATIONS) + .map_err(|err| anyhow!("failed to run migrations: {err}"))?; + truncate_all(&mut conn)?; + Ok(()) + }) + .await + .context("migration task panicked")? +} + +fn truncate_all(conn: &mut PgConnection) -> Result<()> { + conn.batch_execute( + "TRUNCATE TABLE \ + tenant.document_assets, \ + tenant.document_correspondents, \ + tenant.correspondents, \ + tenant.document_tags, \ + tenant.document_versions, \ + tenant.documents, \ + tenant.folders, \ + shared.jobs, \ + tenant.user_sessions, \ + tenant.tags, \ + tenant.api_tokens, \ + shared.webauthn_challenges, \ + shared.user_passkeys, \ + tenant.user_memberships, \ + shared.users, \ + shared.magic_tokens, \ + shared.tenants \ + RESTART IDENTITY CASCADE;", + ) + .context("failed to truncate tables")?; + Ok(()) +} + +fn generate_session_token() -> String { + let mut bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut bytes) + .expect("failed to read random bytes"); + hex::encode(bytes) +} + +fn hash_session_token(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/backend/src/utils/bootstrap.rs b/backend/src/utils/bootstrap.rs new file mode 100644 index 0000000..2549572 --- /dev/null +++ b/backend/src/utils/bootstrap.rs @@ -0,0 +1,14 @@ +use std::sync::Arc; + +use anyhow::Result; + +use crate::{config::AppConfig, state::AppState, utils::tracing::init_tracing}; + +/// Initialize tracing, load configuration, and build the shared `AppState`. +/// Optionally override the connection pool size for lightweight components. +pub async fn init_component(name: &str, pool_override: Option) -> Result> { + init_tracing("info"); + let config = AppConfig::load_and_log(name)?; + let state = AppState::initialize(config, pool_override).await?; + Ok(Arc::new(state)) +} diff --git a/backend/src/utils/db.rs b/backend/src/utils/db.rs new file mode 100644 index 0000000..2c8ba96 --- /dev/null +++ b/backend/src/utils/db.rs @@ -0,0 +1,26 @@ +use diesel::pg::PgConnection; +use uuid::Uuid; + +use crate::{ + error::{AppError, AppResult}, + state::AppState, +}; + +impl AppState { + pub fn with_tenant_conn(&self, tenant_id: Uuid, f: F) -> AppResult + where + F: FnOnce(&mut PgConnection) -> AppResult, + { + let mut conn = self.db_for_tenant(tenant_id)?; + f(&mut conn) + } +} + +pub fn validate_bulk_ids(ids: &mut Vec, label: &str) -> AppResult<()> { + if ids.is_empty() { + return Err(AppError::bad_request(format!("{label} must not be empty"))); + } + ids.sort_unstable(); + ids.dedup(); + Ok(()) +} diff --git a/backend/src/utils/error.rs b/backend/src/utils/error.rs new file mode 100644 index 0000000..6aeca61 --- /dev/null +++ b/backend/src/utils/error.rs @@ -0,0 +1,32 @@ +use diesel::result::Error as DieselError; + +use crate::error::{AppError, AppResult}; + +pub trait DbResultExt { + fn db_context(self, context: &'static str) -> AppResult; +} + +impl DbResultExt for Result { + fn db_context(self, context: &'static str) -> AppResult { + self.map_err(|err| match err { + DieselError::NotFound => AppError::not_found(), + other => { + tracing::error!(error = ?other, "{context}"); + AppError::internal(context) + } + }) + } +} + +pub trait StorageResultExt { + fn storage_context(self, context: &'static str) -> AppResult; +} + +impl StorageResultExt for Result { + fn storage_context(self, context: &'static str) -> AppResult { + self.map_err(|err| { + tracing::error!(error = ?err, "{context}"); + AppError::internal(context) + }) + } +} diff --git a/backend/src/utils/http.rs b/backend/src/utils/http.rs new file mode 100644 index 0000000..119d633 --- /dev/null +++ b/backend/src/utils/http.rs @@ -0,0 +1,53 @@ +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; + +/// Build an inline `Content-Disposition` header value for a given filename. +pub fn inline_content_disposition(filename: &str) -> Option { + if filename.is_empty() { + return None; + } + + let sanitized: String = filename + .chars() + .map(|ch| match ch { + '"' | '\\' => '_', + c if !c.is_ascii() => '_', + _ => ch, + }) + .collect(); + let encoded = utf8_percent_encode(filename, NON_ALPHANUMERIC); + + Some(format!( + "inline; filename=\"{}\"; filename*=UTF-8''{}", + sanitized, encoded + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::header::HeaderValue; + + #[test] + fn test_inline_content_disposition_header_validity() { + // Test with a filename containing non-ASCII characters + let filename = "Täst.pdf"; + let disposition = inline_content_disposition(filename).unwrap(); + println!("Disposition: {}", disposition); + + // This should fail if the sanitized part contains non-ASCII characters + // and we try to create a HeaderValue from it. + let result = HeaderValue::from_str(&disposition); + + if let Ok(val) = result { + // Check if to_str succeeds (it should now!) + let to_str_res = val.to_str(); + assert!(to_str_res.is_ok(), "HeaderValue::to_str should succeed for sanitized filename"); + + let disposition_str = to_str_res.unwrap(); + assert!(disposition_str.contains("filename=\"T_st.pdf\""), "Filename should be sanitized"); + assert!(disposition_str.contains("filename*=UTF-8''T%C3%A4st%2Epdf"), "UTF-8 filename should be preserved"); + } else { + panic!("HeaderValue rejected the string: {:?}", result.err()); + } + } +} diff --git a/backend/src/utils/json.rs b/backend/src/utils/json.rs new file mode 100644 index 0000000..544ab7b --- /dev/null +++ b/backend/src/utils/json.rs @@ -0,0 +1,25 @@ +use serde::Deserialize; +use serde_json::Value; + +pub enum NullableValue { + Omitted, + Null, + String(String), +} + +pub fn classify_nullable(optional_value: Option<&Value>) -> Result { + match optional_value { + None => Ok(NullableValue::Omitted), + Some(Value::Null) => Ok(NullableValue::Null), + Some(Value::String(s)) => Ok(NullableValue::String(s.to_owned())), + Some(other) => Err(format!("expected string or null, got {other}")), + } +} + +pub fn deserialize_patch_field<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer).map(Some) +} diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs new file mode 100644 index 0000000..09e6f47 --- /dev/null +++ b/backend/src/utils/mod.rs @@ -0,0 +1,12 @@ +pub mod bootstrap; +pub mod db; +pub mod error; +pub mod http; +pub mod json; +pub mod named_entity; +pub mod setops; +pub mod storage_paths; +pub mod text; +pub mod time; +pub mod tracing; +pub mod validation; diff --git a/backend/src/utils/named_entity.rs b/backend/src/utils/named_entity.rs new file mode 100644 index 0000000..13f8954 --- /dev/null +++ b/backend/src/utils/named_entity.rs @@ -0,0 +1,28 @@ +use diesel::QueryResult; + +use crate::error::{AppError, AppResult}; + +/// Trim and validate a user-supplied entity name, returning an owned String. +/// +/// The `on_empty` closure is only invoked when the trimmed name is empty, giving +/// callers control over the concrete error that should be surfaced. +pub fn normalize_name(raw: &str, on_empty: impl Fn() -> AppError) -> AppResult { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(on_empty()); + } + Ok(trimmed.to_string()) +} + +/// Ensure that no conflicting entity exists by executing the provided query +/// closure. If a record is returned, the `on_duplicate` closure is evaluated to +/// produce the appropriate error. +pub fn ensure_name_available( + query: impl FnOnce() -> QueryResult>, + on_duplicate: impl Fn() -> AppError, +) -> AppResult<()> { + if query()?.is_some() { + return Err(on_duplicate()); + } + Ok(()) +} diff --git a/backend/src/utils/setops.rs b/backend/src/utils/setops.rs new file mode 100644 index 0000000..91c8eb0 --- /dev/null +++ b/backend/src/utils/setops.rs @@ -0,0 +1,41 @@ +use std::collections::HashSet; +use std::hash::Hash; + +use uuid::Uuid; + +use crate::error::AppResult; +use crate::state::PgPooledConnection; + +/// Intersect an optional base set with a new set, returning the resulting option. +pub fn intersect_option_sets(base: Option>, next: HashSet) -> Option> +where + T: Eq + Hash + Copy, +{ + Some(match base { + Some(existing) => existing.intersection(&next).copied().collect(), + None => next, + }) +} + +/// Iteratively intersect documents linked via a join table loader. +pub fn load_linked_doc_ids( + conn: &mut PgPooledConnection, + ids: &[Uuid], + mut loader: F, +) -> AppResult> +where + F: FnMut(&mut PgPooledConnection, Uuid) -> AppResult>, +{ + let mut current: Option> = None; + + for id in ids { + let docs_set = loader(conn, *id)?; + current = intersect_option_sets(current, docs_set); + + if current.as_ref().is_some_and(|set| set.is_empty()) { + break; + } + } + + Ok(current.unwrap_or_default()) +} diff --git a/backend/src/utils/storage_paths.rs b/backend/src/utils/storage_paths.rs new file mode 100644 index 0000000..2db3e2c --- /dev/null +++ b/backend/src/utils/storage_paths.rs @@ -0,0 +1,95 @@ +//! Document storage path helpers. +//! +//! NOTE: The path layout produced here is part of the durable storage contract. +//! External systems (presigned URLs, lifecycle jobs, migrations) expect the +//! `documents/{document_id}/...` structure to remain stable. Coordinate before +//! changing any of these helpers to avoid breaking compatibility with existing +//! objects. + +use uuid::Uuid; + +const DOCUMENTS_PREFIX: &str = "documents"; + +/// Returns the root prefix for all objects belonging to a document. +pub fn document_prefix(document_id: Uuid) -> String { + format!("{DOCUMENTS_PREFIX}/{document_id}") +} + +/// Returns the prefix for a specific document version (without the object id). +pub fn document_version_prefix(document_id: Uuid, version_number: i32) -> String { + format!("{}/v{}", document_prefix(document_id), version_number) +} + +/// Returns the storage key for a stored document version blob. +pub fn document_version_object_key( + document_id: Uuid, + version_number: i32, + version_id: Uuid, +) -> String { + format!( + "{}/{}", + document_version_prefix(document_id, version_number), + version_id + ) +} + +fn document_asset_prefix(document_id: Uuid, version_number: i32) -> String { + format!( + "{}/assets", + document_version_prefix(document_id, version_number) + ) +} + +fn document_asset_type_prefix(document_id: Uuid, version_number: i32, asset_type: &str) -> String { + format!( + "{}/{}", + document_asset_prefix(document_id, version_number), + asset_type + ) +} + +/// Returns the storage key for an asset (single object). +pub fn document_asset_key( + document_id: Uuid, + version_number: i32, + asset_type: &str, + asset_id: Uuid, +) -> String { + format!( + "{}/{}", + document_asset_type_prefix(document_id, version_number, asset_type), + asset_id + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generates_expected_paths() { + let document_id = Uuid::nil(); + let version_id = Uuid::nil(); + let asset_id = Uuid::nil(); + + assert_eq!( + document_prefix(document_id), + format!("documents/{document_id}") + ); + + assert_eq!( + document_version_prefix(document_id, 3), + format!("documents/{document_id}/v3") + ); + + assert_eq!( + document_version_object_key(document_id, 3, version_id), + format!("documents/{document_id}/v3/{version_id}") + ); + + assert_eq!( + document_asset_key(document_id, 3, "thumbnail", asset_id), + format!("documents/{document_id}/v3/assets/thumbnail/{asset_id}") + ); + } +} diff --git a/backend/src/utils/text.rs b/backend/src/utils/text.rs new file mode 100644 index 0000000..67785ee --- /dev/null +++ b/backend/src/utils/text.rs @@ -0,0 +1,31 @@ +use crate::error::{AppError, AppResult}; + +/// Normalizes an identifier-like user input by trimming, enforcing length, and validating characters. +pub fn normalize_identifier( + value: &str, + max_len: usize, + empty_message: &str, + length_message: &str, + invalid_message: Option<&str>, + mut validator: F, +) -> AppResult +where + F: FnMut(char) -> bool, +{ + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request(empty_message)); + } + + if trimmed.len() > max_len { + return Err(AppError::bad_request(length_message)); + } + + if let Some(msg) = invalid_message { + if !trimmed.chars().all(|ch| validator(ch)) { + return Err(AppError::bad_request(msg)); + } + } + + Ok(trimmed.to_string()) +} diff --git a/backend/src/utils/time.rs b/backend/src/utils/time.rs new file mode 100644 index 0000000..2489dfa --- /dev/null +++ b/backend/src/utils/time.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, NaiveDateTime, Utc}; + +/// Format a timestamp as RFC3339 using UTC. +pub fn to_iso(dt: NaiveDateTime) -> String { + DateTime::::from_naive_utc_and_offset(dt, Utc).to_rfc3339() +} + +/// Format a timestamp for HTTP headers (RFC 7231 date). +pub fn to_http_date(dt: NaiveDateTime) -> String { + DateTime::::from_naive_utc_and_offset(dt, Utc) + .format("%a, %d %b %Y %H:%M:%S GMT") + .to_string() +} diff --git a/backend/src/utils/tracing.rs b/backend/src/utils/tracing.rs new file mode 100644 index 0000000..2884b46 --- /dev/null +++ b/backend/src/utils/tracing.rs @@ -0,0 +1,14 @@ +use tracing_subscriber::EnvFilter; + +/// Initialize tracing with an optional default level. +/// +/// Falls back to `default_level` when `RUST_LOG` is not provided. +pub fn init_tracing(default_level: &str) { + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .compact() + .init(); +} diff --git a/backend/src/utils/validation.rs b/backend/src/utils/validation.rs new file mode 100644 index 0000000..33f6aba --- /dev/null +++ b/backend/src/utils/validation.rs @@ -0,0 +1,10 @@ +use crate::error::{AppError, AppResult}; + +/// Ensure an entity exists, returning a bad request error when it does not. +pub fn ensure_exists(exists: bool, entity: &str) -> AppResult<()> { + if exists { + Ok(()) + } else { + Err(AppError::bad_request(format!("{entity} does not exist"))) + } +} diff --git a/backend/src/workers/analyze.rs b/backend/src/workers/analyze.rs new file mode 100644 index 0000000..5bbdd6f --- /dev/null +++ b/backend/src/workers/analyze.rs @@ -0,0 +1,308 @@ +use std::{collections::HashSet, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use diesel::prelude::*; +use infer; +use serde::Deserialize; +use tokio::task; +use uuid::Uuid; + +use crate::{ + auth::ensure_active_tenant, jobs::JOB_ANALYZE_DOCUMENT, models::Document, state::AppState, + storage::TenantStorage, +}; + +use super::{ + index::IndexDocumentTask, + issued_at::DetermineIssuedAtTask, + job_execution_from_task_error, + ocr::{GenerateOcrTask, TEXT_CONTENT_ASSET_TYPE}, + taskflow::{ + document::DocumentVersionTaskContext, BoxedTask, Task, TaskError, TaskExecutor, + TaskPlanner, TaskResult, + }, + thumbnails::GenerateThumbnailsTask, + JobExecution, JobHandler, +}; + +#[derive(Debug, Deserialize)] +struct AnalyzePayload { + document_id: Uuid, + document_version_id: Uuid, + #[serde(default)] + force: bool, +} + +pub struct AnalyzeDocumentJob; + +impl AnalyzeDocumentJob { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl JobHandler for AnalyzeDocumentJob { + fn job_type(&self) -> &'static str { + JOB_ANALYZE_DOCUMENT + } + + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: TenantStorage, + ) -> JobExecution { + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + return JobExecution::Failed { + error: "job is no longer associated with a tenant".to_string(), + } + } + }; + + if let Err(err) = ensure_active_tenant(&state, tenant_id) { + return JobExecution::Failed { + error: err.to_string(), + }; + } + + let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) { + Ok(payload) => payload, + Err(err) => { + return JobExecution::Failed { + error: format!("invalid analyze payload: {err}"), + } + } + }; + + let mut context = DocumentVersionTaskContext::new( + job.id, + JOB_ANALYZE_DOCUMENT, + tenant_id, + payload.document_id, + payload.document_version_id, + payload.force, + state.config.worker_max_document_bytes, + state.clone(), + storage, + ); + + let planner = AnalyzePlanner::new(payload.force, state.clone()); + match TaskExecutor::run(&planner, &mut context).await { + Ok(()) => JobExecution::Success, + Err(err) => job_execution_from_task_error(err), + } + } +} + +struct AnalyzePlanner { + force: bool, + state: Arc, +} + +const MIME_SNIFF_BYTES: usize = 8192; + +impl AnalyzePlanner { + fn new(force: bool, state: Arc) -> Self { + Self { force, state } + } +} + +#[async_trait] +impl TaskPlanner for AnalyzePlanner { + async fn plan( + &self, + ctx: &mut DocumentVersionTaskContext, + ) -> TaskResult>> { + let document = ctx.document().await?.clone(); + let mut tasks: Vec> = Vec::new(); + + tasks.push(Box::new(EnsureMimeTask)); + + let (thumbnail_supported, _) = determine_thumbnail_support(&document); + if thumbnail_supported { + tasks.push(Box::new(GenerateThumbnailsTask::new(self.force))); + } + + let existing_ocr = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?.is_some(); + let mut should_index = existing_ocr; + + if document_supports_ocr(&document) { + if self.force || !existing_ocr { + tasks.push(Box::new(GenerateOcrTask::new( + self.force, + self.state.clone(), + ))); + should_index = true; + } + } + + tasks.push(Box::new(DetermineIssuedAtTask::new())); + + if should_index { + tasks.push(Box::new(IndexDocumentTask::new())); + } + + Ok(tasks) + } +} + +struct EnsureMimeTask; + +#[async_trait] +impl Task for EnsureMimeTask { + fn name(&self) -> &'static str { + "ensure-mime-type" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let document = ctx.document().await?.clone(); + let current = document.mime_type.clone(); + + let guessed = guess_mime_type(ctx, &document).await?; + let desired = match guessed { + Some(mime) if current.as_deref() != Some(mime.as_str()) => Some(mime), + _ => None, + }; + + if let Some(new_mime) = desired { + update_document_mime(ctx, document.id, new_mime).await?; + } + + Ok(()) + } +} + +async fn guess_mime_type( + ctx: &mut DocumentVersionTaskContext, + document: &Document, +) -> TaskResult> { + let bytes = ctx.object_head(MIME_SNIFF_BYTES).await?; + Ok(sniff_mime(&bytes, &document.original_name)) +} + +fn sniff_mime(bytes: &[u8], original_name: &str) -> Option { + if let Some(kind) = infer::get(bytes) { + return Some(kind.mime_type().to_string()); + } + + mime_guess::from_path(original_name) + .first_raw() + .map(|value| value.to_string()) +} + +async fn update_document_mime( + ctx: &mut DocumentVersionTaskContext, + document_id: Uuid, + mime_type: String, +) -> TaskResult<()> { + let tenant_id = ctx.tenant_id(); + let state = ctx.state().clone(); + let mime_type_clone = mime_type.clone(); + + task::spawn_blocking(move || -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + diesel::update( + crate::schema::documents::table.filter(crate::schema::documents::id.eq(document_id)), + ) + .set(crate::schema::documents::mime_type.eq(Some(mime_type_clone))) + .execute(&mut conn) + .map_err(|err| format!("{err:?}")) + .map(|_| ()) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("mime update task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + ctx.set_document_mime(Some(mime_type)); + Ok(()) +} + +pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option) { + let supported_mimes: HashSet<&'static str> = [ + "image/jpeg", + "image/png", + "image/gif", + "image/tiff", + "image/bmp", + "image/webp", + "application/pdf", + "video/mp4", + "video/quicktime", + "video/webm", + "video/x-msvideo", + "video/x-ms-wmv", + "video/x-matroska", + ] + .into_iter() + .collect(); + + if let Some(ref mime_type) = document.mime_type { + if supported_mimes.contains(mime_type.as_str()) { + return (true, None); + } + } + + if let Some(ext) = document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.to_ascii_lowercase()) + { + let supported_exts = [ + "jpg", "jpeg", "png", "gif", "tif", "tiff", "bmp", "webp", "pdf", "mp4", "m4v", "mov", + "webm", "mkv", "avi", "wmv", + ]; + if supported_exts.contains(&ext.as_str()) { + return (true, None); + } + } + + ( + false, + Some("content type not supported for thumbnails".into()), + ) +} + +fn document_supports_ocr(document: &Document) -> bool { + document + .mime_type + .as_deref() + .map(|mime| mime.eq_ignore_ascii_case("application/pdf")) + .unwrap_or_else(|| { + document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) + }) +} + +#[cfg(test)] +mod tests { + use super::sniff_mime; + + #[test] + fn sniff_mime_prefers_magic_bytes() { + const PNG_HEADER: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let mime = sniff_mime(&PNG_HEADER, "file.txt"); + assert_eq!(mime.as_deref(), Some("image/png")); + } + + #[test] + fn sniff_mime_falls_back_to_extension() { + let mime = sniff_mime(b"not enough to detect", "video.mp4"); + assert_eq!(mime.as_deref(), Some("video/mp4")); + } +} diff --git a/backend/src/workers/common.rs b/backend/src/workers/common.rs new file mode 100644 index 0000000..fddacbb --- /dev/null +++ b/backend/src/workers/common.rs @@ -0,0 +1,73 @@ +use std::collections::HashMap; + +use diesel::{prelude::*, PgConnection}; +use uuid::Uuid; + +use crate::models::{Document, DocumentAsset, DocumentVersion}; +use crate::schema::{document_assets, document_versions, documents}; +use crate::state::AppState; + +pub(crate) struct LoadedDocumentVersion { + pub document: Document, + pub version: DocumentVersion, +} + +pub(crate) fn load_document_version( + state: &AppState, + tenant_id: Uuid, + document_id: Uuid, + version_id: Uuid, +) -> Result { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + let version: DocumentVersion = document_versions::table + .find(version_id) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + if version.document_id != document_id { + return Err("document/version mismatch".into()); + } + + let document: Document = documents::table + .find(document_id) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + Ok(LoadedDocumentVersion { document, version }) +} + +pub struct LoadedAsset { + pub asset: DocumentAsset, +} + +pub(crate) fn load_version_assets( + conn: &mut PgConnection, + tenant_id: Uuid, + version_id: Uuid, + asset_types: &[&str], +) -> Result, String> { + let mut query = document_assets::table + .filter(document_assets::document_version_id.eq(version_id)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .into_boxed(); + + if !asset_types.is_empty() { + let types: Vec = asset_types.iter().map(|ty| (*ty).to_string()).collect(); + query = query.filter(document_assets::asset_type.eq_any(types)); + } + + let assets: Vec = query + .order(document_assets::created_at.asc()) + .load(conn) + .map_err(|err| format!("{err:?}"))?; + + let mut result = HashMap::with_capacity(assets.len()); + for asset in assets { + result.insert(asset.asset_type.clone(), LoadedAsset { asset }); + } + + Ok(result) +} diff --git a/backend/src/workers/index.rs b/backend/src/workers/index.rs new file mode 100644 index 0000000..09bd082 --- /dev/null +++ b/backend/src/workers/index.rs @@ -0,0 +1,79 @@ +use std::time::Duration; + +use async_trait::async_trait; +use reqwest::Client; + +use crate::documents::search::{build_quickwit_ingest_record, quickwit_ingest}; + +use super::{ + ocr::TEXT_CONTENT_ASSET_TYPE, + taskflow::{document::DocumentVersionTaskContext, Task, TaskError, TaskResult}, +}; + +pub struct IndexDocumentTask; + +impl IndexDocumentTask { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Task for IndexDocumentTask { + fn name(&self) -> &'static str { + "index-document-text" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let state = ctx.state().clone(); + let quickwit_endpoint = state + .config + .quickwit_endpoint + .clone() + .ok_or_else(|| TaskError::fail("quickwit endpoint missing"))?; + + let tenant = state.tenants.get_by_id(ctx.tenant_id()).map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("failed to load tenant: {err:?}"), + ) + })?; + + let quickwit_index = tenant + .quickwit_index + .clone() + .ok_or_else(|| TaskError::fail("tenant quickwit index not configured"))?; + + let asset = ctx + .asset(TEXT_CONTENT_ASSET_TYPE) + .await? + .ok_or_else(|| TaskError::fail("missing OCR text asset"))?; + + let s3_key = asset.asset.s3_key.clone(); + let bytes = ctx.storage().get_object(&s3_key).await.map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("failed to download ocr text: {err}"), + ) + })?; + + let text = String::from_utf8(bytes) + .map_err(|err| TaskError::fail(format!("ocr text not valid UTF-8: {err}")))?; + + if text.trim().is_empty() { + return Err(TaskError::fail("ocr text empty")); + } + + let document = ctx.document().await?.clone(); + let version = ctx.version().await?.clone(); + + let record = build_quickwit_ingest_record(&document, &version, ctx.tenant_id(), &text); + let client = Client::new(); + + quickwit_ingest(&client, &quickwit_endpoint, &quickwit_index, &[record]) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?; + + Ok(()) + } +} diff --git a/backend/src/workers/issued_at.rs b/backend/src/workers/issued_at.rs new file mode 100644 index 0000000..05805c9 --- /dev/null +++ b/backend/src/workers/issued_at.rs @@ -0,0 +1,695 @@ +use std::time::Duration; + +use async_trait::async_trait; +use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use diesel::prelude::*; +use once_cell::sync::Lazy; +use regex::Match; +use regex::Regex; +use serde_json::Value; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::issued_at::{DateOrder, IssuedAtSettings}; +use crate::schema::documents::dsl as documents_dsl; +use crate::workers::ocr::TEXT_CONTENT_ASSET_TYPE; +use crate::workers::taskflow::document::DocumentVersionTaskContext; +use crate::workers::taskflow::{Task, TaskContext, TaskError, TaskResult}; + +#[path = "issued_at_months.rs"] +mod issued_at_months; + +const MAX_FILENAME_CHARS: usize = 256; +const MAX_TEXT_CHARS: usize = 50_000; +const DATE_SEP_PATTERN: &str = r"[\s._/\-]+"; + +static YMD_RE: Lazy = + Lazy::new(|| Regex::new(r"(?u)\b(\d{4})[./-](\d{1,2})[./-](\d{1,2})\b").expect("ymd regex")); + +static NUMERIC_RE: Lazy = Lazy::new(|| { + Regex::new(r"(?u)\b(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})\b").expect("numeric regex") +}); + +static DAY_MONTH_RE: Lazy = Lazy::new(|| { + Regex::new(&format!( + r"(?u)\b(\d{{1,2}})(?:st|nd|rd|th)?{SEP}({MONTH_PATTERN}){SEP}(\d{{2,4}})\b", + SEP = DATE_SEP_PATTERN, + MONTH_PATTERN = month_pattern() + )) + .expect("day month regex") +}); + +static MONTH_DAY_RE: Lazy = Lazy::new(|| { + Regex::new(&format!( + r"(?u)\b({MONTH_PATTERN}){SEP}(\d{{1,2}})(?:st|nd|rd|th)?(?:,)?{SEP}(\d{{2,4}})\b", + SEP = DATE_SEP_PATTERN, + MONTH_PATTERN = month_pattern() + )) + .expect("month day regex") +}); + +static MONTH_YEAR_RE: Lazy = Lazy::new(|| { + Regex::new(&format!( + r"(?u)\b({MONTH_PATTERN})[\s._-]*(\d{{4}})\b", + MONTH_PATTERN = month_pattern() + )) + .expect("month year regex") +}); + +fn month_pattern() -> &'static str { + issued_at_months::pattern() +} + +pub struct DetermineIssuedAtTask; + +impl DetermineIssuedAtTask { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Task for DetermineIssuedAtTask { + fn name(&self) -> &'static str { + "determine-issued-at" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let document = ctx.document().await?.clone(); + if document.issued_at.is_some() { + return Ok(()); + } + + let version = ctx.version().await?.clone(); + let settings = ctx.state().issued_at_settings(); + let now_utc = Utc::now(); + + let parser_hint = parser_supplied_date(&document.metadata, &version.metadata, &settings) + .and_then(|dt| settings.normalize_datetime(dt, now_utc)); + + let filename_candidate = settings.filename_date_order().and_then(|order| { + let normalized = normalize_content(&document.original_name, MAX_FILENAME_CHARS); + find_date_in_text(&normalized, order, &settings, now_utc) + }); + + let text_candidate = load_document_text(ctx).await?.and_then(|text| { + let normalized = normalize_content(&text, MAX_TEXT_CHARS); + find_date_in_text(&normalized, settings.date_order(), &settings, now_utc) + }); + + if let Some((final_date, source)) = parser_hint + .map(|dt| (dt, IssuedAtSource::Parser)) + .or_else(|| filename_candidate.map(|dt| (dt, IssuedAtSource::Filename))) + .or_else(|| text_candidate.map(|dt| (dt, IssuedAtSource::Text))) + { + persist_issued_at(ctx, document.id, final_date.naive_utc()).await?; + info!( + job_id = %ctx.job_id(), + document_id = %document.id, + issued_at = %final_date, + source = source.as_ref(), + "issued_at determined" + ); + } else { + info!( + job_id = %ctx.job_id(), + document_id = %document.id, + "no issued_at signals discovered; leaving unset" + ); + } + + Ok(()) + } +} + +async fn persist_issued_at( + ctx: &DocumentVersionTaskContext, + document_id: Uuid, + issued_at: NaiveDateTime, +) -> TaskResult<()> { + let tenant_id = ctx.tenant_id(); + let state = ctx.state().clone(); + let result = tokio::task::spawn_blocking(move || -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope connection: {err:?}"))?; + diesel::update( + documents_dsl::documents + .filter(documents_dsl::tenant_id.eq(tenant_id)) + .filter(documents_dsl::id.eq(document_id)), + ) + .set(documents_dsl::issued_at.eq(issued_at)) + .execute(&mut conn) + .map_err(|err| format!("failed to update issued_at: {err}"))?; + Ok(()) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("issued_at task panicked: {err}"), + ) + })?; + result.map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + Ok(()) +} + +fn parser_supplied_date( + document_meta: &Value, + version_meta: &Value, + settings: &IssuedAtSettings, +) -> Option> { + metadata_datetime(document_meta) + .or_else(|| metadata_datetime(version_meta)) + .and_then(|raw| parse_hint_datetime(raw, settings)) +} + +fn metadata_datetime(value: &Value) -> Option<&str> { + match value { + Value::String(s) => Some(s.as_str()), + Value::Object(map) => { + for key in [ + "issued_at_override", + "issued_at", + "source_date", + "created_at", + ] { + if let Some(Value::String(s)) = map.get(key) { + return Some(s.as_str()); + } + } + if let Some(Value::Object(parser)) = map.get("parser") { + if let Some(Value::String(s)) = parser.get("issued_at") { + return Some(s.as_str()); + } + } + None + } + _ => None, + } +} + +fn parse_hint_datetime(raw: &str, settings: &IssuedAtSettings) -> Option> { + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { + return Some(dt.with_timezone(&Utc)); + } + + if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") { + return settings + .timezone() + .with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0) + .single() + .map(|dt| dt.with_timezone(&Utc)); + } + + None +} + +fn normalize_content(input: &str, limit: usize) -> String { + let truncated: String = input.chars().take(limit).collect(); + let mut normalized = String::with_capacity(truncated.len()); + for ch in truncated.chars() { + if ch.is_control() { + normalized.push(' '); + } else { + normalized.extend(ch.to_lowercase()); + } + } + normalized +} + +fn find_date_in_text( + text: &str, + order: DateOrder, + settings: &IssuedAtSettings, + now_utc: DateTime, +) -> Option> { + collect_matches_with_spans(text, order, settings, now_utc) + .into_iter() + .map(|(_, dt)| dt) + .next() +} + +fn collect_matches_with_spans( + text: &str, + order: DateOrder, + settings: &IssuedAtSettings, + now_utc: DateTime, +) -> Vec<(usize, DateTime)> { + let mut matches: Vec<(usize, usize, DateTime)> = Vec::new(); + let mut push_date = |span: Option, date: NaiveDate| { + if let Some(dt) = settings.normalize_naive(date, now_utc) { + if let Some(span) = span { + let start = span.start(); + let end = span.end(); + if let Some(existing) = + matches + .iter_mut() + .find(|(existing_start, existing_end, _)| { + *existing_start != usize::MAX + && start < *existing_end + && *existing_start < end + }) + { + let existing_len = existing.1.saturating_sub(existing.0); + let new_len = end.saturating_sub(start); + if new_len > existing_len { + *existing = (start, end, dt); + } + return; + } + matches.push((start, end, dt)); + } else { + matches.push((usize::MAX, usize::MAX, dt)); + } + } + }; + + for caps in YMD_RE.captures_iter(text) { + let (Some(year_match), Some(month_match), Some(day_match)) = + (caps.get(1), caps.get(2), caps.get(3)) + else { + continue; + }; + let year = match year_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let month = match month_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let day = match day_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { + push_date(caps.get(0), date); + } + } + + for caps in NUMERIC_RE.captures_iter(text) { + let (Some(first_match), Some(second_match), Some(year_match)) = + (caps.get(1), caps.get(2), caps.get(3)) + else { + continue; + }; + let first = match first_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let second = match second_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let year_raw = year_match.as_str(); + let mut year = match year_raw.parse::() { + Ok(value) => value, + Err(_) => continue, + }; + if year_raw.len() == 2 { + year += if year >= 70 { 1900 } else { 2000 }; + } + // NUMERIC_RE always captures a day-first form (dd[sep]mm[sep]yy(yy)); + // YMD layouts are handled earlier by YMD_RE, so YMD here is treated the + // same as DMY to avoid mis-parsing strings like 01-07-2024. + let (day, month) = match order { + DateOrder::Dmy | DateOrder::Ymd => (first, second), + DateOrder::Mdy => (second, first), + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { + push_date(caps.get(0), date); + } + } + + for caps in DAY_MONTH_RE.captures_iter(text) { + let (Some(day_match), Some(month_match), Some(year_match)) = + (caps.get(1), caps.get(2), caps.get(3)) + else { + continue; + }; + let day_str = day_match + .as_str() + .trim_matches(|c: char| !c.is_ascii_digit()); + let day = match day_str.parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let Some(month) = month_name_to_number(month_match.as_str(), settings) else { + continue; + }; + if year_match.as_str().len() < 3 { + continue; + } + let Some(year) = normalize_year(year_match.as_str()) else { + continue; + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { + push_date(caps.get(0), date); + } + } + + for caps in MONTH_DAY_RE.captures_iter(text) { + let (Some(month_match), Some(day_match), Some(year_match)) = + (caps.get(1), caps.get(2), caps.get(3)) + else { + continue; + }; + let Some(month) = month_name_to_number(month_match.as_str(), settings) else { + continue; + }; + let day_str = day_match + .as_str() + .trim_matches(|c: char| !c.is_ascii_digit()); + let day = match day_str.parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let Some(year) = normalize_year(year_match.as_str()) else { + continue; + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { + push_date(caps.get(0), date); + } + } + + for caps in MONTH_YEAR_RE.captures_iter(text) { + let (Some(month_match), Some(year_match)) = (caps.get(1), caps.get(2)) else { + continue; + }; + let Some(month) = month_name_to_number(month_match.as_str(), settings) else { + continue; + }; + let year = match year_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, 1) { + push_date(caps.get(0), date); + } + } + + matches.sort_by_key(|(start, _, _)| *start); + matches + .into_iter() + .map(|(start, _, dt)| (start, dt)) + .collect() +} + +fn normalize_year(raw: &str) -> Option { + if raw.len() == 2 { + let mut year = raw.parse::().ok()?; + year += if year >= 70 { 1900 } else { 2000 }; + Some(year) + } else { + raw.parse::().ok() + } +} + +fn month_name_to_number(value: &str, settings: &IssuedAtSettings) -> Option { + let normalized = value.trim(); + let variant = issued_at_months::variants() + .iter() + .find(|entry| entry.name == normalized)?; + if settings.locales().is_empty() || variant.locales.is_empty() { + return Some(variant.month); + } + if variant + .locales + .iter() + .any(|locale| settings.locales().contains(*locale)) + { + Some(variant.month) + } else { + None + } +} + +async fn load_document_text(ctx: &mut DocumentVersionTaskContext) -> TaskResult> { + let object_key = { + let asset = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?; + asset.map(|a| a.asset.s3_key.clone()) + }; + + let Some(key) = object_key else { + return Ok(None); + }; + + match ctx.storage().get_object(&key).await { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(mut text) => { + if text.len() > MAX_TEXT_CHARS { + text.truncate(MAX_TEXT_CHARS); + } + Ok(Some(text)) + } + Err(err) => { + warn!(error = %err, "ocr text asset not valid utf-8"); + Ok(None) + } + }, + Err(err) => { + warn!(error = %err, "failed to download ocr text for issued_at extractor"); + Ok(None) + } + } +} + +#[derive(Copy, Clone)] +enum IssuedAtSource { + Parser, + Filename, + Text, +} + +impl IssuedAtSource { + fn as_ref(&self) -> &'static str { + match self { + IssuedAtSource::Parser => "parser", + IssuedAtSource::Filename => "filename", + IssuedAtSource::Text => "text", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + use serde::Deserialize; + use serde_yaml::Value as YamlValue; + + use once_cell::sync::Lazy; + + #[derive(Clone, Deserialize)] + struct CaseSuite { + cases: Vec, + } + + #[derive(Clone, Deserialize)] + struct CaseDefinition { + name: String, + parser: String, + #[serde(default)] + filename: Option, + #[serde(default)] + content: Option, + #[serde(default)] + settings: CaseSettings, + expected: ExpectedCase, + } + + #[derive(Clone, Default, Deserialize)] + struct CaseSettings { + #[serde(rename = "DATE_PARSER_LANGUAGES", default)] + date_parser_languages: Vec, + #[serde(rename = "FILENAME_DATE_ORDER")] + filename_date_order: Option, + #[serde(rename = "DATE_ORDER")] + date_order: Option, + #[serde(rename = "IGNORE_DATES", default)] + ignore_dates: Vec, + } + + #[derive(Clone, Deserialize)] + struct ExpectedCase { + mode: ExpectedMode, + #[serde(default)] + value: Option, + } + + #[derive(Clone, Deserialize, PartialEq)] + #[serde(rename_all = "lowercase")] + enum ExpectedMode { + None, + Single, + Multiple, + } + + static CASES: Lazy = Lazy::new(|| { + let raw = include_str!("../../tests/data/issued_at_cases.yaml"); + serde_yaml::from_str(raw).expect("failed to parse issued_at cases") + }); + + pub(crate) fn run_named_case(name: &str) { + let case = CASES + .cases + .iter() + .find(|case| case.name == name) + .unwrap_or_else(|| panic!("case '{}' not found", name)) + .clone(); + run_case(case); + } + + fn run_case(case: CaseDefinition) { + let mut config = base_config(); + if let Some(order) = case.settings.date_order { + config.issued_at_date_order = order; + } + config.issued_at_filename_date_order = case.settings.filename_date_order; + config.issued_at_date_parser_locales = case.settings.date_parser_languages; + config.issued_at_ignore_dates = case.settings.ignore_dates; + + let settings = IssuedAtSettings::from_config(&config); + let now_utc = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(); + let mut matches = Vec::new(); + + if let Some(filename) = &case.filename { + if let Some(order) = settings.filename_date_order() { + let normalized = normalize_content(filename, MAX_FILENAME_CHARS); + matches.extend( + collect_matches_with_spans(&normalized, order, &settings, now_utc) + .into_iter() + .map(|(_, dt)| dt.date_naive()), + ); + } + } + + if let Some(content) = &case.content { + let normalized = normalize_content(content, MAX_TEXT_CHARS); + matches.extend( + collect_matches_with_spans(&normalized, settings.date_order(), &settings, now_utc) + .into_iter() + .map(|(_, dt)| dt.date_naive()), + ); + } + + let actual: Vec = matches + .into_iter() + .map(|date| date.format("%Y-%m-%d").to_string()) + .collect(); + + match case.parser.as_str() { + "parse_date" => match case.expected.mode { + ExpectedMode::None => assert!( + actual.is_empty(), + "case '{}' expected no matches, got {:?}", + case.name, + actual + ), + ExpectedMode::Single => { + let expected = case.expected.single_value(); + assert!( + expected.is_some(), + "case '{}' is missing expected single value", + case.name + ); + assert_eq!( + actual.first(), + expected.as_ref(), + "case '{}' single mismatch", + case.name + ); + } + ExpectedMode::Multiple => panic!( + "case '{}' declares parse_date but expects multiple results", + case.name + ), + }, + "parse_date_generator" => { + let expected = case.expected.multiple_values().unwrap_or_default(); + assert_eq!(expected, actual, "case '{}' multiple mismatch", case.name); + } + other => panic!("unsupported parser '{}' in case {}", other, case.name), + } + } + + impl ExpectedCase { + fn single_value(&self) -> Option { + match self.value.as_ref()? { + YamlValue::String(value) => Some(value.clone()), + other => Some(other.as_str()?.to_string()), + } + } + + fn multiple_values(&self) -> Option> { + let list = match self.value.as_ref()? { + YamlValue::Sequence(seq) => seq, + _ => return None, + }; + Some( + list.iter() + .filter_map(|value| value.as_str().map(|s| s.to_string())) + .collect(), + ) + } + } + + fn base_config() -> AppConfig { + AppConfig { + database_url: "postgres://test".to_string(), + migrations_database_url: None, + database_max_pool_size: 5, + server_host: "127.0.0.1".to_string(), + server_port: 0, + webdav_host: "127.0.0.1".to_string(), + webdav_port: 0, + jwt_secret: "secret".to_string(), + jwt_issuer: "issuer".to_string(), + jwt_audience: "audience".to_string(), + jwt_expiry_minutes: 60, + download_token_audience: "download".to_string(), + download_token_expiry_minutes: 60, + refresh_token_expiry_days: 30, + refresh_cookie_secure: false, + refresh_cookie_domain: None, + cors_allowed_origin: None, + proxy_downloads: false, + aws_endpoint_url: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_region: "us-east-1".to_string(), + s3_bucket: "bucket".to_string(), + quickwit_endpoint: None, + quickwit_index: None, + worker_max_document_bytes: 100 * 1024 * 1024, + upload_body_limit_bytes: 64 * 1024 * 1024, + service_timezone: "UTC".to_string(), + issued_at_date_order: "DMY".to_string(), + issued_at_filename_date_order: None, + issued_at_date_parser_locales: Vec::new(), + issued_at_ignore_dates: Vec::new(), + webauthn_rp_id: Some("localhost".to_string()), + webauthn_origin: Some("http://localhost".to_string()), + webauthn_rp_name: "Papercrate".to_string(), + } + } + + include!(concat!(env!("OUT_DIR"), "/issued_at_generated_tests.rs")); + + #[test] + fn month_name_lookup_handles_turkish_variants() { + let settings = IssuedAtSettings::from_config(&base_config()); + assert_eq!(month_name_to_number("şubat", &settings), Some(2)); + assert_eq!(month_name_to_number("subat", &settings), Some(2)); + } + + #[test] + fn locale_filter_limits_month_names() { + let mut config = base_config(); + config.issued_at_date_parser_locales = vec!["tr".into()]; + let settings = IssuedAtSettings::from_config(&config); + assert_eq!(month_name_to_number("january", &settings), None); + assert_eq!(month_name_to_number("şubat", &settings), Some(2)); + } +} diff --git a/backend/src/workers/issued_at_months.rs b/backend/src/workers/issued_at_months.rs new file mode 100644 index 0000000..dc648a3 --- /dev/null +++ b/backend/src/workers/issued_at_months.rs @@ -0,0 +1,15 @@ +pub(super) struct MonthVariant { + pub name: &'static str, + pub month: u32, + pub locales: &'static [&'static str], +} + +include!(concat!(env!("OUT_DIR"), "/issued_at_months.rs")); + +pub(super) fn pattern() -> &'static str { + MONTH_PATTERN +} + +pub(super) fn variants() -> &'static [MonthVariant] { + MONTH_VARIANTS +} diff --git a/backend/src/workers/mod.rs b/backend/src/workers/mod.rs new file mode 100644 index 0000000..7ab5910 --- /dev/null +++ b/backend/src/workers/mod.rs @@ -0,0 +1,209 @@ +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use anyhow::Error as AnyhowError; +use async_trait::async_trait; +use tokio::time::sleep; +use tracing::{error, info, warn}; + +use crate::{ + jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError}, + models::Job, + state::AppState, + storage::TenantStorage, +}; +use taskflow::TaskError; + +pub mod analyze; +pub mod common; +pub mod index; +pub mod issued_at; +pub mod ocr; +pub mod purge; +pub mod taskflow; +pub mod tenants; +pub mod thumbnails; + +use tenants::{DeleteTenantJob, ProvisionTenantJob}; + +#[derive(Debug)] +pub enum JobExecution { + Success, + Retry { delay: Duration, error: String }, + Failed { error: String }, +} + +#[async_trait] +pub trait JobHandler: Send + Sync { + fn job_type(&self) -> &'static str; + async fn handle(&self, state: Arc, job: Job, storage: TenantStorage) -> JobExecution; +} + +pub struct Worker { + state: Arc, + handlers: HashMap<&'static str, Arc>, + poll_interval: Duration, +} + +impl Worker { + pub fn new( + state: Arc, + handlers: Vec>, + poll_interval: Duration, + ) -> Self { + let map = handlers + .into_iter() + .map(|handler| (handler.job_type(), handler)) + .collect(); + Self { + state, + handlers: map, + poll_interval, + } + } + + pub async fn run(&self) { + info!("worker started"); + loop { + match self.tick().await { + Ok(true) => {} + Ok(false) => sleep(self.poll_interval).await, + Err(err) => { + error!(error = %err, "worker tick failed"); + sleep(self.poll_interval).await; + } + } + } + } + + async fn tick(&self) -> Result { + let job_types: Vec<&str> = self.handlers.keys().copied().collect(); + if job_types.is_empty() { + return Ok(false); + } + + let mut conn = match self.state.db_unscoped() { + Ok(conn) => conn, + Err(err) => { + error!(?err, "failed to obtain database connection in worker"); + return Ok(false); + } + }; + + let job_opt = reserve_job(&mut conn, &job_types)?; + drop(conn); + + if let Some(job) = job_opt { + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + warn!(job_id = %job.id, job_type = %job.job_type, "job detached from tenant; marking failed"); + if let Ok(mut conn) = self.state.db_unscoped() { + let _ = + mark_job_failed(&mut conn, job.id, "job detached from tenant context"); + } + return Ok(true); + } + }; + + if let Some(handler) = self.handlers.get(job.job_type.as_str()) { + let execution = match self.state.storage_for_tenant(tenant_id) { + Ok(storage) => { + handler + .handle(self.state.clone(), job.clone(), storage) + .await + } + Err(err) => { + error!(job_id = %job.id, error = ?err, "failed to load tenant storage for job"); + JobExecution::Failed { + error: format!("tenant storage unavailable: {err:?}"), + } + } + }; + match execution { + JobExecution::Success => { + if let Ok(mut conn) = self.state.db_unscoped() { + mark_job_succeeded(&mut conn, job.id)?; + info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully"); + } else { + error!("failed to mark job succeeded due to pool error"); + } + } + JobExecution::Retry { delay, error } => { + warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry"); + if let Ok(mut conn) = self.state.db_unscoped() { + retry_job_after(&mut conn, job.id, delay, &error)?; + } else { + error!("failed to requeue job for retry due to pool error"); + } + } + JobExecution::Failed { error } => { + error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed"); + if let Ok(mut conn) = self.state.db_unscoped() { + mark_job_failed(&mut conn, job.id, &error)?; + } else { + error!("failed to mark job failed due to pool error"); + } + } + } + } else { + error!(job_type = %job.job_type, "no handler registered for job type"); + if let Ok(mut conn) = self.state.db_unscoped() { + mark_job_failed(&mut conn, job.id, "no handler registered")?; + } else { + error!("failed to mark job failed for missing handler due to pool error"); + } + } + Ok(true) + } else { + Ok(false) + } + } +} + +pub fn default_handlers() -> Vec> { + vec![ + Arc::new(analyze::AnalyzeDocumentJob::new()), + Arc::new(purge::PurgeDocumentJob::new()), + Arc::new(ProvisionTenantJob::new()), + Arc::new(DeleteTenantJob::new()), + ] +} + +pub(crate) fn job_execution_from_task_error(error: TaskError) -> JobExecution { + match error { + TaskError::Fail { error } => JobExecution::Failed { error }, + TaskError::Retry { delay, error } => JobExecution::Retry { delay, error }, + } +} + +pub(crate) fn check_worker_document_limit( + size_bytes: i64, + limit_bytes: u64, +) -> Result<(), (u64, u64)> { + let size = size_bytes.max(0) as u64; + if size > limit_bytes { + Err((size, limit_bytes)) + } else { + Ok(()) + } +} + +pub(crate) enum FetchVersionError { + TooLarge { size: u64, limit: u64 }, + Storage(AnyhowError), +} + +pub(crate) async fn fetch_version_object( + version: &crate::models::DocumentVersion, + storage: &TenantStorage, + s3_key: &str, + limit_bytes: u64, +) -> Result, FetchVersionError> { + check_worker_document_limit(version.size_bytes, limit_bytes) + .map_err(|(size, limit)| FetchVersionError::TooLarge { size, limit })?; + + storage + .get_object(s3_key) + .await + .map_err(FetchVersionError::Storage) +} diff --git a/backend/src/workers/ocr.rs b/backend/src/workers/ocr.rs new file mode 100644 index 0000000..d6dd8ff --- /dev/null +++ b/backend/src/workers/ocr.rs @@ -0,0 +1,424 @@ +use std::{ + fmt, fs, + io::{ErrorKind, Write}, + process::Command, + sync::Arc, + time::Duration, +}; + +use async_trait::async_trait; +use chrono::Utc; +use diesel::{pg::upsert::excluded, prelude::*}; +use pdfium_render::prelude::*; +use serde_json::json; +use tempfile::NamedTempFile; +use tokio::task; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::{ + documents::asset::delete_asset, + error::AppResult, + models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset}, + schema::document_assets, + state::AppState, + utils::storage_paths::document_asset_key, +}; + +use super::taskflow::{ + document::DocumentVersionTaskContext, Task, TaskContext, TaskError, TaskResult, +}; + +pub const TEXT_CONTENT_ASSET_TYPE: &str = "text-content"; +const MIN_TEXT_LENGTH: usize = 50; + +pub struct GenerateOcrTask { + force: bool, + state: Arc, +} + +impl GenerateOcrTask { + pub fn new(force: bool, state: Arc) -> Self { + Self { force, state } + } +} + +#[async_trait] +impl Task for GenerateOcrTask { + fn name(&self) -> &'static str { + "generate-ocr-text" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let context = build_ocr_context(ctx, self.force).await?; + + if context.skip { + info!(job_id = %ctx.job_id(), "ocr already present; skipping"); + return Ok(()); + } + + let bytes = ctx.buffered_object().await?.to_vec(); + let meta = PdfDocumentMeta { + mime_type: context.document.mime_type.clone(), + original_name: context.document.original_name.clone(), + }; + + let generation = task::spawn_blocking(move || generate_ocr_text(&meta, &bytes)) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("ocr text task panicked: {err}"), + ) + })?; + + let Some(generation) = generation else { + warn!(job_id = %ctx.job_id(), "no text extracted from document; failing job"); + return Err(TaskError::fail("no text extracted and OCR unavailable")); + }; + + remove_existing_ocr_asset(ctx, &context).await; + + let asset_id = Uuid::new_v4(); + let s3_key = document_asset_key( + context.document.id, + context.version.version_number, + TEXT_CONTENT_ASSET_TYPE, + asset_id, + ); + + ctx.storage() + .put_object( + &s3_key, + generation.text.into_bytes(), + Some("text/plain; charset=utf-8".into()), + None, + ) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?; + + let state = self.state.clone(); + task::spawn_blocking(move || { + persist_ocr_metadata(state, &context, asset_id, &s3_key, generation.source) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("ocr metadata task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + ctx.invalidate_asset_cache(); + + Ok(()) + } +} + +struct OcrContext { + document: Document, + version: DocumentVersion, + existing_asset: Option, + skip: bool, +} + +async fn build_ocr_context( + ctx: &mut DocumentVersionTaskContext, + force: bool, +) -> TaskResult { + let document = ctx.document().await?.clone(); + let version = ctx.version().await?.clone(); + let asset = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?; + + let existing_asset = asset.map(|asset| asset.asset.clone()); + + if !document_is_pdf(&document) { + return Ok(OcrContext { + document, + version, + existing_asset, + skip: true, + }); + } + + let skip = existing_asset.is_some() && !force; + + Ok(OcrContext { + document, + version, + existing_asset, + skip, + }) +} + +async fn remove_existing_ocr_asset(ctx: &DocumentVersionTaskContext, context: &OcrContext) { + if let Some(existing_asset) = &context.existing_asset { + if let Err(err) = ctx.storage().delete_object(&existing_asset.s3_key).await { + warn!( + job_id = %ctx.job_id(), + error = %err, + s3_key = %existing_asset.s3_key, + "failed to delete existing ocr asset object" + ); + } + + let tenant_id = context.document.tenant_id; + let asset_id = existing_asset.id; + let state = ctx.state().clone(); + match task::spawn_blocking(move || -> AppResult<()> { + let mut conn = state.db_for_tenant(tenant_id)?; + delete_asset(&mut conn, tenant_id, asset_id) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + warn!( + job_id = %ctx.job_id(), + error = ?err, + asset_id = %asset_id, + "failed to remove ocr asset metadata after deletion" + ); + } + Err(join_err) => { + warn!( + job_id = %ctx.job_id(), + error = %join_err, + asset_id = %asset_id, + "failed to remove ocr asset metadata: task panicked" + ); + } + } + } +} + +fn persist_ocr_metadata( + state: Arc, + context: &OcrContext, + asset_id: Uuid, + s3_key: &str, + source: OcrSource, +) -> Result<(), String> { + let tenant_id = context.document.tenant_id; + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + let document_version_id = context.version.id; + let existing_asset = context.existing_asset.as_ref().map(|asset| asset.id); + + if let Some(existing_asset) = existing_asset { + diesel::delete( + document_assets::table + .filter(document_assets::id.eq(existing_asset)) + .filter(document_assets::tenant_id.eq(tenant_id)), + ) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + } + + let metadata = json!({ + "source": source.to_string(), + "generated_at": Utc::now().to_rfc3339(), + }); + + let new_asset = NewDocumentAsset { + id: asset_id, + document_version_id, + asset_type: TEXT_CONTENT_ASSET_TYPE.to_string(), + mime_type: "text/plain".to_string(), + metadata, + s3_key: s3_key.to_string(), + tenant_id, + }; + + diesel::insert_into(document_assets::table) + .values(&new_asset) + .on_conflict(( + document_assets::document_version_id, + document_assets::asset_type, + )) + .do_update() + .set(( + document_assets::mime_type.eq(excluded(document_assets::mime_type)), + document_assets::metadata.eq(excluded(document_assets::metadata)), + document_assets::s3_key.eq(excluded(document_assets::s3_key)), + document_assets::id.eq(excluded(document_assets::id)), + )) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + Ok(()) +} + +fn generate_ocr_text(meta: &PdfDocumentMeta, bytes: &[u8]) -> Option { + if !document_meta_is_pdf(meta) { + return None; + } + + if let Ok(text) = extract_pdf_text(bytes) { + if text.trim().chars().count() >= MIN_TEXT_LENGTH { + return Some(OcrGeneration { + text, + source: OcrSource::PdfText, + }); + } + } + + match run_ocr(bytes) { + Ok(Some(text)) => Some(OcrGeneration { + text, + source: OcrSource::Ocr, + }), + Ok(None) => None, + Err(OcrError::BinaryMissing) => { + warn!("ocrmypdf binary not found; OCR unavailable"); + None + } + Err(err) => { + warn!(error = %err, "ocr command failed"); + None + } + } +} + +struct PdfDocumentMeta { + mime_type: Option, + original_name: String, +} + +struct OcrGeneration { + text: String, + source: OcrSource, +} + +#[derive(Clone, Copy)] +enum OcrSource { + PdfText, + Ocr, +} + +impl fmt::Display for OcrSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OcrSource::PdfText => write!(f, "pdf-text"), + OcrSource::Ocr => write!(f, "ocr"), + } + } +} + +#[derive(Debug)] +enum OcrError { + BinaryMissing, + Failed(String), +} + +impl fmt::Display for OcrError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OcrError::BinaryMissing => write!(f, "ocrmypdf binary not found"), + OcrError::Failed(msg) => write!(f, "ocr failed: {msg}"), + } + } +} + +fn extract_pdf_text(bytes: &[u8]) -> Result { + let pdfium = Pdfium::default(); + let document = pdfium + .load_pdf_from_byte_slice(bytes, None) + .map_err(|err| format!("load pdf: {err}"))?; + + let mut combined = String::new(); + let pages = document.pages(); + for page_index in 0..pages.len() { + let page = pages + .get(page_index) + .map_err(|err| format!("load page {page_index}: {err}"))?; + if let Ok(page_text) = page.text() { + for segment in page_text.segments().iter() { + combined.push_str(&segment.text()); + combined.push('\n'); + } + }; + } + + Ok(combined) +} + +fn run_ocr(bytes: &[u8]) -> Result, OcrError> { + let mut input = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?; + input + .write_all(bytes) + .map_err(|err| OcrError::Failed(err.to_string()))?; + input + .flush() + .map_err(|err| OcrError::Failed(err.to_string()))?; + + let output_pdf = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?; + let sidecar = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?; + + let status = Command::new("ocrmypdf") + .arg("--sidecar") + .arg(sidecar.path()) + .arg("--skip-text") + .arg(input.path()) + .arg(output_pdf.path()) + .output(); + + match status { + Ok(output) => { + if !output.status.success() { + return Err(OcrError::Failed(format!( + "ocrmypdf failed: exit={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ))); + } + + let text = fs::read_to_string(sidecar.path()) + .map_err(|err| OcrError::Failed(err.to_string()))?; + if text.trim().chars().count() >= MIN_TEXT_LENGTH { + Ok(Some(text)) + } else { + Ok(None) + } + } + Err(err) => { + if err.kind() == ErrorKind::NotFound { + Err(OcrError::BinaryMissing) + } else { + Err(OcrError::Failed(err.to_string())) + } + } + } +} + +fn document_meta_is_pdf(meta: &PdfDocumentMeta) -> bool { + if let Some(mime_type) = &meta.mime_type { + if mime_type.eq_ignore_ascii_case("application/pdf") { + return true; + } + } + + meta.original_name + .rsplit('.') + .next() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) +} + +fn document_is_pdf(document: &Document) -> bool { + document + .mime_type + .as_deref() + .map(|mime| mime.eq_ignore_ascii_case("application/pdf")) + .unwrap_or_else(|| { + document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) + }) +} diff --git a/backend/src/workers/purge.rs b/backend/src/workers/purge.rs new file mode 100644 index 0000000..4d4e305 --- /dev/null +++ b/backend/src/workers/purge.rs @@ -0,0 +1,298 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use diesel::prelude::*; +use diesel::result::Error as DieselError; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::ensure_active_tenant; +use crate::jobs::JOB_PURGE_DOCUMENT; +use crate::models::{Document, DocumentVersion}; +use crate::schema::{document_assets, document_versions}; +use crate::state::AppState; +use crate::storage::TenantStorage; + +use super::{ + job_execution_from_task_error, + taskflow::{BoxedTask, Task, TaskContext, TaskError, TaskExecutor, TaskPlanner, TaskResult}, + JobExecution, JobHandler, +}; + +#[derive(Debug, Deserialize)] +struct PurgeDocumentPayload { + document_id: Uuid, +} + +#[derive(Debug)] +struct PurgeContext { + document_id: Uuid, + version_keys: Vec, + asset_keys: Vec, +} + +pub struct PurgeDocumentJob; + +impl PurgeDocumentJob { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl JobHandler for PurgeDocumentJob { + fn job_type(&self) -> &'static str { + JOB_PURGE_DOCUMENT + } + + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: TenantStorage, + ) -> JobExecution { + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + return JobExecution::Failed { + error: "job is no longer associated with a tenant".to_string(), + } + } + }; + + if let Err(err) = ensure_active_tenant(&state, tenant_id) { + return JobExecution::Failed { + error: err.to_string(), + }; + } + + let payload: PurgeDocumentPayload = match serde_json::from_value(job.payload.clone()) { + Ok(payload) => payload, + Err(err) => { + return JobExecution::Failed { + error: format!("invalid purge payload: {err}"), + }; + } + }; + + let mut context = PurgeTaskContext::new( + job.id, + JOB_PURGE_DOCUMENT, + tenant_id, + payload.document_id, + state.clone(), + storage, + ); + + let planner = PurgePlanner; + match TaskExecutor::run(&planner, &mut context).await { + Ok(()) => JobExecution::Success, + Err(err) => job_execution_from_task_error(err), + } + } +} + +struct PurgeTaskContext { + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + document_id: Uuid, + state: Arc, + storage: TenantStorage, +} + +impl PurgeTaskContext { + fn new( + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + document_id: Uuid, + state: Arc, + storage: TenantStorage, + ) -> Self { + Self { + job_id, + job_type, + tenant_id, + document_id, + state, + storage, + } + } +} + +impl TaskContext for PurgeTaskContext { + fn job_id(&self) -> Uuid { + self.job_id + } + + fn job_type(&self) -> &'static str { + self.job_type + } +} + +struct PurgePlanner; + +#[async_trait] +impl TaskPlanner for PurgePlanner { + async fn plan( + &self, + _ctx: &mut PurgeTaskContext, + ) -> TaskResult>> { + Ok(vec![Box::new(PurgeTask)]) + } +} + +struct PurgeTask; + +#[async_trait] +impl Task for PurgeTask { + fn name(&self) -> &'static str { + "purge-document" + } + + async fn execute(&self, ctx: &mut PurgeTaskContext) -> TaskResult<()> { + let tenant_id = ctx.tenant_id; + let document_id = ctx.document_id; + let state = ctx.state.clone(); + + let preparation = tokio::task::spawn_blocking(move || { + prepare_purge_context(state, tenant_id, document_id) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("purge preparation panicked: {err}"), + ) + })?; + + let Some(context) = + preparation.map_err(|err| TaskError::retry(Duration::from_secs(30), err))? + else { + return Ok(()); + }; + + delete_storage_objects(&ctx.storage, &context) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + let state = ctx.state.clone(); + tokio::task::spawn_blocking(move || finalize_purge(state, tenant_id, context.document_id)) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("purge finalize panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + Ok(()) + } +} + +fn prepare_purge_context( + state: Arc, + tenant_id: Uuid, + document_id: Uuid, +) -> Result, String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + + conn.transaction(|conn| { + use crate::schema::documents::dsl as doc_dsl; + + let doc_opt = doc_dsl::documents + .filter(doc_dsl::tenant_id.eq(tenant_id)) + .find(document_id) + .for_update() + .first::(conn) + .optional()?; + + let Some(document) = doc_opt else { + return Ok(None); + }; + + if document.deleted_at.is_none() { + return Ok(None); + } + + let versions: Vec = document_versions::table + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .load(conn)?; + + let version_keys: Vec = versions + .iter() + .map(|version| version.s3_key.clone()) + .collect(); + let version_ids: Vec = versions.iter().map(|version| version.id).collect(); + + let asset_keys = if version_ids.is_empty() { + Vec::new() + } else { + document_assets::table + .filter(document_assets::document_version_id.eq_any(&version_ids)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .select(document_assets::s3_key) + .load(conn)? + }; + + Ok(Some(PurgeContext { + document_id, + version_keys, + asset_keys, + })) + }) + .map_err(|err: DieselError| format!("failed to prepare purge: {err}")) +} + +async fn delete_storage_objects( + storage: &TenantStorage, + context: &PurgeContext, +) -> Result<(), String> { + let mut keys = HashSet::new(); + keys.extend(context.version_keys.iter().cloned()); + keys.extend(context.asset_keys.iter().cloned()); + + for key in keys { + if let Err(err) = storage.delete_object(&key).await { + return Err(format!("failed to delete object {}: {err:?}", key)); + } + } + + Ok(()) +} + +fn finalize_purge(state: Arc, tenant_id: Uuid, document_id: Uuid) -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + + conn.transaction(|conn| { + use crate::schema::documents::dsl as doc_dsl; + + let doc_opt = doc_dsl::documents + .filter(doc_dsl::tenant_id.eq(tenant_id)) + .find(document_id) + .for_update() + .first::(conn) + .optional()?; + + let Some(document) = doc_opt else { + return Ok(()); + }; + + if document.deleted_at.is_none() { + return Ok(()); + } + + diesel::delete(doc_dsl::documents.filter(doc_dsl::id.eq(document_id))).execute(conn)?; + Ok(()) + }) + .map_err(|err: DieselError| format!("failed to finalize purge: {err}")) +} diff --git a/backend/src/workers/taskflow/document.rs b/backend/src/workers/taskflow/document.rs new file mode 100644 index 0000000..54f76ca --- /dev/null +++ b/backend/src/workers/taskflow/document.rs @@ -0,0 +1,225 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use tokio::task; +use uuid::Uuid; + +use crate::models::{Document, DocumentVersion}; +use crate::state::AppState; +use crate::storage::TenantStorage; +use crate::workers::common::{load_document_version, load_version_assets, LoadedAsset}; +use crate::workers::{check_worker_document_limit, fetch_version_object, FetchVersionError}; + +use super::{TaskContext, TaskError, TaskResult}; + +const BLOCKING_RETRY_DELAY: Duration = Duration::from_secs(60); +const DEFAULT_RETRY_DELAY: Duration = Duration::from_secs(30); + +pub struct DocumentVersionTaskContext { + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + document_id: Uuid, + document_version_id: Uuid, + force: bool, + max_document_bytes: u64, + state: Arc, + storage: TenantStorage, + document: Option, + version: Option, + assets: Option>, // keyed by asset_type + object_bytes: Option>, +} + +impl DocumentVersionTaskContext { + #[allow(clippy::too_many_arguments)] + pub fn new( + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + document_id: Uuid, + document_version_id: Uuid, + force: bool, + max_document_bytes: u64, + state: Arc, + storage: TenantStorage, + ) -> Self { + Self { + job_id, + job_type, + tenant_id, + document_id, + document_version_id, + force, + max_document_bytes, + state, + storage, + document: None, + version: None, + assets: None, + object_bytes: None, + } + } + + pub fn tenant_id(&self) -> Uuid { + self.tenant_id + } + + pub fn document_id(&self) -> Uuid { + self.document_id + } + + pub fn version_id(&self) -> Uuid { + self.document_version_id + } + + pub fn force(&self) -> bool { + self.force + } + + pub fn storage(&self) -> &TenantStorage { + &self.storage + } + + pub fn invalidate_asset_cache(&mut self) { + self.assets = None; + } + + pub fn set_document_mime(&mut self, mime: Option) { + if let Some(document) = self.document.as_mut() { + document.mime_type = mime; + } + } + + pub fn state(&self) -> &Arc { + &self.state + } + + pub fn max_document_bytes(&self) -> u64 { + self.max_document_bytes + } + + pub async fn document(&mut self) -> TaskResult<&Document> { + self.ensure_document_loaded().await?; + Ok(self.document.as_ref().expect("document hydrated")) + } + + pub async fn version(&mut self) -> TaskResult<&DocumentVersion> { + self.ensure_document_loaded().await?; + Ok(self.version.as_ref().expect("version hydrated")) + } + + pub async fn assets(&mut self) -> TaskResult<&HashMap> { + if self.assets.is_none() { + let tenant_id = self.tenant_id; + let version_id = self.document_version_id; + let state = self.state.clone(); + let result = task::spawn_blocking(move || { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + load_version_assets(&mut conn, tenant_id, version_id, &[]) + }) + .await + .map_err(|err| { + TaskError::retry( + BLOCKING_RETRY_DELAY, + format!("asset load task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(DEFAULT_RETRY_DELAY, err))?; + self.assets = Some(result); + } + Ok(self.assets.as_ref().expect("asset map hydrated")) + } + + pub async fn asset(&mut self, asset_type: &str) -> TaskResult> { + let assets = self.assets().await?; + Ok(assets.get(asset_type)) + } + + pub async fn buffered_object(&mut self) -> TaskResult<&[u8]> { + if self.object_bytes.is_some() { + return Ok(self.object_bytes.as_deref().expect("bytes present")); + } + let version = self.version().await?.clone(); + let bytes = fetch_version_object( + &version, + &self.storage, + &version.s3_key, + self.max_document_bytes, + ) + .await + .map_err(|err| match err { + FetchVersionError::TooLarge { size, limit } => TaskError::fail(format!( + "document size {size} bytes exceeds worker limit of {limit} bytes" + )), + FetchVersionError::Storage(err) => TaskError::retry( + DEFAULT_RETRY_DELAY, + format!("failed to fetch object: {err}"), + ), + })?; + self.object_bytes = Some(bytes); + Ok(self.object_bytes.as_deref().expect("bytes hydrated")) + } + + pub async fn object_head(&mut self, max_bytes: usize) -> TaskResult> { + let version = self.version().await?.clone(); + check_worker_document_limit(version.size_bytes, self.max_document_bytes).map_err( + |(size, limit)| { + TaskError::fail(format!( + "document size {size} bytes exceeds worker limit of {limit} bytes" + )) + }, + )?; + + let end = max_bytes.saturating_sub(1) as u64; + self.storage + .get_object_range(&version.s3_key, 0, Some(end)) + .await + .map_err(|err| { + TaskError::retry( + DEFAULT_RETRY_DELAY, + format!("failed to fetch ranged object: {err}"), + ) + }) + } + + async fn ensure_document_loaded(&mut self) -> TaskResult<()> { + if self.document.is_some() && self.version.is_some() { + return Ok(()); + } + + let tenant_id = self.tenant_id; + let document_id = self.document_id; + let version_id = self.document_version_id; + let state = self.state.clone(); + + let loaded = task::spawn_blocking(move || { + load_document_version(state.as_ref(), tenant_id, document_id, version_id) + }) + .await + .map_err(|err| { + TaskError::retry( + BLOCKING_RETRY_DELAY, + format!("document load task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::fail(format!("failed to load document context: {err}")))?; + + self.document = Some(loaded.document); + self.version = Some(loaded.version); + Ok(()) + } +} + +impl TaskContext for DocumentVersionTaskContext { + fn job_id(&self) -> Uuid { + self.job_id + } + + fn job_type(&self) -> &'static str { + self.job_type + } +} diff --git a/backend/src/workers/taskflow/mod.rs b/backend/src/workers/taskflow/mod.rs new file mode 100644 index 0000000..9fc3831 --- /dev/null +++ b/backend/src/workers/taskflow/mod.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use async_trait::async_trait; +use thiserror::Error; +use tracing::info; +use uuid::Uuid; + +pub mod document; + +pub type TaskResult = Result; + +#[derive(Debug, Error)] +pub enum TaskError { + #[error("{error}")] + Fail { error: String }, + #[error("{error}")] + Retry { delay: Duration, error: String }, +} + +impl TaskError { + pub fn fail(error: impl Into) -> Self { + Self::Fail { + error: error.into(), + } + } + + pub fn retry(delay: Duration, error: impl Into) -> Self { + Self::Retry { + delay, + error: error.into(), + } + } +} + +pub trait TaskContext: Send + Sync { + fn job_id(&self) -> Uuid; + fn job_type(&self) -> &'static str; +} + +#[async_trait] +pub trait Task: Send + Sync +where + Ctx: TaskContext, +{ + fn name(&self) -> &'static str; + async fn execute(&self, ctx: &mut Ctx) -> TaskResult<()>; +} + +pub type BoxedTask = Box + Send + Sync>; + +#[async_trait] +pub trait TaskPlanner: Send + Sync +where + Ctx: TaskContext, +{ + async fn plan(&self, ctx: &mut Ctx) -> TaskResult>>; +} + +pub struct TaskExecutor; + +impl TaskExecutor { + pub async fn run(planner: &P, ctx: &mut C) -> TaskResult<()> + where + P: TaskPlanner, + C: TaskContext, + { + let tasks = planner.plan(ctx).await?; + for task in tasks { + info!( + job_id = %ctx.job_id(), + job_type = ctx.job_type(), + task = task.name(), + "starting job task" + ); + task.execute(ctx).await?; + info!( + job_id = %ctx.job_id(), + job_type = ctx.job_type(), + task = task.name(), + "finished job task" + ); + } + Ok(()) + } +} diff --git a/backend/src/workers/tenants.rs b/backend/src/workers/tenants.rs new file mode 100644 index 0000000..2a235f9 --- /dev/null +++ b/backend/src/workers/tenants.rs @@ -0,0 +1,640 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel::sql_types::Jsonb; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use serde::Deserialize; +use serde_json::json; +use tracing::warn; +use uuid::Uuid; + +use sha2::Sha256; + +use crate::auth::capability_sets::{ + ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities, + webdav_capabilities, +}; +use crate::documents::search::{delete_quickwit_index, ensure_quickwit_index}; +use crate::jobs::{JOB_DELETE_TENANT, JOB_PROVISION_TENANT}; +use crate::models::{NewUserMembership, Tenant, TenantStatus}; +use crate::schema::{ + api_tokens, correspondents, document_assets, document_correspondents, document_tags, + document_versions, documents, folders, tags, tenants, user_memberships, user_sessions, +}; +use crate::state::AppState; +use crate::tenants::TenantRepository; +use crate::workers::{ + job_execution_from_task_error, + taskflow::{BoxedTask, Task, TaskContext, TaskError, TaskExecutor, TaskPlanner, TaskResult}, + JobExecution, JobHandler, +}; + +type HmacSha256 = Hmac; +const DELETE_PROOF_TTL_SECONDS: i64 = 300; +const DELETE_PROOF_VERSION: &str = "v1"; + +pub struct ProvisionTenantJob; + +impl ProvisionTenantJob { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl JobHandler for ProvisionTenantJob { + fn job_type(&self) -> &'static str { + JOB_PROVISION_TENANT + } + + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + _storage: crate::storage::TenantStorage, + ) -> JobExecution { + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + return JobExecution::Failed { + error: "provision job is missing tenant context".to_string(), + } + } + }; + + let members = ProvisionPayload::from_job(&job).unwrap_or_default(); + let mut context = ProvisionContext::new( + job.id, + JOB_PROVISION_TENANT, + tenant_id, + state.clone(), + members, + ); + + let planner = ProvisionPlanner; + match TaskExecutor::run(&planner, &mut context).await { + Ok(()) => JobExecution::Success, + Err(err) => job_execution_from_task_error(err), + } + } +} + +struct ProvisionContext { + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + state: Arc, + members: Vec, +} + +impl ProvisionContext { + fn new( + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + state: Arc, + members: Vec, + ) -> Self { + Self { + job_id, + job_type, + tenant_id, + state, + members, + } + } +} + +impl TaskContext for ProvisionContext { + fn job_id(&self) -> Uuid { + self.job_id + } + + fn job_type(&self) -> &'static str { + self.job_type + } +} + +struct ProvisionPlanner; + +#[async_trait] +impl TaskPlanner for ProvisionPlanner { + async fn plan( + &self, + _ctx: &mut ProvisionContext, + ) -> TaskResult>> { + Ok(vec![Box::new(ProvisionTask)]) + } +} + +struct ProvisionTask; + +#[async_trait] +impl Task for ProvisionTask { + fn name(&self) -> &'static str { + "provision-tenant" + } + + async fn execute(&self, ctx: &mut ProvisionContext) -> TaskResult<()> { + let mut conn = ctx + .state + .db_unscoped() + .map_err(|err| TaskError::retry(Duration::from_secs(30), format!("{err:?}")))?; + + let tenant = TenantRepository::get_by_id(&mut conn, ctx.tenant_id).map_err(|err| { + TaskError::fail(format!("tenant not found for provisioning: {err:?}")) + })?; + drop(conn); + + let mut conn = ctx + .state + .db_for_tenant(tenant.id) + .map_err(|err| TaskError::retry(Duration::from_secs(30), format!("{err:?}")))?; + + if tenant.status == TenantStatus::Active { + warn!( + job_id = %ctx.job_id(), + tenant_id = %tenant.id, + "tenant already active; skipping provisioning" + ); + return Ok(()); + } + + if tenant.status != TenantStatus::Creating { + return Err(TaskError::fail(format!( + "tenant status '{}' not eligible for provisioning", + tenant.status.as_str() + ))); + } + + let endpoint = ctx + .state + .config + .quickwit_endpoint + .as_ref() + .map(|value| value.trim_end_matches('/').to_owned()) + .ok_or_else(|| { + TaskError::retry(Duration::from_secs(30), "quickwit endpoint not configured") + })?; + + let index_id = tenant + .quickwit_index + .as_deref() + .map(str::to_owned) + .unwrap_or_else(|| format!("documents-{}", tenant.id)); + + let client = Client::new(); + ensure_quickwit_index(&client, &endpoint, &index_id) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?; + + let owner_capability_set_id = + ensure_capability_set(&mut conn, tenant.id, owner_capabilities()) + .map_err(|_| { + TaskError::retry(Duration::from_secs(30), "owner capability set unavailable") + })? + .id; + + ensure_capability_set(&mut conn, tenant.id, user_capabilities()).map_err(|_| { + TaskError::retry(Duration::from_secs(30), "user capability set unavailable") + })?; + ensure_capability_set(&mut conn, tenant.id, readonly_capabilities()).map_err(|_| { + TaskError::retry( + Duration::from_secs(30), + "readonly capability set unavailable", + ) + })?; + ensure_capability_set(&mut conn, tenant.id, webdav_capabilities()).map_err(|_| { + TaskError::retry(Duration::from_secs(30), "webdav capability set unavailable") + })?; + + for member in &ctx.members { + let new_membership = NewUserMembership { + id: Uuid::new_v4(), + user_id: *member, + tenant_id: tenant.id, + capability_set_id: Some(owner_capability_set_id), + }; + + if let Err(err) = diesel::insert_into(user_memberships::table) + .values(&new_membership) + .on_conflict((user_memberships::user_id, user_memberships::tenant_id)) + .do_nothing() + .execute(&mut conn) + { + warn!( + job_id = %ctx.job_id(), + tenant_id = %tenant.id, + user_id = %member, + error = %err, + "failed to assign initial membership" + ); + } + } + + diesel::update(tenants::table.find(tenant.id)) + .set(( + tenants::status.eq(TenantStatus::Active), + tenants::quickwit_index.eq(Some(index_id)), + tenants::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(&mut conn) + .map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("failed to update tenant status: {err}"), + ) + })?; + + Ok(()) + } +} + +async fn delete_tenant( + state: &Arc, + storage: crate::storage::TenantStorage, + tenant_id: Uuid, + current_job_id: Uuid, + payload: &DeleteTenantPayload, +) -> Result<(), String> { + let remove_tenant = payload.remove_tenant; + let tenant = { + let mut conn = state + .db_unscoped() + .map_err(|err| format!("failed to get db connection: {err:?}"))?; + TenantRepository::get_by_id(&mut conn, tenant_id) + .map_err(|err| format!("tenant lookup failed: {err:?}"))? + }; + + if tenant.name != payload.tenant_name { + return Err(format!( + "tenant name mismatch: expected '{}', got '{}'", + payload.tenant_name, tenant.name + )); + } + + if tenant.status != TenantStatus::Deleting { + return Err(format!( + "tenant status '{}' not eligible for deletion", + tenant.status.as_str() + )); + } + + match (payload_action_applicable(remove_tenant), payload.action) { + (DeleteAction::Delete, DeleteAction::Delete) + | (DeleteAction::Reset, DeleteAction::Reset) => {} + _ => { + return Err("delete payload action mismatch".into()); + } + } + + let issued_at = DateTime::parse_from_rfc3339(&payload.issued_at) + .map_err(|_| "invalid issued_at timestamp".to_string())? + .with_timezone(&Utc); + if (Utc::now() - issued_at).num_seconds().abs() > DELETE_PROOF_TTL_SECONDS { + return Err("delete confirmation expired".into()); + } + + let resolved_final_status = if remove_tenant { + None + } else { + Some(payload.final_status.unwrap_or(FinalTenantStatus::Suspended)) + }; + let final_status_str = resolved_final_status.map(|s| s.as_str()); + + let message = build_delete_proof_message( + tenant_id, + &tenant.name, + payload.action, + &payload.nonce, + &payload.issued_at, + final_status_str, + ); + + verify_delete_proof(&state.config.jwt_secret, &message, &payload.signature)?; + + let object_keys = { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + collect_object_keys(&mut conn) + .map_err(|err| format!("failed to collect storage keys: {err}"))? + }; + + delete_storage_objects(&storage, &object_keys) + .await + .map_err(|err| format!("failed to delete storage objects: {err}"))?; + + reset_quickwit_index(state, &tenant, remove_tenant) + .await + .map_err(|err| format!("quickwit cleanup failed: {err}"))?; + + { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + delete_tenant_rows(&mut conn, tenant_id, remove_tenant) + .map_err(|err| format!("tenant data cleanup failed: {err}"))?; + } + + { + let mut conn = state + .db_unscoped() + .map_err(|err| format!("failed to get db connection: {err:?}"))?; + + let detach_result = json!({ + "tenant": { + "id": tenant.id, + "name": tenant.name, + }, + "action": payload.action.as_str(), + "remove_tenant": remove_tenant, + "final_status": final_status_str, + "timestamp": Utc::now().to_rfc3339(), + }); + + diesel::sql_query( + "UPDATE jobs \ + SET tenant_id = NULL, \ + result = jsonb_set(COALESCE(result, '{}'::jsonb), '{detached_tenant}', $3::jsonb, true) \ + WHERE tenant_id = $1 AND id <> $2", + ) + .bind::(tenant_id) + .bind::(current_job_id) + .bind::(detach_result) + .execute(&mut conn) + .map_err(|err| format!("failed to detach tenant jobs: {err}"))?; + + if remove_tenant { + diesel::delete(tenants::table.find(tenant_id)) + .execute(&mut conn) + .map_err(|err| format!("failed to delete tenant row: {err}"))?; + } else { + let new_status = match resolved_final_status.unwrap_or(FinalTenantStatus::Suspended) { + FinalTenantStatus::Active => TenantStatus::Active, + FinalTenantStatus::Suspended => TenantStatus::Suspended, + }; + diesel::update(tenants::table.find(tenant_id)) + .set(( + tenants::status.eq(new_status), + tenants::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(&mut conn) + .map_err(|err| format!("failed to update tenant status: {err}"))?; + } + } + + Ok(()) +} + +struct TenantObjectKeys { + version_keys: Vec, + asset_keys: Vec, +} + +fn collect_object_keys(conn: &mut PgConnection) -> Result { + let version_keys = document_versions::table + .select(document_versions::s3_key) + .load::(conn)?; + let asset_keys = document_assets::table + .select(document_assets::s3_key) + .load::(conn)?; + + Ok(TenantObjectKeys { + version_keys, + asset_keys, + }) +} + +async fn delete_storage_objects( + storage: &crate::storage::TenantStorage, + keys: &TenantObjectKeys, +) -> Result<(), String> { + for key in keys.version_keys.iter().chain(keys.asset_keys.iter()) { + storage + .delete_object(key) + .await + .map_err(|err| format!("failed to delete object '{key}': {err}"))?; + } + Ok(()) +} + +async fn reset_quickwit_index( + state: &Arc, + tenant: &Tenant, + remove_index: bool, +) -> Result<(), String> { + let endpoint = match state.config.quickwit_endpoint.as_ref() { + Some(endpoint) => endpoint, + None => return Ok(()), + }; + + let index_id = match tenant.quickwit_index.as_deref() { + Some(index) => index, + None => return Ok(()), + }; + + let client = Client::new(); + delete_quickwit_index(&client, endpoint, index_id) + .await + .map_err(|err| format!("quickwit delete failed: {err}"))?; + + if !remove_index { + ensure_quickwit_index(&client, endpoint, index_id) + .await + .map_err(|err| format!("quickwit ensure failed: {err}"))?; + } + + Ok(()) +} + +fn delete_tenant_rows( + conn: &mut PgConnection, + tenant_id: Uuid, + remove_memberships: bool, +) -> Result<(), diesel::result::Error> { + conn.transaction(|conn| { + diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete( + document_correspondents::table.filter(document_correspondents::tenant_id.eq(tenant_id)), + ) + .execute(conn)?; + diesel::delete(document_tags::table.filter(document_tags::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(document_versions::table.filter(document_versions::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(documents::table.filter(documents::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(folders::table.filter(folders::tenant_id.eq(tenant_id))).execute(conn)?; + diesel::delete(correspondents::table.filter(correspondents::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(tags::table.filter(tags::tenant_id.eq(tenant_id))).execute(conn)?; + diesel::delete(user_sessions::table.filter(user_sessions::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(api_tokens::table.filter(api_tokens::tenant_id.eq(tenant_id))) + .execute(conn)?; + if remove_memberships { + diesel::delete( + user_memberships::table.filter(user_memberships::tenant_id.eq(tenant_id)), + ) + .execute(conn)?; + } + Ok(()) + }) +} + +#[derive(Deserialize, Default)] +struct ProvisionPayload { + #[serde(default)] + members: Vec, +} + +impl ProvisionPayload { + fn from_job(job: &crate::models::Job) -> Option> { + serde_json::from_value(job.payload.clone()) + .map(|payload: ProvisionPayload| payload.members) + .ok() + } +} +pub struct DeleteTenantJob; + +impl DeleteTenantJob { + pub fn new() -> Self { + Self + } +} + +pub fn build_delete_proof_message( + tenant_id: Uuid, + tenant_name: &str, + action: DeleteAction, + nonce: &str, + issued_at: &str, + final_status: Option<&str>, +) -> String { + let status = final_status.unwrap_or("none"); + format!( + "{}|{}|{}|{}|{}|{}|{}", + DELETE_PROOF_VERSION, + tenant_id, + tenant_name, + action.as_str(), + nonce, + issued_at, + status + ) +} + +pub fn sign_delete_proof(secret: &str, message: &str) -> Result { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|err| format!("failed to init hmac: {err}"))?; + mac.update(message.as_bytes()); + let bytes = mac.finalize().into_bytes(); + Ok(hex::encode(bytes)) +} + +fn verify_delete_proof(secret: &str, message: &str, signature: &str) -> Result<(), String> { + let signature_bytes = hex::decode(signature) + .map_err(|_| "invalid delete proof signature encoding".to_string())?; + + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|err| format!("failed to init hmac: {err}"))?; + mac.update(message.as_bytes()); + mac.verify_slice(&signature_bytes) + .map_err(|_| "delete proof signature mismatch".to_string()) +} + +#[derive(Debug, Deserialize)] +struct DeleteTenantPayload { + #[serde(default)] + remove_tenant: bool, + #[serde(default)] + final_status: Option, + tenant_name: String, + action: DeleteAction, + nonce: String, + issued_at: String, + signature: String, +} + +#[derive(Debug, Deserialize, Clone, Copy)] +#[serde(rename_all = "lowercase")] +enum FinalTenantStatus { + Active, + Suspended, +} + +impl FinalTenantStatus { + fn as_str(&self) -> &'static str { + match self { + FinalTenantStatus::Active => "active", + FinalTenantStatus::Suspended => "suspended", + } + } +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DeleteAction { + Delete, + Reset, +} + +impl DeleteAction { + pub fn as_str(&self) -> &'static str { + match self { + DeleteAction::Delete => "delete", + DeleteAction::Reset => "reset", + } + } +} + +fn payload_action_applicable(remove_tenant: bool) -> DeleteAction { + if remove_tenant { + DeleteAction::Delete + } else { + DeleteAction::Reset + } +} + +#[async_trait] +impl JobHandler for DeleteTenantJob { + fn job_type(&self) -> &'static str { + JOB_DELETE_TENANT + } + + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: crate::storage::TenantStorage, + ) -> JobExecution { + let payload: DeleteTenantPayload = match serde_json::from_value(job.payload.clone()) { + Ok(payload) => payload, + Err(err) => { + return JobExecution::Failed { + error: format!("invalid delete tenant payload: {err}"), + } + } + }; + + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + return JobExecution::Failed { + error: "delete job is missing tenant context".to_string(), + } + } + }; + + match delete_tenant(&state, storage, tenant_id, job.id, &payload).await { + Ok(()) => JobExecution::Success, + Err(err) => JobExecution::Failed { error: err }, + } + } +} diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs new file mode 100644 index 0000000..1c053e2 --- /dev/null +++ b/backend/src/workers/thumbnails.rs @@ -0,0 +1,647 @@ +use std::{convert::TryInto, io::Cursor, panic, process::Stdio, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use chrono::Utc; +use diesel::{pg::upsert::excluded, prelude::*}; +use image::{GenericImageView, ImageFormat, ImageReader}; +use pdfium_render::prelude::*; +use serde::Deserialize; +use serde_json::{Map, Value}; +use tokio::{process::Command, task, time::timeout}; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::{ + documents::asset::delete_asset, + error::AppResult, + models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset}, + schema::{document_assets, document_versions}, + state::AppState, + utils::storage_paths::document_asset_key, + workers::check_worker_document_limit, +}; + +use super::{ + analyze::determine_thumbnail_support, + taskflow::{document::DocumentVersionTaskContext, Task, TaskContext, TaskError, TaskResult}, +}; + +pub const THUMBNAIL_WIDTH: u32 = 512; +pub const THUMBNAIL_HEIGHT: u32 = 512; +const RENDER_WIDTH: u32 = THUMBNAIL_WIDTH * 4; +const RENDER_HEIGHT: u32 = THUMBNAIL_HEIGHT * 4; +const VIDEO_PRESIGN_TTL: Duration = Duration::from_secs(300); +const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30); +pub const THUMBNAIL_ASSET_TYPE: &str = "thumbnail"; + +pub struct GenerateThumbnailsTask { + force: bool, +} + +impl GenerateThumbnailsTask { + pub fn new(force: bool) -> Self { + Self { force } + } +} + +#[async_trait] +impl Task for GenerateThumbnailsTask { + fn name(&self) -> &'static str { + "generate-thumbnails" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let context = build_thumbnail_context(ctx, self.force).await?; + + if context.skip { + info!(job_id = %ctx.job_id(), "thumbnails already exist; skipping"); + return Ok(()); + } + + let generation = if document_is_video(&context.document) { + generate_video_thumbnail(ctx, &context).await? + } else { + let bytes = ctx.buffered_object().await?; + generate_thumbnails(&context.document, bytes).map_err(TaskError::fail)? + }; + + if let Some(page_count) = generation.page_count { + let state = ctx.state().clone(); + let tenant_id = context.tenant_id; + let document_id = context.document.id; + let version_id = context.version.id; + task::spawn_blocking(move || { + persist_document_page_count(state, tenant_id, document_id, version_id, page_count) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("page count task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + } + + remove_existing_thumbnail_assets(ctx, &context).await; + + let thumbnail_asset_id = Uuid::new_v4(); + + let thumbnail_persistence = upload_generated_asset( + ctx, + &context, + THUMBNAIL_ASSET_TYPE, + thumbnail_asset_id, + &generation.thumbnail, + ) + .await?; + + let asset_persistences = vec![thumbnail_persistence]; + + let state = ctx.state().clone(); + let tenant_id = context.document.tenant_id; + let version_id = context.version.id; + task::spawn_blocking(move || { + persist_assets_metadata(state, tenant_id, version_id, &asset_persistences) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("thumbnail metadata task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + ctx.invalidate_asset_cache(); + Ok(()) + } +} + +async fn build_thumbnail_context( + ctx: &mut DocumentVersionTaskContext, + force: bool, +) -> TaskResult { + let document = ctx.document().await?.clone(); + let version = ctx.version().await?.clone(); + let tenant_id = ctx.tenant_id(); + + let (supported, _) = determine_thumbnail_support(&document); + if !supported { + return Ok(ThumbnailContext { + document, + version, + existing_thumbnail: None, + skip: true, + tenant_id, + }); + } + + let assets = ctx.assets().await?; + let existing_thumbnail = assets + .get(THUMBNAIL_ASSET_TYPE) + .map(|entry| entry.asset.clone()); + + let skip = existing_thumbnail.is_some() && !force; + + Ok(ThumbnailContext { + document, + version, + existing_thumbnail, + skip, + tenant_id, + }) +} + +async fn remove_existing_thumbnail_assets( + ctx: &DocumentVersionTaskContext, + context: &ThumbnailContext, +) { + if let Some(existing_thumbnail) = &context.existing_thumbnail { + delete_asset_object(ctx, existing_thumbnail).await; + } +} + +async fn delete_asset_object(ctx: &DocumentVersionTaskContext, asset: &DocumentAsset) { + if let Err(err) = ctx.storage().delete_object(&asset.s3_key).await { + warn!( + job_id = %ctx.job_id(), + error = %err, + s3_key = %asset.s3_key, + "failed to delete existing asset object" + ); + } + + let tenant_id = ctx.tenant_id(); + let asset_id = asset.id; + let state = ctx.state().clone(); + match task::spawn_blocking(move || -> AppResult<()> { + let mut conn = state.db_for_tenant(tenant_id)?; + delete_asset(&mut conn, tenant_id, asset_id) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + warn!( + job_id = %ctx.job_id(), + asset_id = %asset_id, + error = ?err, + "failed to delete asset metadata" + ); + } + Err(join_err) => { + warn!( + job_id = %ctx.job_id(), + asset_id = %asset_id, + error = %join_err, + "failed to delete asset metadata task panicked" + ); + } + } +} + +async fn upload_generated_asset( + ctx: &DocumentVersionTaskContext, + context: &ThumbnailContext, + asset_type: &str, + asset_id: Uuid, + asset: &GeneratedAsset, +) -> TaskResult { + let image = &asset.image; + + let s3_key = document_asset_key( + context.document.id, + context.version.version_number, + asset_type, + asset_id, + ); + + ctx.storage() + .put_object( + &s3_key, + image.image_bytes.clone(), + Some("image/webp".into()), + None, + ) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?; + + Ok(AssetPersistence { + asset_type: asset_type.to_string(), + asset_id, + s3_key, + width: image.width, + height: image.height, + }) +} + +struct ThumbnailContext { + document: Document, + version: DocumentVersion, + existing_thumbnail: Option, + skip: bool, + tenant_id: Uuid, +} + +struct GeneratedImage { + image_bytes: Vec, + width: Option, + height: Option, +} + +struct GeneratedAsset { + image: GeneratedImage, +} + +struct GeneratedAssets { + thumbnail: GeneratedAsset, + page_count: Option, +} + +struct AssetPersistence { + asset_type: String, + asset_id: Uuid, + s3_key: String, + width: Option, + height: Option, +} + +fn generate_thumbnails(document: &Document, bytes: &[u8]) -> Result { + if document_is_pdf(document) { + let pdf_assets = generate_pdf_assets(bytes)?; + Ok(GeneratedAssets { + thumbnail: pdf_assets.thumbnail, + page_count: Some(pdf_assets.page_count), + }) + } else { + let thumbnail = generate_image_assets(bytes)?; + Ok(GeneratedAssets { + thumbnail, + page_count: None, + }) + } +} + +async fn generate_video_thumbnail( + ctx: &DocumentVersionTaskContext, + context: &ThumbnailContext, +) -> TaskResult { + if let Err((size, limit)) = + check_worker_document_limit(context.version.size_bytes, ctx.max_document_bytes()) + { + return Err(TaskError::fail(format!( + "document size {size} bytes exceeds worker limit of {limit} bytes" + ))); + } + + let presigned_url = ctx + .storage() + .presign_get_object(&context.version.s3_key, VIDEO_PRESIGN_TTL, None) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("failed to presign video for thumbnail: {err}"), + ) + })?; + + let probe = probe_video_metadata(&presigned_url) + .await + .map_err(TaskError::fail)?; + let timestamp = pick_thumbnail_timestamp(probe.duration); + + let frame_bytes = extract_video_frame(&presigned_url, timestamp) + .await + .map_err(TaskError::fail)?; + + let thumbnail = generate_image_assets(&frame_bytes).map_err(TaskError::fail)?; + + Ok(GeneratedAssets { + thumbnail, + page_count: None, + }) +} + +fn generate_image_assets(bytes: &[u8]) -> Result { + let reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|err| err.to_string())?; + let image = reader.decode().map_err(|err| err.to_string())?; + + let render_image = if image.width() > RENDER_WIDTH || image.height() > RENDER_HEIGHT { + image.thumbnail(RENDER_WIDTH, RENDER_HEIGHT) + } else { + image.clone() + }; + + let thumbnail_image = + if render_image.width() > THUMBNAIL_WIDTH || render_image.height() > THUMBNAIL_HEIGHT { + render_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) + } else { + render_image.clone() + }; + + let thumbnail = encode_dynamic_image(thumbnail_image)?; + + Ok(GeneratedAsset { image: thumbnail }) +} + +struct PdfGeneratedAssets { + thumbnail: GeneratedAsset, + page_count: u32, +} + +fn generate_pdf_assets(bytes: &[u8]) -> Result { + let pdfium = panic::catch_unwind(|| Pdfium::default()) + .map_err(|_| "failed to initialize PDFium".to_string())?; + + let document = pdfium + .load_pdf_from_byte_slice(bytes, None) + .map_err(|err| format!("load pdf: {err}"))?; + + let pages = document.pages(); + let total_pages = pages.len() as usize; + if total_pages == 0 { + return Err("pdf has no pages".to_string()); + } + + let render_config = PdfRenderConfig::new() + .set_target_width(RENDER_WIDTH as i32) + .set_maximum_height(RENDER_HEIGHT as i32) + .render_form_data(true) + .rotate_if_landscape(PdfPageRenderRotation::None, true); + + let first_page = pages.get(0).map_err(|err| format!("load page 0: {err}"))?; + + let bitmap = first_page + .render_with_config(&render_config) + .map_err(|err| format!("render pdf page 0: {err}"))?; + + let render_buffer = bitmap.as_image().to_rgb8(); + let render_image = image::DynamicImage::ImageRgb8(render_buffer); + + let thumbnail_image = + if render_image.width() > THUMBNAIL_WIDTH || render_image.height() > THUMBNAIL_HEIGHT { + render_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) + } else { + render_image.clone() + }; + + let page_count: u32 = total_pages + .try_into() + .map_err(|_| "page count exceeds supported range".to_string())?; + + Ok(PdfGeneratedAssets { + thumbnail: GeneratedAsset { + image: encode_dynamic_image(thumbnail_image)?, + }, + page_count, + }) +} + +#[derive(Deserialize)] +struct FfprobeOutput { + format: Option, +} + +#[derive(Deserialize)] +struct FfprobeFormat { + duration: Option, +} + +struct VideoProbe { + duration: Option, +} + +async fn probe_video_metadata(url: &str) -> Result { + let mut cmd = Command::new("ffprobe"); + cmd.arg("-v") + .arg("error") + .arg("-show_entries") + .arg("format=duration") + .arg("-of") + .arg("json") + .arg(url) + .stdout(Stdio::piped()); + + let output = timeout(FFMPEG_TIMEOUT, cmd.output()) + .await + .map_err(|_| "ffprobe timed out".to_string())? + .map_err(|err| format!("ffprobe failed to start: {err}"))?; + + if !output.status.success() { + return Err(format!( + "ffprobe exited with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + + let parsed: FfprobeOutput = serde_json::from_slice(&output.stdout) + .map_err(|err| format!("failed to parse ffprobe output: {err}"))?; + + let duration = parsed + .format + .and_then(|format| format.duration) + .and_then(|dur| dur.parse::().ok()); + + Ok(VideoProbe { duration }) +} + +fn pick_thumbnail_timestamp(duration: Option) -> f64 { + if let Some(duration) = duration { + if duration.is_finite() && duration > 0.0 { + let target = duration * 0.2; + let end = (duration - 1.0).max(0.0); + return target.max(2.0).min(end).max(0.0); + } + } + 2.0 +} + +async fn extract_video_frame(url: &str, timestamp_secs: f64) -> Result, String> { + let timestamp_arg = format!("{timestamp_secs:.3}"); + + let mut cmd = Command::new("ffmpeg"); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-ss") + .arg(timestamp_arg) + .arg("-i") + .arg(url) + .arg("-frames:v") + .arg("1") + .arg("-f") + .arg("image2pipe") + .arg("-vcodec") + .arg("png") + .arg("-") + .stdout(Stdio::piped()); + + let output = timeout(FFMPEG_TIMEOUT, cmd.output()) + .await + .map_err(|_| "ffmpeg timed out".to_string())? + .map_err(|err| format!("ffmpeg failed to start: {err}"))?; + + if !output.status.success() { + return Err(format!( + "ffmpeg exited with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + + if output.stdout.is_empty() { + return Err("ffmpeg produced no frame data".to_string()); + } + + Ok(output.stdout) +} + +fn encode_dynamic_image(image: image::DynamicImage) -> Result { + let (width, height) = image.dimensions(); + let mut cursor = Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::WebP) + .map_err(|err| err.to_string())?; + Ok(GeneratedImage { + image_bytes: cursor.into_inner(), + width: Some(width as i32), + height: Some(height as i32), + }) +} + +fn persist_assets_metadata( + state: Arc, + tenant_id: Uuid, + version_id: Uuid, + assets: &[AssetPersistence], +) -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + for asset in assets { + let mut metadata_map = Map::new(); + if let Some(width) = asset.width { + metadata_map.insert("width".to_string(), Value::from(width)); + } + if let Some(height) = asset.height { + metadata_map.insert("height".to_string(), Value::from(height)); + } + metadata_map.insert( + "generated_at".to_string(), + Value::from(Utc::now().to_rfc3339()), + ); + + let new_asset = NewDocumentAsset { + id: asset.asset_id, + document_version_id: version_id, + asset_type: asset.asset_type.clone(), + mime_type: "image/webp".to_string(), + metadata: Value::Object(metadata_map), + s3_key: asset.s3_key.clone(), + tenant_id, + }; + + diesel::insert_into(document_assets::table) + .values(&new_asset) + .on_conflict(( + document_assets::document_version_id, + document_assets::asset_type, + )) + .do_update() + .set(( + document_assets::mime_type.eq(excluded(document_assets::mime_type)), + document_assets::metadata.eq(excluded(document_assets::metadata)), + document_assets::s3_key.eq(excluded(document_assets::s3_key)), + )) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + } + + Ok(()) +} + +fn persist_document_page_count( + state: Arc, + tenant_id: Uuid, + document_id: Uuid, + document_version_id: Uuid, + page_count: u32, +) -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + let existing_metadata: Value = document_versions::table + .filter(document_versions::id.eq(document_version_id)) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .select(document_versions::metadata) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + let updated = match existing_metadata { + Value::Object(mut map) => { + map.insert("page_count".to_string(), Value::from(page_count)); + Value::Object(map) + } + _ => { + let mut map = Map::new(); + map.insert("page_count".to_string(), Value::from(page_count)); + Value::Object(map) + } + }; + + diesel::update( + document_versions::table + .filter(document_versions::id.eq(document_version_id)) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)), + ) + .set(document_versions::metadata.eq(updated)) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + Ok(()) +} + +fn document_is_video(document: &Document) -> bool { + const VIDEO_MIME_TYPES: [&str; 6] = [ + "video/mp4", + "video/quicktime", + "video/webm", + "video/x-msvideo", + "video/x-ms-wmv", + "video/x-matroska", + ]; + if let Some(mime) = document.mime_type.as_deref() { + if VIDEO_MIME_TYPES + .iter() + .any(|candidate| mime.eq_ignore_ascii_case(candidate)) + { + return true; + } + } + + false +} + +fn document_is_pdf(document: &Document) -> bool { + document + .mime_type + .as_deref() + .map(|mime| mime.eq_ignore_ascii_case("application/pdf")) + .unwrap_or_else(|| { + document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) + }) +} diff --git a/backend/tests/api_tokens_flow.rs b/backend/tests/api_tokens_flow.rs new file mode 100644 index 0000000..032f23d --- /dev/null +++ b/backend/tests/api_tokens_flow.rs @@ -0,0 +1,402 @@ +use anyhow::{Context, Result}; +use axum::body::Body; +use axum::http::{header, Method, Request, StatusCode}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use diesel::prelude::*; +use diesel::OptionalExtension; +use papercrate::auth::capability_sets; +use papercrate::models::{ApiCapability, ApiToken}; +use papercrate::routes::webdav; +use papercrate::schema::api_tokens; +use papercrate::schema::capability_sets::dsl as capability_sets_dsl; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde_json::json; +use tower::ServiceExt; +use uuid::Uuid; + +const LEGACY_WEBDAV_CAPS: &[&str] = &[ + "documents:edit", + "documents:read", + "documents:upload", + "documents:write", + "folders:edit", + "folders:read", + "folders:write", + "webdav:read", +]; + +const READ_ONLY_CAPS: &[&str] = &["documents:read"]; +const LIMITED_WEBDAV_CAPS: &[&str] = &["documents:read", "webdav:read"]; + +#[derive(Debug, Deserialize)] +struct TokenInfo { + id: Uuid, + label: Option, + last_used_at: Option, + revoked_at: Option, + capability_set_id: Uuid, +} + +#[derive(Debug, Deserialize)] +struct CreateTokenResponse { + token: String, + #[serde(rename = "token_info")] + info: TokenInfo, +} + +#[derive(Debug, Deserialize)] +struct LoginResponseView { + access_token: String, + token_type: String, + expires_in: i64, + tenant: TenantView, +} + +#[derive(Debug, Deserialize)] +struct TenantView { + id: Uuid, + name: String, +} + +#[tokio::test] +async fn api_token_crud_flow() -> Result<()> { + let _guard = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "alice"; + let password = "correct horse battery"; + app.insert_user(username, TestUserRole::Owner).await?; + let access_token = app.login_token(username, password).await?; + + let legacy_set_id = + ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS) + .await?; + + let created = create_token(&app, &access_token, Some("dav"), legacy_set_id, None).await?; + let token_id = created.info.id; + assert_eq!(created.info.label.as_deref(), Some("dav")); + assert!(created.info.last_used_at.is_none()); + assert_eq!(created.info.capability_set_id, legacy_set_id); + + let regenerated = regenerate_token(&app, &access_token, token_id).await?; + assert_eq!(regenerated.info.id, token_id); + assert_ne!(regenerated.token, created.token); + assert!(regenerated.info.last_used_at.is_none()); + + let listed = list_tokens(&app, &access_token).await?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, token_id); + assert_eq!(listed[0].capability_set_id, legacy_set_id); + + let tenant_id_for_token = app + .with_conn(move |conn| { + let tenant_id = api_tokens::table + .find(token_id) + .select(api_tokens::tenant_id) + .first::(conn)?; + Ok::<_, anyhow::Error>(tenant_id) + }) + .await?; + + let readonly_set_id = + ensure_capability_set_slug(&app, &access_token, "readonly", READ_ONLY_CAPS).await?; + + let readonly_token = + create_token(&app, &access_token, Some("readonly"), readonly_set_id, None).await?; + let readonly_exchange = exchange_token(&app, &readonly_token.token).await?; + assert_eq!(readonly_exchange.tenant.id, tenant_id_for_token); + delete_token(&app, &access_token, readonly_token.info.id).await?; + + let exchange = exchange_token(&app, ®enerated.token).await?; + assert_eq!(exchange.token_type, "Bearer"); + assert!(!exchange.access_token.is_empty()); + assert!(exchange.expires_in > 0); + assert_eq!(exchange.tenant.id, tenant_id_for_token); + assert!(!exchange.tenant.name.is_empty()); + + delete_token(&app, &access_token, token_id).await?; + + let listed_after = list_tokens(&app, &access_token).await?; + let revoked_entry = listed_after + .iter() + .find(|entry| entry.id == token_id) + .expect("revoked token still listed"); + assert!(revoked_entry.revoked_at.is_some()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn webdav_basic_auth_uses_api_tokens() -> Result<()> { + let _guard = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "bruce"; + let password = "wayne"; + app.insert_user(username, TestUserRole::Owner).await?; + let access_token = app.login_token(username, password).await?; + + let legacy_set_id = + ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS) + .await?; + + let created = create_token(&app, &access_token, Some("webdav"), legacy_set_id, None).await?; + let token_id = created.info.id; + + let router = webdav::create_router().with_state(app.state.clone()); + let original_secret = created.token.clone(); + let auth_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, original_secret)) + ); + + let propfind = Method::from_bytes(b"PROPFIND")?; + let success_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, auth_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let response = router.clone().oneshot(success_request).await?; + assert_eq!(response.status(), StatusCode::MULTI_STATUS); + + let used = app + .with_conn(move |conn| { + let record = api_tokens::table.find(token_id).first::(conn)?; + Ok::<_, anyhow::Error>(record.last_used_at) + }) + .await?; + assert!(used.is_some()); + + let regenerated = regenerate_token(&app, &access_token, token_id).await?; + assert_ne!(regenerated.token, original_secret); + + let unused_after_regen = app + .with_conn(move |conn| { + let record = api_tokens::table.find(token_id).first::(conn)?; + Ok::<_, anyhow::Error>(record.last_used_at) + }) + .await?; + assert!(unused_after_regen.is_none()); + + let old_secret_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header( + header::AUTHORIZATION, + format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, original_secret)) + ), + ) + .header("depth", "0") + .body(Body::empty())?; + let old_secret_response = router.clone().oneshot(old_secret_request).await?; + assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED); + + let new_secret_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, regenerated.token)) + ); + + let success_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, new_secret_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let response = router.clone().oneshot(success_request).await?; + assert_eq!(response.status(), StatusCode::MULTI_STATUS); + + // Token without webdav_read cannot authenticate. + let read_only_set_id = + ensure_capability_set_slug(&app, &access_token, "documents_read", READ_ONLY_CAPS).await?; + + let limited_token = + create_token(&app, &access_token, Some("limited"), read_only_set_id, None).await?; + assert_eq!(limited_token.info.capability_set_id, read_only_set_id); + + let limited_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, limited_token.token)) + ); + + let limited_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, limited_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let limited_response = router.clone().oneshot(limited_request).await?; + assert_eq!(limited_response.status(), StatusCode::UNAUTHORIZED); + + let limited_set_id = ensure_capability_set_slug( + &app, + &access_token, + "documents_read_webdav", + LIMITED_WEBDAV_CAPS, + ) + .await?; + + let upgraded_token = create_token( + &app, + &access_token, + Some("limited-webdav"), + limited_set_id, + None, + ) + .await?; + assert_eq!(upgraded_token.info.capability_set_id, limited_set_id); + + let upgraded_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header( + header::AUTHORIZATION, + format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, upgraded_token.token)) + ), + ) + .header("depth", "0") + .body(Body::empty())?; + let upgraded_response = router.clone().oneshot(upgraded_request).await?; + assert_eq!(upgraded_response.status(), StatusCode::MULTI_STATUS); + + delete_token(&app, &access_token, token_id).await?; + + let failure_request = Request::builder() + .method(propfind) + .uri("/") + .header(header::AUTHORIZATION, new_secret_header) + .header("depth", "0") + .body(Body::empty())?; + let failure_response = router.oneshot(failure_request).await?; + assert_eq!(failure_response.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} + +async fn create_token( + app: &TestApp, + access_token: &str, + label: Option<&str>, + capability_set_id: Uuid, + expires_at: Option<&str>, +) -> Result { + let mut payload = json!({ + "capability_set_id": capability_set_id, + }); + + if let Some(label) = label { + payload["label"] = json!(label); + } + + if let Some(expires) = expires_at { + payload["expires_at"] = json!(expires); + } + + let response = app + .post_json("/api/profile/api-tokens", &payload, Some(access_token)) + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn regenerate_token( + app: &TestApp, + access_token: &str, + token_id: Uuid, +) -> Result { + let response = app + .post_json( + &format!("/api/profile/api-tokens/{token_id}/regenerate"), + &json!({}), + Some(access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn list_tokens(app: &TestApp, access_token: &str) -> Result> { + let response = app + .get("/api/profile/api-tokens", Some(access_token)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Result<()> { + let response = app + .delete( + &format!("/api/profile/api-tokens/{token_id}"), + Some(access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + Ok(()) +} + +async fn ensure_capability_set_slug( + app: &TestApp, + access_token: &str, + slug: &str, + capabilities: &[&str], +) -> Result { + let claims = app + .state + .jwt + .verify_token(access_token) + .context("failed to decode access token claims")?; + let tenant_id = claims.tenant_id; + let slug = slug.to_string(); + let desired_capabilities = capabilities + .iter() + .map(|value| { + value + .parse::() + .map_err(|err| anyhow::anyhow!("invalid capability '{value}': {err}")) + }) + .collect::>>()?; + + let caps_for_insert = desired_capabilities.clone(); + app.with_conn(move |conn| { + if let Some(existing) = capability_sets_dsl::capability_sets + .filter(capability_sets_dsl::tenant_id.eq(tenant_id)) + .filter(capability_sets_dsl::slug.eq(&slug)) + .select(capability_sets_dsl::id) + .first::(conn) + .optional()? + { + return Ok(existing); + } + + let created = + capability_sets::create_capability_set(conn, tenant_id, &slug, caps_for_insert) + .map_err(|err| { + anyhow::anyhow!("failed to create capability set '{slug}': {err:?}") + })?; + Ok(created.id) + }) + .await +} +async fn exchange_token(app: &TestApp, api_token: &str) -> Result { + let response = app + .post_json( + "/api/auth/exchange-api-token", + &json!({ "api_token": api_token }), + None, + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs new file mode 100644 index 0000000..bfdf6f2 --- /dev/null +++ b/backend/tests/auth_flow.rs @@ -0,0 +1,745 @@ +use anyhow::{anyhow, Context, Result}; +use axum::http::{header::SET_COOKIE, StatusCode}; +use chrono::{Duration as ChronoDuration, Utc}; +use diesel::prelude::*; +use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities}; +use papercrate::auth::jwt::{AccessTokenContext, PrincipalKind}; +use papercrate::auth::passkeys::{ + PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, + RegistrationChallengeResponse, +}; +use papercrate::models::{ + MagicToken, MagicTokenKind, NewUserMembership, NewUserSession, TenantStatus, UserPasskey, +}; +use papercrate::openapi::schemas::PasskeySummary; +use papercrate::schema::{ + capability_sets, magic_tokens::dsl as magic_dsl, tenants, tenants::dsl as tenant_dsl, + user_memberships, user_sessions, users, +}; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use rand::{rngs::OsRng, TryRngCore}; +use serde::Deserialize; +use serde_json::json; +use sha2::{Digest, Sha256}; +use uuid::Uuid; +use webauthn_rs_core::proto::{ + AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, + RegisterPublicKeyCredential, +}; + +#[derive(Deserialize)] +struct AuthenticatedUser { + username: String, +} + +#[derive(Deserialize)] +struct ApiErrorResponse { + error: String, + #[serde(default)] + _code: Option, +} + +#[derive(Deserialize)] +struct LoginTenant { + id: Uuid, + name: String, +} + +#[derive(Deserialize)] +struct LoginResponse { + access_token: String, + tenant: LoginTenant, +} + +#[derive(Deserialize)] +struct SignupStartResponse { + signup_token: String, + challenge: RegistrationChallengeResponse, +} + +#[derive(Deserialize)] +struct TenantSummary { + id: Uuid, + name: String, +} + +#[tokio::test] +async fn login_and_me_roundtrip() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "s3cret"; + app.insert_user("alice", TestUserRole::Owner).await?; + + let (login, _) = login_with_session(&app, "alice", password).await?; + + let response = app.get("/api/auth/me", Some(&login.access_token)).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let user: AuthenticatedUser = serde_json::from_slice(&body)?; + + assert_eq!(user.username, "alice"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn login_rejects_unknown_user() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = json!({ "username": "ghost", "password": "nope" }); + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_to_vec(response.into_body()).await?; + let err: ApiErrorResponse = serde_json::from_slice(&body)?; + assert_eq!(err.error, "password authentication is no longer supported"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn signup_start_and_finish_require_valid_passkey() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = json!({ "username": "signup-user" }); + + let response = app + .post_json("/api/auth/signup/start", &payload, None) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let start: SignupStartResponse = serde_json::from_slice(&body)?; + assert!(!start.signup_token.is_empty()); + assert_ne!(start.challenge.challenge_id, Uuid::nil()); + + app.with_conn(|conn| { + let exists: bool = diesel::select(diesel::dsl::exists( + users::table.filter(users::username.eq("signup-user")), + )) + .get_result(conn)?; + assert!(!exists); + Ok(()) + }) + .await?; + + let finish_payload = json!({ + "signup_token": start.signup_token, + "credential": fake_register_credential(), + }); + let finish_response = app + .post_json("/api/auth/signup/finish", &finish_payload, None) + .await?; + assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_register_start_creates_challenge() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + app.insert_user("passkey-user", TestUserRole::Owner).await?; + + let (login, _) = login_with_session(&app, "passkey-user", password).await?; + + let response = app + .post_json( + "/api/auth/passkeys/register/start", + &json!({}), + Some(&login.access_token), + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?; + assert_ne!(challenge.challenge_id, Uuid::nil()); + let challenge_id = challenge.challenge_id; + + app.with_conn(move |conn| { + use diesel::dsl::{exists, select}; + use papercrate::schema::webauthn_challenges::dsl; + + let exists: bool = select(exists( + dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)), + )) + .get_result(conn)?; + assert!(exists, "challenge not persisted"); + Ok(()) + }) + .await?; + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + app.insert_user("passkey-register", TestUserRole::Owner) + .await?; + let (login, _) = login_with_session(&app, "passkey-register", password).await?; + + let payload = PasskeyRegistrationFinishPayload { + challenge_id: Uuid::new_v4(), + credential: fake_register_credential(), + nickname: None, + }; + + let response = app + .post_json( + "/api/auth/passkeys/register/finish", + &payload, + Some(&login.access_token), + ) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_login_start_requires_passkey() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let _password = "secret"; + app.insert_user("passkey-login", TestUserRole::Owner) + .await?; + + let payload = PasskeyLoginStartPayload { + username: "passkey-login".to_string(), + }; + + let response = app + .post_json("/api/auth/passkeys/login/start", &payload, None) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = PasskeyLoginStartPayload { + username: "nobody".to_string(), + }; + + let response = app + .post_json("/api/auth/passkeys/login/start", &payload, None) + .await?; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = PasskeyLoginFinishPayload { + challenge_id: Uuid::new_v4(), + credential: fake_authentication_credential(), + }; + + let response = app + .post_json("/api/auth/passkeys/login/finish", &payload, None) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn list_passkeys_returns_entries() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + let user_id = app + .insert_user("passkey-owner", TestUserRole::Owner) + .await?; + app.insert_passkey(user_id, Some("Laptop")).await?; + + let (session, _) = login_with_session(&app, "passkey-owner", password).await?; + + let response = app + .get("/api/profile/passkeys", Some(&session.access_token)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + + let body = body_to_vec(response.into_body()).await?; + let summaries: Vec = serde_json::from_slice(&body)?; + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop")); + assert!(summaries[0].revoked_at.is_none()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_passkey_soft_revokes() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + let user_id = app + .insert_user("passkey-delete", TestUserRole::Owner) + .await?; + let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?; + app.insert_passkey(user_id, Some("Backup")).await?; + let (session, _) = login_with_session(&app, "passkey-delete", password).await?; + + let response = app + .delete( + &format!("/api/profile/passkeys/{}?reason=lost", passkey_id), + Some(&session.access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + app.with_conn(move |conn| { + use papercrate::schema::user_passkeys::dsl as passkey_dsl; + + let record = passkey_dsl::user_passkeys + .find(passkey_id) + .first::(conn)?; + assert!(record.revoked_at.is_some()); + assert_eq!(record.revoked_reason.as_deref(), Some("lost")); + Ok(()) + }) + .await?; + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_passkey_prevents_last() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + let user_id = app + .insert_user("passkey-guard", TestUserRole::Owner) + .await?; + let first_id = app.insert_passkey(user_id, Some("Key A")).await?; + let last_id = app.insert_passkey(user_id, Some("Key B")).await?; + let (session, _) = login_with_session(&app, "passkey-guard", password).await?; + + let response = app + .delete( + &format!("/api/profile/passkeys/{}", first_id), + Some(&session.access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let block_response = app + .delete( + &format!("/api/profile/passkeys/{}", last_id), + Some(&session.access_token), + ) + .await?; + assert_eq!(block_response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +fn fake_register_credential() -> RegisterPublicKeyCredential { + RegisterPublicKeyCredential { + id: "fake-passkey".to_string(), + raw_id: vec![1, 2, 3, 4].into(), + response: AuthenticatorAttestationResponseRaw { + attestation_object: vec![5, 6, 7, 8].into(), + client_data_json: vec![9, 10, 11, 12].into(), + transports: None, + }, + type_: "public-key".to_string(), + extensions: Default::default(), + } +} + +fn fake_authentication_credential() -> PublicKeyCredential { + PublicKeyCredential { + id: "fake-auth".to_string(), + raw_id: vec![1, 2, 3].into(), + response: AuthenticatorAssertionResponseRaw { + authenticator_data: vec![4, 5, 6].into(), + client_data_json: vec![7, 8, 9].into(), + signature: vec![10, 11, 12].into(), + user_handle: None, + }, + extensions: Default::default(), + type_: "public-key".to_string(), + } +} + +#[tokio::test] +async fn login_rejects_invalid_password() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let _password = "valid"; + app.insert_user("robin", TestUserRole::Owner).await?; + + let payload = json!({ "username": "robin", "password": "wrong" }); + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_to_vec(response.into_body()).await?; + let err: ApiErrorResponse = serde_json::from_slice(&body)?; + assert_eq!(err.error, "password authentication is no longer supported"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn refresh_rotates_refresh_token() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "rotate"; + app.insert_user("rita", TestUserRole::Owner).await?; + + let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?; + + let response = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let new_cookie = extract_refresh_cookie(response.headers())?; + let body = body_to_vec(response.into_body()).await?; + let refreshed: LoginResponse = serde_json::from_slice(&body)?; + assert_eq!(refreshed.tenant.name, login.tenant.name); + + let me_response = app + .get("/api/auth/me", Some(&refreshed.access_token)) + .await?; + assert_eq!(me_response.status(), StatusCode::OK); + + let retry = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie)) + .await?; + assert_eq!(retry.status(), StatusCode::UNAUTHORIZED); + + // new cookie should differ from old to avoid reuse + assert_ne!(new_cookie, refresh_cookie); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn logout_revokes_refresh_token() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "logout"; + app.insert_user("logan", TestUserRole::Owner).await?; + + let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?; + + let response = app + .post_json_with_cookie( + "/api/auth/logout", + &json!({}), + Some(&login.access_token), + Some(&refresh_cookie), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let cleared_cookie = extract_refresh_cookie(response.headers())?; + assert!(cleared_cookie.ends_with("=")); + + let after_logout = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie)) + .await?; + assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn me_requires_authentication() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let unauthenticated = app.get("/api/auth/me", None).await?; + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let invalid = app.get("/api/auth/me", Some("invalid")).await?; + assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "multipass"; + let user_id = app.insert_user("multipass", TestUserRole::Owner).await?; + + let secondary_name = "secondary".to_string(); + let name_for_insert = secondary_name.clone(); + let secondary_id = Uuid::new_v4(); + app.with_conn(move |conn| { + diesel::insert_into(tenants::table) + .values(( + tenants::id.eq(secondary_id), + tenants::name.eq(&name_for_insert), + tenants::status.eq(TenantStatus::Active), + )) + .execute(conn)?; + + let owner_capability_set_id = + ensure_capability_set(conn, secondary_id, owner_capabilities()) + .map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))? + .id; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id, + tenant_id: secondary_id, + capability_set_id: Some(owner_capability_set_id), + }; + + diesel::insert_into(user_memberships::table) + .values(&membership) + .execute(conn)?; + Ok(()) + }) + .await?; + + let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?; + + let tenants_response = app.get("/api/tenants", Some(&login.access_token)).await?; + assert_eq!(tenants_response.status(), StatusCode::OK); + let tenants_body = body_to_vec(tenants_response.into_body()).await?; + let tenant_list: Vec = serde_json::from_slice(&tenants_body)?; + assert!(tenant_list.len() >= 2); + + let secondary = tenant_list + .iter() + .find(|tenant| tenant.name == secondary_name) + .map(|t| t.id) + .context("secondary tenant missing from listing")?; + + let select_response = app + .post_json_with_cookie( + "/api/auth/select-tenant", + &json!({ "tenant_id": secondary }), + Some(&login.access_token), + Some(&refresh_cookie), + ) + .await?; + assert_eq!(select_response.status(), StatusCode::OK); + let select_body = body_to_vec(select_response.into_body()).await?; + let rotated: LoginResponse = serde_json::from_slice(&select_body)?; + assert_eq!(rotated.tenant.id, secondary); + assert_eq!(rotated.tenant.name, secondary_name); + + app.cleanup().await?; + Ok(()) +} + +async fn login_with_session( + app: &TestApp, + username: &str, + _password: &str, +) -> Result<(LoginResponse, String)> { + let username = username.to_string(); + let state = app.state.clone(); + app.with_conn(move |conn| { + use papercrate::schema::user_memberships::dsl as memberships_dsl; + use papercrate::schema::users::dsl as users_dsl; + + let user: papercrate::models::User = users_dsl::users + .filter(users_dsl::username.eq(&username)) + .first(conn)?; + + let membership: papercrate::models::UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .first(conn)?; + + let tenant: papercrate::models::Tenant = + tenants::table.find(membership.tenant_id).first(conn)?; + + let capability_set_id = membership + .capability_set_id + .ok_or_else(|| anyhow!("membership missing capability set"))?; + + let cap_version = capability_sets::table + .find(capability_set_id) + .select(capability_sets::cap_version) + .first::(conn)?; + + let now = Utc::now(); + let session_id = Uuid::new_v4(); + let access_token = state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: tenant.id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version, + }) + .map_err(|err| anyhow!(err))?; + + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); + let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); + + let new_session = NewUserSession { + id: session_id, + user_id: user.id, + token_hash: session_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + tenant_id: tenant.id, + }; + + diesel::insert_into(user_sessions::table) + .values(&new_session) + .execute(conn)?; + + let login = LoginResponse { + access_token, + tenant: LoginTenant { + id: tenant.id, + name: tenant.name.clone(), + }, + }; + + let cookie = format!("refresh_token={session_value}"); + Ok((login, cookie)) + }) + .await +} + +fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result { + let header_value = headers + .get(SET_COOKIE) + .context("missing set-cookie header")? + .to_str() + .context("invalid set-cookie header")?; + let cookie = header_value + .split(';') + .next() + .context("set-cookie missing cookie value")? + .to_string(); + Ok(cookie) +} + +fn generate_session_token() -> String { + let mut bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut bytes) + .expect("failed to read random bytes"); + hex::encode(bytes) +} + +fn hash_session_token(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + hex::encode(hasher.finalize()) +} +#[tokio::test] +async fn tenant_selection_excludes_inactive_tenants() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let user_id = app.insert_user("tenant-user", TestUserRole::Owner).await?; + + let tenant_id = app + .with_conn(|conn| { + let tenant: papercrate::models::Tenant = tenant_dsl::tenants + .filter(tenant_dsl::name.eq("test_tenant")) + .first(conn)?; + Ok::<_, anyhow::Error>(tenant.id) + }) + .await?; + + app.with_conn(move |conn| { + diesel::update(tenant_dsl::tenants.find(tenant_id)) + .set(tenant_dsl::status.eq(TenantStatus::Suspended)) + .execute(conn)?; + Ok(()) + }) + .await?; + + let magic_value = "tenant-status-token"; + let token_hash = { + let mut hasher = Sha256::new(); + hasher.update(magic_value.as_bytes()); + hex::encode(hasher.finalize()) + }; + + let user_id_for_token = user_id; + app.with_conn(move |conn| { + let token = MagicToken { + id: Uuid::new_v4(), + user_id: user_id_for_token, + kind: MagicTokenKind::EmailLogin, + token_hash, + metadata: json!({}), + expires_at: (Utc::now() + ChronoDuration::hours(1)).naive_utc(), + max_uses: None, + used_count: 0, + created_at: Utc::now().naive_utc(), + created_by: None, + last_used_at: None, + }; + + diesel::insert_into(magic_dsl::magic_tokens) + .values(&token) + .execute(conn)?; + Ok(()) + }) + .await?; + + let payload = json!({ + "username": "tenant-user", + "magic_token": magic_value, + }); + + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = body_to_vec(response.into_body()).await?; + let err: ApiErrorResponse = serde_json::from_slice(&body)?; + assert_eq!(err.error, "no active tenants available"); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/capability_guards_flow.rs b/backend/tests/capability_guards_flow.rs new file mode 100644 index 0000000..8072b1a --- /dev/null +++ b/backend/tests/capability_guards_flow.rs @@ -0,0 +1,104 @@ +use anyhow::{anyhow, Result}; +use axum::http::StatusCode; +use diesel::prelude::*; +use papercrate::models::ApiCapability; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde_json::json; + +async fn set_user_capabilities( + app: &TestApp, + user_id: uuid::Uuid, + caps: &[ApiCapability], +) -> Result<()> { + let capabilities = caps.to_vec(); + app.with_conn(move |conn| { + use papercrate::schema::user_memberships::dsl as memberships_dsl; + + let membership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .first::(conn)?; + + let capability_set = papercrate::auth::capability_sets::ensure_capability_set( + conn, + membership.tenant_id, + &capabilities, + ) + .map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?; + + diesel::update(memberships_dsl::user_memberships.find(membership.id)) + .set(memberships_dsl::capability_set_id.eq(Some(capability_set.id))) + .execute(conn)?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn documents_routes_enforce_capabilities() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "limited-docs"; + let user_id = app.insert_user("limited-docs", TestUserRole::Owner).await?; + set_user_capabilities(&app, user_id, &[ApiCapability::DocumentsRead]).await?; + + let token = app.login_token("limited-docs", password).await?; + + let list = app.get("/api/documents", Some(&token)).await?; + assert_eq!(list.status(), StatusCode::OK); + + let upload = app + .upload_document( + "/api/documents", + "limited.txt", + "text/plain", + b"limited", + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::FORBIDDEN); + let upload_body = body_to_vec(upload.into_body()).await?; + assert!(String::from_utf8_lossy(&upload_body).contains("missing")); + + let capability_sets = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(capability_sets.status(), StatusCode::FORBIDDEN); + let caps_body = body_to_vec(capability_sets.into_body()).await?; + assert!(String::from_utf8_lossy(&caps_body).contains("missing")); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn capability_set_routes_require_write_privilege() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "caps-reader"; + let user_id = app.insert_user("caps-reader", TestUserRole::Member).await?; + set_user_capabilities(&app, user_id, &[ApiCapability::CapabilitySetsRead]).await?; + + let token = app.login_token("caps-reader", password).await?; + + let list = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(list.status(), StatusCode::OK); + + let create = app + .post_json( + "/api/capability-sets", + &json!({ + "slug": "should-fail", + "capabilities": ["documents:read"] + }), + Some(&token), + ) + .await?; + assert_eq!(create.status(), StatusCode::FORBIDDEN); + let create_body = body_to_vec(create.into_body()).await?; + assert!(String::from_utf8_lossy(&create_body).contains("missing")); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/capability_sets_flow.rs b/backend/tests/capability_sets_flow.rs new file mode 100644 index 0000000..a4ebadd --- /dev/null +++ b/backend/tests/capability_sets_flow.rs @@ -0,0 +1,114 @@ +use anyhow::Result; +use axum::http::StatusCode; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; + +#[derive(Deserialize)] +struct CapabilitySetResponse { + id: Uuid, + slug: String, + cap_version: i32, + #[allow(dead_code)] + is_system: bool, + capabilities: Vec, +} + +#[tokio::test] +async fn capability_set_crud_flow() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "caps-admin"; + app.insert_user("caps", TestUserRole::Owner).await?; + let token = app.login_token("caps", password).await?; + + // Initial list should contain system sets. + let initial = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(initial.status(), StatusCode::OK); + let initial_body = body_to_vec(initial.into_body()).await?; + let sets: Vec = serde_json::from_slice(&initial_body)?; + let owner_id = sets + .iter() + .find(|set| set.slug == "owner") + .map(|set| set.id) + .expect("owner set present"); + assert!(sets.iter().any(|set| set.slug == "user")); + assert!(sets.iter().any(|set| set.slug == "readonly")); + assert!(sets.iter().any(|set| set.slug == "webdav")); + + let capabilities_resp = app.get("/api/capabilities", Some(&token)).await?; + assert_eq!(capabilities_resp.status(), StatusCode::OK); + let capabilities_body = body_to_vec(capabilities_resp.into_body()).await?; + let capabilities: Vec = serde_json::from_slice(&capabilities_body)?; + assert!(capabilities.contains(&"documents:read".to_string())); + assert!(capabilities.contains(&"capability_sets:write".to_string())); + assert_eq!( + capabilities.len(), + papercrate::models::ApiCapability::variants().len() + ); + + // Create a new capability set. + let create = app + .post_json( + "/api/capability-sets", + &json!({ + "slug": "api_readonly", + "capabilities": ["documents:read", "capability_sets:read"] + }), + Some(&token), + ) + .await?; + assert_eq!(create.status(), StatusCode::CREATED); + let create_body = body_to_vec(create.into_body()).await?; + let created: CapabilitySetResponse = serde_json::from_slice(&create_body)?; + assert_eq!(created.slug, "api_readonly"); + assert!(created.capabilities.contains(&"documents:read".to_string())); + assert!(created + .capabilities + .contains(&"capability_sets:read".to_string())); + + // Update capabilities. + let update = app + .patch_json( + &format!("/api/capability-sets/{}", created.id), + &json!({ + "capabilities": ["documents:read", "documents:edit"], + }), + Some(&token), + ) + .await?; + assert_eq!(update.status(), StatusCode::OK); + let update_body = body_to_vec(update.into_body()).await?; + let updated: CapabilitySetResponse = serde_json::from_slice(&update_body)?; + assert_eq!(updated.cap_version, created.cap_version + 1); + assert!(updated.capabilities.contains(&"documents:edit".to_string())); + assert!(!updated + .capabilities + .contains(&"capability_sets:read".to_string())); + + // Attempt to delete system set should conflict. + let delete_owner = app + .delete(&format!("/api/capability-sets/{}", owner_id), Some(&token)) + .await?; + assert_eq!(delete_owner.status(), StatusCode::CONFLICT); + + // Delete custom set succeeds. + let delete = app + .delete( + &format!("/api/capability-sets/{}", created.id), + Some(&token), + ) + .await?; + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + + let final_list = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(final_list.status(), StatusCode::OK); + let final_body = body_to_vec(final_list.into_body()).await?; + let final_sets: Vec = serde_json::from_slice(&final_body)?; + assert!(!final_sets.iter().any(|set| set.slug == "api_readonly")); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs new file mode 100644 index 0000000..690c28d --- /dev/null +++ b/backend/tests/common/mod.rs @@ -0,0 +1 @@ +pub use papercrate::test_support::*; diff --git a/backend/tests/correspondents_flow.rs b/backend/tests/correspondents_flow.rs new file mode 100644 index 0000000..ff057d7 --- /dev/null +++ b/backend/tests/correspondents_flow.rs @@ -0,0 +1,275 @@ +use anyhow::Result; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; + +#[derive(Deserialize)] +struct DocumentDetail { + document: DocumentSummary, +} + +#[derive(Deserialize)] +struct DocumentSummary { + id: Uuid, + #[serde(rename = "title")] + _title: String, + #[serde(default)] + correspondents: Vec, +} + +#[derive(Deserialize)] +struct DocumentCorrespondentSummary { + id: Uuid, + name: String, +} + +#[derive(Deserialize)] +struct CorrespondentSummary { + id: Uuid, +} + +#[derive(Deserialize)] +struct BulkCorrespondentResult { + assigned: usize, + removed: usize, +} + +struct TestContext { + app: TestApp, + token: String, + document_ids: Vec, + sender_id: Uuid, + receiver_id: Uuid, +} + +impl TestContext { + const SENDER_NAME: &'static str = "Acme Corp"; + const RECEIVER_NAME: &'static str = "Bank Ltd"; + + async fn new(prefix: &str) -> Result { + let app = TestApp::new().await?; + let username = format!("{prefix}_user"); + let password = format!("{prefix}_pw"); + app.insert_user(&username, TestUserRole::Owner).await?; + let token = app.login_token(&username, &password).await?; + + let first_id = + upload_document(&app, &token, &format!("{prefix}-one.txt"), b"letter one").await?; + let second_id = + upload_document(&app, &token, &format!("{prefix}-two.txt"), b"letter two").await?; + let sender_id = create_correspondent(&app, &token, Self::SENDER_NAME).await?; + let receiver_id = create_correspondent(&app, &token, Self::RECEIVER_NAME).await?; + + Ok(Self { + app, + token, + document_ids: vec![first_id, second_id], + sender_id, + receiver_id, + }) + } + + async fn assign(&self, correspondent_ids: &[Uuid]) -> Result { + self.assign_with_action(correspondent_ids, None).await + } + + async fn assign_with_action( + &self, + correspondent_ids: &[Uuid], + action: Option<&str>, + ) -> Result { + let assignments: Vec<_> = correspondent_ids + .iter() + .map(|id| json!({ "correspondent_id": id })) + .collect(); + + let mut payload = json!({ + "document_ids": self.document_ids, + "assignments": assignments, + }); + + if let Some(action) = action { + if let Some(obj) = payload.as_object_mut() { + obj.insert("action".to_string(), json!(action)); + } + } + + let response = self + .app + .post_json( + "/api/documents/bulk/correspondents", + &payload, + Some(&self.token), + ) + .await?; + assert!(response.status().is_success()); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) + } + + async fn fetch_correspondents( + &self, + document_id: Uuid, + ) -> Result> { + let detail = fetch_document_detail(&self.app, &self.token, document_id).await?; + Ok(detail.document.correspondents) + } + + async fn create_correspondent(&self, name: &str) -> Result { + create_correspondent(&self.app, &self.token, name).await + } +} + +#[tokio::test] +async fn bulk_assign_correspondents_adds_new_links() -> Result<()> { + let _lock = acquire_db_lock().await; + let context = TestContext::new("corresp_add").await?; + + let result = context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + assert_eq!(result.assigned, 4); + assert_eq!(result.removed, 0); + + for doc_id in &context.document_ids { + let correspondents = context.fetch_correspondents(*doc_id).await?; + let names: Vec<_> = correspondents + .iter() + .map(|entry| entry.name.as_str()) + .collect(); + assert!(names.contains(&TestContext::SENDER_NAME)); + assert!(names.contains(&TestContext::RECEIVER_NAME)); + let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect(); + assert!(ids.contains(&context.sender_id)); + assert!(ids.contains(&context.receiver_id)); + } + + context.app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_assign_correspondents_is_idempotent() -> Result<()> { + let _lock = acquire_db_lock().await; + let context = TestContext::new("corresp_idempotent").await?; + + context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + let repeat = context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + assert_eq!(repeat.assigned, 0); + assert_eq!(repeat.removed, 0); + + context.app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_remove_correspondents_detaches_links() -> Result<()> { + let _lock = acquire_db_lock().await; + let context = TestContext::new("corresp_remove").await?; + + context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + let removal = context + .assign_with_action(&[context.sender_id], Some("remove")) + .await?; + assert_eq!(removal.assigned, 0); + assert_eq!(removal.removed, 2); + + for doc_id in &context.document_ids { + let correspondents = context.fetch_correspondents(*doc_id).await?; + assert_eq!(correspondents.len(), 1); + let entry = &correspondents[0]; + assert_eq!(entry.id, context.receiver_id); + assert_eq!(entry.name, TestContext::RECEIVER_NAME); + } + + context.app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_assign_correspondents_appends_new_entries() -> Result<()> { + let _lock = acquire_db_lock().await; + let context = TestContext::new("corresp_append").await?; + + context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + context + .assign_with_action(&[context.sender_id], Some("remove")) + .await?; + + let charlie_name = "Charlie"; + let charlie_id = context.create_correspondent(charlie_name).await?; + let add_result = context.assign(&[charlie_id]).await?; + assert_eq!(add_result.assigned, 2); + assert_eq!(add_result.removed, 0); + + for doc_id in &context.document_ids { + let correspondents = context.fetch_correspondents(*doc_id).await?; + assert_eq!(correspondents.len(), 2); + let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect(); + assert!(ids.contains(&context.receiver_id)); + assert!(ids.contains(&charlie_id)); + let names: Vec<_> = correspondents + .iter() + .map(|entry| entry.name.as_str()) + .collect(); + assert!(names.contains(&TestContext::RECEIVER_NAME)); + assert!(names.contains(&charlie_name)); + } + + context.app.cleanup().await?; + Ok(()) +} + +async fn upload_document( + app: &TestApp, + token: &str, + filename: &str, + contents: &[u8], +) -> Result { + let response = app + .upload_document( + "/api/documents", + filename, + "text/plain", + contents, + None, + token, + ) + .await?; + assert!(response.status().is_success()); + let body = body_to_vec(response.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + Ok(detail.document.id) +} + +async fn create_correspondent(app: &TestApp, token: &str, name: &str) -> Result { + let response = app + .post_json("/api/correspondents", &json!({ "name": name }), Some(token)) + .await?; + assert!(response.status().is_success()); + let body = body_to_vec(response.into_body()).await?; + let summary: CorrespondentSummary = serde_json::from_slice(&body)?; + Ok(summary.id) +} + +async fn fetch_document_detail( + app: &TestApp, + token: &str, + document_id: Uuid, +) -> Result { + let response = app + .get(&format!("/api/documents/{document_id}"), Some(token)) + .await?; + assert!(response.status().is_success()); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} diff --git a/backend/tests/data/issued_at_cases.yaml b/backend/tests/data/issued_at_cases.yaml new file mode 100644 index 0000000..f00aa4d --- /dev/null +++ b/backend/tests/data/issued_at_cases.yaml @@ -0,0 +1,584 @@ +# Derived test cases from the Paperless-ngx project (https://github.com/paperless-ngx/paperless-ngx). +# Copyright (c) Paperless-ngx contributors, licensed under the GNU GPL-3.0. +cases: + - name: date_format_1 + parser: parse_date + filename: null + content: "lorem ipsum 130218 lorem ipsum" + settings: {} + expected: + mode: none + + - name: date_format_2 + parser: parse_date + filename: null + content: "lorem ipsum 2018 lorem ipsum" + settings: {} + expected: + mode: none + + - name: date_format_3 + parser: parse_date + filename: null + content: "lorem ipsum 20180213 lorem ipsum" + settings: {} + expected: + mode: none + + - name: date_format_4 + parser: parse_date + filename: null + content: "lorem ipsum 13.02.2018 lorem ipsum" + settings: {} + expected: + mode: single + value: 2018-02-13 + + - name: date_format_5 + parser: parse_date + filename: null + content: "lorem ipsum 130218, 2018, 20180213 and lorem 13.02.2018 lorem ipsum" + settings: {} + expected: + mode: single + value: 2018-02-13 + + - name: date_format_6 + parser: parse_date + filename: null + content: | + lorem ipsum + Wohnort + 3100 + IBAN + AT87 4534 + 1234 + 1234 5678 + BIC + lorem ipsum + settings: {} + expected: + mode: none + + - name: date_format_7 + parser: parse_date + filename: null + content: | + lorem ipsum + März 2019 + lorem ipsum + settings: + DATE_PARSER_LANGUAGES: + - de + expected: + mode: single + value: 2019-03-01 + + - name: date_format_8 + parser: parse_date + filename: null + content: | + lorem ipsum + Wohnort + 3100 + IBAN + AT87 4534 + 1234 + 1234 5678 + BIC + lorem ipsum + März 2020 + settings: + DATE_PARSER_LANGUAGES: + - de + expected: + mode: single + value: 2020-03-01 + + - name: date_format_9 + parser: parse_date + filename: null + content: | + lorem ipsum + 27. Nullmonth 2020 + März 2020 + lorem ipsum + settings: + DATE_PARSER_LANGUAGES: + - de + expected: + mode: single + value: 2020-03-01 + + - name: date_format_10 + parser: parse_date + filename: null + content: "Customer Number Currency 22-MAR-2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_11 + parser: parse_date + filename: null + content: "Customer Number Currency 22 MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_12 + parser: parse_date + filename: null + content: "Customer Number Currency 22/MAR/2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_13 + parser: parse_date + filename: null + content: "Customer Number Currency 22.MAR.2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_14 + parser: parse_date + filename: null + content: "Customer Number Currency 22.MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_15 + parser: parse_date + filename: null + content: "Customer Number Currency 22.MAR.22 Credit Card 1934829304" + settings: {} + expected: + mode: none + + - name: date_format_16 + parser: parse_date + filename: null + content: "Customer Number Currency 22.MAR,22 Credit Card 1934829304" + settings: {} + expected: + mode: none + + - name: date_format_17 + parser: parse_date + filename: null + content: "Customer Number Currency 22,MAR,2022 Credit Card 1934829304" + settings: {} + expected: + mode: none + + - name: date_format_18 + parser: parse_date + filename: null + content: "Customer Number Currency 22 MAR,2022 Credit Card 1934829304" + settings: {} + expected: + mode: none + + - name: date_format_19 + parser: parse_date + filename: null + content: "Customer Number Currency 21st MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-21 + + - name: date_format_20 + parser: parse_date + filename: null + content: "Customer Number Currency 22nd March 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_21 + parser: parse_date + filename: null + content: "Customer Number Currency 2nd MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-02 + + - name: date_format_22 + parser: parse_date + filename: null + content: "Customer Number Currency 23rd MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-23 + + - name: date_format_23 + parser: parse_date + filename: null + content: "Customer Number Currency 24th MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-24 + + - name: date_format_24 + parser: parse_date + filename: null + content: "Customer Number Currency 21-MAR-2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-21 + + - name: date_format_25 + parser: parse_date + filename: null + content: "Customer Number Currency 25TH MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-25 + + - name: date_format_26 + parser: parse_date + filename: null + content: "CHASE 0 September 25, 2019 JPMorgan Chase Bank, NA. P0 Box 182051" + settings: {} + expected: + mode: single + value: 2019-09-25 + + - name: numeric_mdy_slash + parser: parse_date + filename: null + content: "03/17/2008" + settings: + DATE_ORDER: MDY + expected: + mode: single + value: 2008-03-17 + + - name: crazy_date_past + parser: parse_date + filename: null + content: "01-07-0590 00:00:00" + settings: {} + expected: + mode: none + + - name: crazy_date_future + parser: parse_date + filename: null + content: "01-07-2350 00:00:00" + settings: {} + expected: + mode: none + + - name: crazy_date_with_spaces + parser: parse_date + filename: null + content: "20 408000l 2475" + settings: {} + expected: + mode: none + + - name: utf_month_names_decembre + parser: parse_date + filename: null + content: "13 décembre 2023" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2023-12-13 + + - name: utf_month_names_aout + parser: parse_date + filename: null + content: "13 août 2022" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2022-08-13 + + - name: utf_month_names_marz + parser: parse_date + filename: null + content: "11 März 2020" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2020-03-11 + + - name: utf_month_names_ozujka + parser: parse_date + filename: null + content: "17. ožujka 2018." + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2018-03-17 + + - name: utf_month_names_veljace + parser: parse_date + filename: null + content: "1. veljače 2016." + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2016-02-01 + + - name: utf_month_names_unora + parser: parse_date + filename: null + content: "15. února 1985" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 1985-02-15 + + - name: utf_month_names_zari + parser: parse_date + filename: null + content: "30. září 2011" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2011-09-30 + + - name: utf_month_names_kvetna + parser: parse_date + filename: null + content: "28. května 1990" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 1990-05-28 + + - name: utf_month_names_grudzien + parser: parse_date + filename: null + content: "1. grudzień 1997" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 1997-12-01 + + - name: utf_month_names_subat + parser: parse_date + filename: null + content: "17 Şubat 2024" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2024-02-17 + + - name: utf_month_names_agustos + parser: parse_date + filename: null + content: "30 Ağustos 2012" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2012-08-30 + + - name: utf_month_names_eylul + parser: parse_date + filename: null + content: "17 Eylül 2000" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2000-09-17 + + - name: utf_month_names_oktober + parser: parse_date + filename: null + content: "5. október 1992" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + - hu + expected: + mode: single + value: 1992-10-05 + + - name: multiple_dates + parser: parse_date_generator + filename: null + content: | + This text has multiple dates. + For example 02.02.2018, 22 July 2022 and December 2021. + But not 24-12-9999 because it's in the future... + settings: {} + expected: + mode: multiple + value: + - 2018-02-02 + - 2022-07-22 + - 2021-12-01 + + - name: filename_date_parse_valid_ymd + parser: parse_date + filename: /tmp/Scan-2022-04-01.pdf + content: "No date in here" + settings: + FILENAME_DATE_ORDER: YMD + expected: + mode: single + value: 2022-04-01 + + - name: filename_date_parse_valid_dmy + parser: parse_date + filename: /tmp/Scan-10.01.2021.pdf + content: "No date in here" + settings: + FILENAME_DATE_ORDER: DMY + expected: + mode: single + value: 2021-01-10 + + - name: filename_date_parse_invalid + parser: parse_date + filename: "/tmp/20 408000l 2475 - test.pdf" + content: "No date in here" + settings: + FILENAME_DATE_ORDER: YMD + expected: + mode: none + + - name: filename_date_ignored_use_content + parser: parse_date + filename: /tmp/Scan-2022-04-01.pdf + content: "The matching date is 24.03.2022" + settings: + FILENAME_DATE_ORDER: YMD + IGNORE_DATES: + - 2022-04-01 + expected: + mode: single + value: 2022-03-24 + + - name: ignored_dates_default_order + parser: parse_date + filename: null + content: "lorem ipsum 110319, 20200117 and lorem 13.02.2018 lorem ipsum" + settings: + IGNORE_DATES: + - 2019-11-03 + - 2020-01-17 + expected: + mode: single + value: 2018-02-13 + + - name: ignored_dates_order_ymd + parser: parse_date + filename: null + content: "lorem ipsum 190311, 20200117 and lorem 13.02.2018 lorem ipsum" + settings: + FILENAME_DATE_ORDER: YMD + IGNORE_DATES: + - 2019-11-03 + - 2020-01-17 + expected: + mode: single + value: 2018-02-13 diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs new file mode 100644 index 0000000..8e2ad0f --- /dev/null +++ b/backend/tests/documents_flow.rs @@ -0,0 +1,2071 @@ +use anyhow::{anyhow, Result}; +use axum::http::StatusCode; +use diesel::prelude::*; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole, UploadExtras}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use papercrate::jobs::{mark_job_succeeded, JOB_PURGE_DOCUMENT}; +use papercrate::models::{Job, NewDocumentAsset}; +use papercrate::schema::document_assets; +use papercrate::workers::{purge::PurgeDocumentJob, JobExecution, JobHandler}; +use std::sync::Arc; +#[derive(Deserialize)] +struct DocumentDetail { + document: DocumentInfo, +} + +#[derive(Deserialize)] +struct ApiErrorResponse { + error: String, + #[serde(default)] + code: Option, + #[serde(default)] + details: Option, +} + +#[derive(Deserialize)] +struct DocumentInfo { + id: Uuid, + title: String, + filename: String, + original_name: String, + #[serde(default)] + folder_id: Option, + deleted_at: Option, + issued_at: Option, + metadata: Value, + tags: Vec, + #[serde(default)] + current_version: Option, +} + +#[derive(Deserialize)] +struct DocumentVersionPayload { + id: Uuid, + version_number: i32, + size_bytes: i64, + download: DownloadLinkPayload, + #[serde(default)] + assets: Vec, +} + +#[derive(Deserialize)] +struct DownloadLinkPayload { + url: String, + expires_at: i64, +} + +#[derive(Deserialize)] +struct DocumentVersionListItem { + id: Uuid, + version_number: i32, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +struct DocumentAssetInfo { + id: Uuid, + asset_type: String, +} + +#[derive(Deserialize)] +struct AssetProxyDetail { + id: Uuid, + download: Option, +} + +#[derive(Deserialize)] +struct DocumentListItem { + id: Uuid, + #[serde(default)] + current_version: Option, +} + +#[derive(Deserialize)] +struct BulkReanalyze { + queued: usize, +} + +#[derive(Deserialize)] +struct BulkMoveResult { + updated: usize, +} + +#[derive(Deserialize)] +struct BulkTagResult { + added: usize, + removed: usize, +} + +#[derive(Deserialize)] +struct TagSummary { + label: String, +} + +#[derive(Deserialize)] +struct AnalyzeJobPayload { + document_id: Uuid, + document_version_id: Uuid, + #[serde(default)] + force: bool, +} + +#[derive(Deserialize)] +struct FolderResponse { + folder: FolderInfo, +} + +#[derive(Deserialize)] +struct FolderInfo { + id: Uuid, +} + +#[derive(Deserialize)] +struct FolderContents { + documents: Vec, +} + +#[derive(Deserialize)] +struct TagResponse { + id: Uuid, +} + +#[derive(Serialize)] +struct BulkMoveRequest<'a> { + document_ids: &'a [Uuid], + folder_id: Option, +} + +#[derive(Serialize)] +struct BulkTagRequest<'a> { + document_ids: &'a [Uuid], + tag_ids: &'a [Uuid], + action: &'a str, +} + +#[derive(Serialize)] +struct CreateFolderRequest<'a> { + name: &'a str, + parent_id: Option, +} + +#[derive(Serialize)] +struct CreateTagPayload<'a> { + label: &'a str, + color: Option<&'a str>, +} + +#[tokio::test] +async fn upload_and_list_document() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "passw0rd"; + app.insert_user("dana", TestUserRole::Owner).await?; + let token = app.login_token("dana", password).await?; + + let file_bytes = b"example document body".to_vec(); + let upload = app + .upload_document( + "/api/documents", + "doc.txt", + "text/plain", + &file_bytes, + None, + &token, + ) + .await?; + { + let status = upload.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + assert_eq!(detail.document.original_name, "doc.txt"); + assert_eq!(detail.document.title, "doc"); + assert_eq!(detail.document.deleted_at, None); + assert!(detail.document.issued_at.is_none()); + assert!(detail.document.tags.is_empty()); + let current_version = detail + .document + .current_version + .as_ref() + .expect("current version detail"); + assert!(current_version.download.url.starts_with("/api/download/")); + assert!(current_version.download.expires_at > 0); + assert_eq!(current_version.version_number, 1); + assert_eq!(current_version.size_bytes, file_bytes.len() as i64); + assert!(current_version.assets.is_empty()); + + assert_eq!(app.storage().object_count().await, 1); + + let response = app.get("/api/documents", Some(&token)).await?; + { + let status = response.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let body = body_to_vec(response.into_body()).await?; + let mut list: Vec = serde_json::from_slice(&body)?; + assert_eq!(list.len(), 1); + let item = list.pop().unwrap(); + assert_eq!(item.id, detail.document.id); + assert_eq!( + item.current_version + .as_ref() + .map(|version| version.version_number), + Some(1) + ); + assert!(item + .current_version + .as_ref() + .expect("list current version") + .download + .url + .starts_with("/api/download/")); + + let redirect = app.get(¤t_version.download.url, None).await?; + assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT); + let location = redirect + .headers() + .get("location") + .expect("redirect location header"); + let location = location.to_str().expect("location header utf8"); + assert!(!location.is_empty()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn upload_document_with_custom_title_sets_filename() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "passw0rd"; + app.insert_user("nora", TestUserRole::Owner).await?; + let token = app.login_token("nora", password).await?; + + let file_bytes = b"example contract body".to_vec(); + let title = "Vendor Contract"; + let original_filename = "scan.pdf"; + + let upload = app + .upload_document_with_options( + "/api/documents", + original_filename, + "application/pdf", + &file_bytes, + None, + Some(title), + None, + &token, + ) + .await?; + { + let status = upload.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + assert_eq!(detail.document.title, title); + assert_eq!(detail.document.filename, format!("{title}.pdf")); + assert_eq!(detail.document.original_name, original_filename); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn asset_detail_uses_proxy_urls_when_configured() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::with_config(|config| config.proxy_downloads = true).await?; + let tenant_id = app.tenant_id().await?; + + let username = "proxy-assets"; + let password = "secret"; + app.insert_user(username, TestUserRole::Owner).await?; + let token = app.login_token(username, password).await?; + + let upload = app + .upload_document( + "/api/documents", + "proxy.pdf", + "application/pdf", + b"dummy", + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::CREATED); + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + let document = detail.document; + let version = document + .current_version + .as_ref() + .ok_or_else(|| anyhow!("current version missing"))?; + + let mut conn = app + .state + .db_for_tenant(tenant_id) + .map_err(|err| anyhow!("tenant connection: {err:?}"))?; + + let asset_id = Uuid::new_v4(); + let s3_key = "objects/preview.png".to_string(); + + diesel::insert_into(document_assets::table) + .values(&NewDocumentAsset { + id: asset_id, + document_version_id: version.id, + asset_type: "preview".to_string(), + mime_type: "image/png".to_string(), + metadata: json!({}), + s3_key: s3_key.clone(), + tenant_id, + }) + .execute(&mut conn)?; + + drop(conn); + + let response = app + .get(&format!("/api/assets/{asset_id}"), Some(&token)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let asset_detail: AssetProxyDetail = serde_json::from_slice(&body)?; + assert_eq!(asset_detail.id, asset_id); + let download = asset_detail + .download + .as_ref() + .ok_or_else(|| anyhow!("missing download link"))?; + assert!(download.url.starts_with("/api/download/")); + assert!(download.expires_at > 0); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn document_list_sorting_controls() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "passw0rd"; + app.insert_user("sorting", TestUserRole::Owner).await?; + let token = app.login_token("sorting", password).await?; + + let first = app + .upload_document_with_options( + "/api/documents", + "alpha.txt", + "text/plain", + b"alpha", + None, + Some("Alpha"), + None, + &token, + ) + .await?; + assert!(first.status().is_success()); + let first_body = body_to_vec(first.into_body()).await?; + let first_doc: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document_with_options( + "/api/documents", + "zulu.txt", + "text/plain", + b"zulu", + None, + Some("Zulu"), + None, + &token, + ) + .await?; + assert!(second.status().is_success()); + let second_body = body_to_vec(second.into_body()).await?; + let second_doc: DocumentDetail = serde_json::from_slice(&second_body)?; + + // Default sort should be title ASC => Alpha first. + let default_resp = app.get("/api/documents", Some(&token)).await?; + assert_eq!(default_resp.status(), StatusCode::OK); + let default_body = body_to_vec(default_resp.into_body()).await?; + let default_list: Vec = serde_json::from_slice(&default_body)?; + assert_eq!(default_list.len(), 2); + assert_eq!(default_list[0].id, first_doc.document.id); + assert_eq!(default_list[1].id, second_doc.document.id); + + // Sort by created_at DESC, expecting most recent (second) first. + let created_desc = app + .get("/api/documents?sort=created_at&dir=desc", Some(&token)) + .await?; + assert_eq!(created_desc.status(), StatusCode::OK); + let created_body = body_to_vec(created_desc.into_body()).await?; + let created_list: Vec = serde_json::from_slice(&created_body)?; + assert_eq!(created_list.len(), 2); + assert_eq!(created_list[0].id, second_doc.document.id); + assert_eq!(created_list[1].id, first_doc.document.id); + + // Folder contents respects the same parameters. + let folder_resp = app + .get( + "/api/folders/root/contents?sort=created_at&dir=desc", + Some(&token), + ) + .await?; + assert_eq!(folder_resp.status(), StatusCode::OK); + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder_contents: FolderContents = serde_json::from_slice(&folder_body)?; + assert_eq!(folder_contents.documents.len(), 2); + assert_eq!(folder_contents.documents[0].id, second_doc.document.id); + assert_eq!(folder_contents.documents[1].id, first_doc.document.id); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn duplicate_and_restore_document() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "pass1234"; + app.insert_user("sam", TestUserRole::Owner).await?; + let token = app.login_token("sam", password).await?; + + let payload = b"same bytes".to_vec(); + let first = app + .upload_document( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + &token, + ) + .await?; + let first_status = first.status(); + let first_body = body_to_vec(first.into_body()).await?; + assert!( + first_status == StatusCode::OK + || first_status == StatusCode::CREATED + || first_status == StatusCode::NO_CONTENT, + "unexpected first upload status {} with body {}", + first_status, + String::from_utf8_lossy(&first_body) + ); + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + &token, + ) + .await?; + let second_status = second.status(); + let second_body = body_to_vec(second.into_body()).await?; + assert_eq!(second_status, StatusCode::CONFLICT); + let second_error: ApiErrorResponse = serde_json::from_slice(&second_body)?; + assert_eq!(second_error.code.as_deref(), Some("duplicate_document")); + let conflict_id = second_error + .details + .as_ref() + .and_then(|details| details.get("conflict_document_id")) + .and_then(|value| value.as_str()) + .and_then(|value| Uuid::parse_str(value).ok()) + .expect("conflict_document_id present"); + assert_eq!(conflict_id, first_detail.document.id); + assert_eq!(app.storage().object_count().await, 1); + + let delete = app + .post_json( + &format!("/api/documents/{}/trash", first_detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + + let trashed_conflict = app + .upload_document( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + &token, + ) + .await?; + assert_eq!(trashed_conflict.status(), StatusCode::CONFLICT); + let trashed_body = body_to_vec(trashed_conflict.into_body()).await?; + let trashed_error: ApiErrorResponse = serde_json::from_slice(&trashed_body)?; + assert_eq!(trashed_error.code.as_deref(), Some("duplicate_document")); + assert!(trashed_error.error.contains("trash")); + let trashed_details = trashed_error.details.as_ref().expect("details present"); + assert_eq!( + trashed_details + .get("conflict_document_in_trash") + .and_then(|value| value.as_bool()), + Some(true) + ); + + let third = app + .upload_document_with_extras( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + UploadExtras { + title: None, + metadata_json: None, + tag_ids_json: None, + correspondents_json: None, + issued_at: None, + skip_existing: Some(false), + }, + &token, + ) + .await?; + let third_status = third.status(); + let third_body = body_to_vec(third.into_body()).await?; + assert!( + third_status == StatusCode::OK + || third_status == StatusCode::CREATED + || third_status == StatusCode::NO_CONTENT, + "unexpected third upload status {} with body {}", + third_status, + String::from_utf8_lossy(&third_body) + ); + let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?; + + assert_eq!(third_detail.document.id, first_detail.document.id); + assert_eq!(third_detail.document.deleted_at, None); + assert!(third_detail + .document + .current_version + .as_ref() + .expect("third current version") + .assets + .is_empty()); + assert_eq!(app.storage().object_count().await, 1); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn upload_skips_existing_when_requested() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "skip-doc"; + app.insert_user("skip", TestUserRole::Owner).await?; + let token = app.login_token("skip", password).await?; + + let primary_tag_payload = CreateTagPayload { + label: "primary", + color: None, + }; + let primary_tag_resp = app + .post_json("/api/tags", &primary_tag_payload, Some(&token)) + .await?; + { + let status = primary_tag_resp.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let primary_tag_body = body_to_vec(primary_tag_resp.into_body()).await?; + let primary_tag: TagResponse = serde_json::from_slice(&primary_tag_body)?; + + let payload = b"identical document payload"; + let primary_tag_ids = format!("[\"{}\"]", primary_tag.id); + let extras = UploadExtras { + title: Some("Original"), + metadata_json: None, + tag_ids_json: Some(primary_tag_ids.as_str()), + correspondents_json: None, + issued_at: None, + skip_existing: None, + }; + + let first_upload = app + .upload_document_with_extras( + "/api/documents", + "original.pdf", + "application/pdf", + payload, + None, + extras, + &token, + ) + .await?; + { + let status = first_upload.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let first_body = body_to_vec(first_upload.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let alt_tag_payload = CreateTagPayload { + label: "alternate", + color: None, + }; + let alt_tag_resp = app + .post_json("/api/tags", &alt_tag_payload, Some(&token)) + .await?; + { + let status = alt_tag_resp.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let alt_tag_body = body_to_vec(alt_tag_resp.into_body()).await?; + let alt_tag: TagResponse = serde_json::from_slice(&alt_tag_body)?; + + let alt_tag_ids = format!("[\"{}\"]", alt_tag.id); + let skip_extras = UploadExtras { + title: Some("Updated"), + metadata_json: None, + tag_ids_json: Some(alt_tag_ids.as_str()), + correspondents_json: None, + issued_at: None, + skip_existing: Some(true), + }; + + let skip_resp = app + .upload_document_with_extras( + "/api/documents", + "ignored.pdf", + "application/pdf", + payload, + None, + skip_extras, + &token, + ) + .await?; + assert_eq!(skip_resp.status(), StatusCode::CONFLICT); + let skip_body = body_to_vec(skip_resp.into_body()).await?; + let skip_error: ApiErrorResponse = serde_json::from_slice(&skip_body)?; + assert_eq!(skip_error.code.as_deref(), Some("duplicate_document")); + let conflict_id = skip_error + .details + .as_ref() + .and_then(|details| details.get("conflict_document_id")) + .and_then(|value| value.as_str()) + .and_then(|value| Uuid::parse_str(value).ok()) + .expect("conflict_document_id present"); + assert_eq!(conflict_id, first_detail.document.id); + + let fetch = app + .get( + &format!("/api/documents/{}", first_detail.document.id), + Some(&token), + ) + .await?; + { + let status = fetch.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let fetch_body = body_to_vec(fetch.into_body()).await?; + let fetched: DocumentDetail = serde_json::from_slice(&fetch_body)?; + + assert_eq!(fetched.document.id, first_detail.document.id); + assert_eq!(fetched.document.title, first_detail.document.title); + assert_eq!(fetched.document.tags.len(), 1); + assert_eq!(fetched.document.tags[0].label, "primary"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn filter_documents_without_tags() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "tagfilter"; + app.insert_user("tagfilter", TestUserRole::Owner).await?; + let token = app.login_token("tagfilter", password).await?; + + // Create a tag and upload a document that uses it. + let tag_payload = CreateTagPayload { + label: "with-tag", + color: None, + }; + let tag_resp = app + .post_json("/api/tags", &tag_payload, Some(&token)) + .await?; + assert!(tag_resp.status().is_success()); + let tag_body = body_to_vec(tag_resp.into_body()).await?; + let tag: TagResponse = serde_json::from_slice(&tag_body)?; + + let tag_json = format!("[\"{}\"]", tag.id); + let tagged_upload = app + .upload_document_with_extras( + "/api/documents", + "with-tag.txt", + "text/plain", + b"tagged", + None, + UploadExtras { + title: Some("With Tag"), + metadata_json: None, + tag_ids_json: Some(tag_json.as_str()), + correspondents_json: None, + issued_at: None, + skip_existing: None, + }, + &token, + ) + .await?; + assert!(tagged_upload.status().is_success()); + + let untagged_upload = app + .upload_document( + "/api/documents", + "without-tag.txt", + "text/plain", + b"untagged", + None, + &token, + ) + .await?; + assert!(untagged_upload.status().is_success()); + let untagged_body = body_to_vec(untagged_upload.into_body()).await?; + let untagged_detail: DocumentDetail = serde_json::from_slice(&untagged_body)?; + + // Sanity: both documents appear in the default listing. + let all_resp = app.get("/api/documents", Some(&token)).await?; + assert_eq!(all_resp.status(), StatusCode::OK); + let all_body = body_to_vec(all_resp.into_body()).await?; + let all_docs: Vec = serde_json::from_slice(&all_body)?; + assert_eq!(all_docs.len(), 2); + + // Filter for documents without tags. + let none_resp = app.get("/api/documents?tags=none", Some(&token)).await?; + assert_eq!(none_resp.status(), StatusCode::OK); + let none_body = body_to_vec(none_resp.into_body()).await?; + let none_docs: Vec = serde_json::from_slice(&none_body)?; + assert_eq!(none_docs.len(), 1); + assert_eq!(none_docs[0].id, untagged_detail.document.id); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_move_documents_to_folder() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulkmove"; + app.insert_user("mover", TestUserRole::Owner).await?; + let token = app.login_token("mover", password).await?; + + let alpha = app + .upload_document( + "/api/documents", + "alpha.txt", + "text/plain", + b"alpha", + None, + &token, + ) + .await?; + { + let status = alpha.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let alpha_body = body_to_vec(alpha.into_body()).await?; + let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?; + + let beta = app + .upload_document( + "/api/documents", + "beta.txt", + "text/plain", + b"beta", + None, + &token, + ) + .await?; + { + let status = beta.status(); + assert!(status.is_success(), "status was {}", status); + } + let beta_body = body_to_vec(beta.into_body()).await?; + let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?; + + let folder_resp = app + .post_json( + "/api/folders", + &CreateFolderRequest { + name: "Archives", + parent_id: None, + }, + Some(&token), + ) + .await?; + { + let status = folder_resp.status(); + assert!(status.is_success(), "status was {}", status); + } + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder: FolderResponse = serde_json::from_slice(&folder_body)?; + + let move_resp = app + .post_json( + "/api/documents/bulk/move", + &BulkMoveRequest { + document_ids: &[alpha_detail.document.id, beta_detail.document.id], + folder_id: Some(folder.folder.id), + }, + Some(&token), + ) + .await?; + let move_status = move_resp.status(); + let move_body = body_to_vec(move_resp.into_body()).await?; + assert!( + move_status.is_success(), + "status was {} body {}", + move_status, + String::from_utf8_lossy(&move_body) + ); + let result: BulkMoveResult = serde_json::from_slice(&move_body)?; + assert_eq!(result.updated, 2); + + let folder_contents = app + .get( + &format!("/api/folders/{}/contents", folder.folder.id), + Some(&token), + ) + .await?; + { + let status = folder_contents.status(); + assert!(status.is_success(), "status was {}", status); + } + let folder_body = body_to_vec(folder_contents.into_body()).await?; + let folder_docs: FolderContents = serde_json::from_slice(&folder_body)?; + let moved_ids: Vec<_> = folder_docs.documents.iter().map(|doc| doc.id).collect(); + assert!(moved_ids.contains(&alpha_detail.document.id)); + assert!(moved_ids.contains(&beta_detail.document.id)); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root_docs: FolderContents = serde_json::from_slice(&root_body)?; + assert!(root_docs + .documents + .iter() + .all(|doc| doc.id != alpha_detail.document.id && doc.id != beta_detail.document.id)); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_add_tags_for_selection() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulktags"; + app.insert_user("tagger", TestUserRole::Owner).await?; + let token = app.login_token("tagger", password).await?; + + let first = app + .upload_document( + "/api/documents", + "notes.txt", + "text/plain", + b"notes", + None, + &token, + ) + .await?; + { + let status = first.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let first_body = body_to_vec(first.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "report.txt", + "text/plain", + b"report", + None, + &token, + ) + .await?; + { + let status = second.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let urgent_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Urgent", + color: None, + }, + Some(&token), + ) + .await?; + { + let status = urgent_tag.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let urgent_body = body_to_vec(urgent_tag.into_body()).await?; + let urgent: TagResponse = serde_json::from_slice(&urgent_body)?; + + let review_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Review", + color: None, + }, + Some(&token), + ) + .await?; + { + let status = review_tag.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let review_body = body_to_vec(review_tag.into_body()).await?; + let review: TagResponse = serde_json::from_slice(&review_body)?; + + let add_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id, review.id], + action: "add", + }, + Some(&token), + ) + .await?; + { + let status = add_resp.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let add_body = body_to_vec(add_resp.into_body()).await?; + let add_result: BulkTagResult = serde_json::from_slice(&add_body)?; + assert_eq!(add_result.added, 4); + + for doc_id in [&first_detail.document.id, &second_detail.document.id] { + let refreshed = app + .get(&format!("/api/documents/{}", doc_id), Some(&token)) + .await?; + { + let status = refreshed.status(); + assert!(status == StatusCode::OK || status == StatusCode::CREATED); + } + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + let labels: Vec<_> = detail + .document + .tags + .iter() + .map(|tag| tag.label.as_str()) + .collect(); + assert!(labels.contains(&"Urgent")); + assert!(labels.contains(&"Review")); + } + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_remove_tags_from_selection() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulktagremove"; + app.insert_user("tagrem", TestUserRole::Owner).await?; + let token = app.login_token("tagrem", password).await?; + + let first = app + .upload_document( + "/api/documents", + "meeting-notes.txt", + "text/plain", + b"notes", + None, + &token, + ) + .await?; + let first_body = body_to_vec(first.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "draft.txt", + "text/plain", + b"draft", + None, + &token, + ) + .await?; + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let urgent_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Urgent", + color: None, + }, + Some(&token), + ) + .await?; + let urgent_body = body_to_vec(urgent_tag.into_body()).await?; + let urgent: TagResponse = serde_json::from_slice(&urgent_body)?; + + let review_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Review", + color: None, + }, + Some(&token), + ) + .await?; + let review_body = body_to_vec(review_tag.into_body()).await?; + let review: TagResponse = serde_json::from_slice(&review_body)?; + + let seed_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id, review.id], + action: "add", + }, + Some(&token), + ) + .await?; + assert!(seed_resp.status().is_success()); + + let remove_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id], + action: "remove", + }, + Some(&token), + ) + .await?; + assert!(remove_resp.status().is_success()); + let remove_body = body_to_vec(remove_resp.into_body()).await?; + let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?; + assert_eq!(remove_result.removed, 2); + assert_eq!(remove_result.added, 0); + + for doc_id in [&first_detail.document.id, &second_detail.document.id] { + let refreshed = app + .get(&format!("/api/documents/{}", doc_id), Some(&token)) + .await?; + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + let labels: Vec<_> = detail + .document + .tags + .iter() + .map(|tag| tag.label.as_str()) + .collect(); + assert!(!labels.contains(&"Urgent")); + assert!(labels.contains(&"Review")); + } + + let idempotent_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id], + action: "remove", + }, + Some(&token), + ) + .await?; + assert!(idempotent_resp.status().is_success()); + let idempotent_body = body_to_vec(idempotent_resp.into_body()).await?; + let idempotent_result: BulkTagResult = serde_json::from_slice(&idempotent_body)?; + assert_eq!(idempotent_result.removed, 0); + assert_eq!(idempotent_result.added, 0); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_reanalyze_selected_documents() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "subsetrean"; + app.insert_user("subset", TestUserRole::Owner).await?; + let token = app.login_token("subset", password).await?; + + app.clear_jobs().await?; + + let first = app + .upload_document( + "/api/documents", + "doc-one.txt", + "text/plain", + b"one", + None, + &token, + ) + .await?; + let first_body = body_to_vec(first.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "doc-two.txt", + "text/plain", + b"two", + None, + &token, + ) + .await?; + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let third = app + .upload_document( + "/api/documents", + "doc-three.txt", + "text/plain", + b"three", + None, + &token, + ) + .await?; + let third_body = body_to_vec(third.into_body()).await?; + let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?; + + app.clear_jobs().await?; + + let response = app + .post_json( + "/api/documents/bulk/reanalyze", + &serde_json::json!({ + "document_ids": [ + first_detail.document.id, + third_detail.document.id + ], + "force": true + }), + Some(&token), + ) + .await?; + assert_eq!(response.status(), StatusCode::ACCEPTED); + let body = body_to_vec(response.into_body()).await?; + let bulk: BulkReanalyze = serde_json::from_slice(&body)?; + assert_eq!(bulk.queued, 2); + + let jobs = app.jobs_by_type("analyze-document").await?; + assert_eq!(jobs.len(), 2); + let mut payload_docs = Vec::new(); + for job in jobs { + let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?; + assert!(payload.force); + payload_docs.push((payload.document_id, payload.document_version_id)); + } + + assert!(payload_docs + .iter() + .all(|(doc_id, _)| *doc_id != second_detail.document.id)); + + let mut expected = vec![ + ( + first_detail.document.id, + first_detail + .document + .current_version + .as_ref() + .expect("first current version") + .id, + ), + ( + third_detail.document.id, + third_detail + .document + .current_version + .as_ref() + .expect("third current version") + .id, + ), + ]; + payload_docs.sort(); + expected.sort(); + assert_eq!(payload_docs, expected); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_updates_title_and_handles_conflict() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-title"; + app.insert_user("editor", TestUserRole::Owner).await?; + let token = app.login_token("editor", password).await?; + + let first_upload = app + .upload_document( + "/api/documents", + "report.pdf", + "application/pdf", + b"fake pdf contents", + None, + &token, + ) + .await?; + let first_body = body_to_vec(first_upload.into_body()).await?; + let mut first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let update = app + .patch_json( + &format!("/api/documents/{}", first_detail.document.id), + &json!({ + "title": "Quarterly Summary" + }), + Some(&token), + ) + .await?; + assert_eq!(update.status(), StatusCode::OK); + let update_body = body_to_vec(update.into_body()).await?; + first_detail = serde_json::from_slice(&update_body)?; + assert_eq!(first_detail.document.title, "Quarterly Summary"); + assert_eq!(first_detail.document.filename, "Quarterly Summary.pdf"); + + let second_upload = app + .upload_document( + "/api/documents", + "notes.pdf", + "application/pdf", + b"other pdf", + None, + &token, + ) + .await?; + let second_body = body_to_vec(second_upload.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let conflict = app + .patch_json( + &format!("/api/documents/{}", second_detail.document.id), + &json!({ + "title": "Quarterly Summary" + }), + Some(&token), + ) + .await?; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + let conflict_body = body_to_vec(conflict.into_body()).await?; + let conflict_json: ApiErrorResponse = serde_json::from_slice(&conflict_body)?; + assert_eq!(conflict_json.code.as_deref(), Some("duplicate_filename")); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_updates_and_clears_issued_at() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-issued"; + app.insert_user("scheduler", TestUserRole::Owner).await?; + let token = app.login_token("scheduler", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "invoice.txt", + "text/plain", + b"invoice contents", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + assert!(detail.document.issued_at.is_none()); + + let issued_at = "2021-02-03T04:05:06Z"; + let response = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "issued_at": issued_at + }), + Some(&token), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let patched: DocumentDetail = serde_json::from_slice(&body)?; + assert_eq!( + patched.document.issued_at.as_deref(), + Some("2021-02-03T04:05:06+00:00") + ); + + let cleared = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "issued_at": null + }), + Some(&token), + ) + .await?; + let cleared_status = cleared.status(); + let cleared_body = body_to_vec(cleared.into_body()).await?; + assert!( + cleared_status == StatusCode::OK, + "clear issued_at failed status {} body {}", + cleared_status, + String::from_utf8_lossy(&cleared_body) + ); + let cleared_detail: DocumentDetail = serde_json::from_slice(&cleared_body)?; + assert!(cleared_detail.document.issued_at.is_none()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_metadata_merge_and_replace() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-meta"; + app.insert_user("curator", TestUserRole::Owner).await?; + let token = app.login_token("curator", password).await?; + + let initial_metadata = r#"{"existing":{"keep":true},"other":1}"#; + let upload = app + .upload_document_with_options( + "/api/documents", + "meta.txt", + "text/plain", + b"meta", + None, + None, + Some(initial_metadata), + &token, + ) + .await?; + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + assert_eq!( + detail.document.metadata, + json!({ + "existing": {"keep": true}, + "other": 1 + }) + ); + + let merge_response = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "value": { + "existing": {"update": 5}, + "added": "new" + } + } + }), + Some(&token), + ) + .await?; + assert_eq!(merge_response.status(), StatusCode::OK); + let merge_body = body_to_vec(merge_response.into_body()).await?; + let merged: DocumentDetail = serde_json::from_slice(&merge_body)?; + assert_eq!( + merged.document.metadata, + json!({ + "existing": {"keep": true, "update": 5}, + "other": 1, + "added": "new" + }) + ); + + let replace_response = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "replace": true, + "value": {"fresh": true} + } + }), + Some(&token), + ) + .await?; + assert_eq!(replace_response.status(), StatusCode::OK); + let replace_body = body_to_vec(replace_response.into_body()).await?; + let replaced: DocumentDetail = serde_json::from_slice(&replace_body)?; + assert_eq!(replaced.document.metadata, json!({"fresh": true})); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_validation_errors() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-errors"; + app.insert_user("auditor", TestUserRole::Owner).await?; + let token = app.login_token("auditor", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "errors.txt", + "text/plain", + b"errors", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let empty_title = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ "title": " " }), + Some(&token), + ) + .await?; + assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST); + let title_body = body_to_vec(empty_title.into_body()).await?; + let title_error: ApiErrorResponse = serde_json::from_slice(&title_body)?; + assert_eq!(title_error.error, "title must not be empty"); + + let empty_issued = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ "issued_at": "" }), + Some(&token), + ) + .await?; + assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST); + let issued_body = body_to_vec(empty_issued.into_body()).await?; + let issued_error: ApiErrorResponse = serde_json::from_slice(&issued_body)?; + assert_eq!(issued_error.error, "issued_at must not be empty"); + + let invalid_merge = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "value": 5 + } + }), + Some(&token), + ) + .await?; + assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST); + let merge_body = body_to_vec(invalid_merge.into_body()).await?; + let merge_error: ApiErrorResponse = serde_json::from_slice(&merge_body)?; + assert_eq!( + merge_error.error, + "metadata value must be a JSON object when replace is false" + ); + + let replace_scalar = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "replace": true, + "value": 5 + } + }), + Some(&token), + ) + .await?; + assert_eq!(replace_scalar.status(), StatusCode::OK); + let replace_body = body_to_vec(replace_scalar.into_body()).await?; + let replace_detail: DocumentDetail = serde_json::from_slice(&replace_body)?; + assert_eq!(replace_detail.document.metadata, json!(5)); + + let merge_after_scalar = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "value": { "new": 1 } + } + }), + Some(&token), + ) + .await?; + assert_eq!(merge_after_scalar.status(), StatusCode::BAD_REQUEST); + let merge_after_body = body_to_vec(merge_after_scalar.into_body()).await?; + let merge_after_error: ApiErrorResponse = serde_json::from_slice(&merge_after_body)?; + assert_eq!( + merge_after_error.error, + "existing metadata is not an object; set replace=true to overwrite" + ); + + let malformed_timestamp = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ "issued_at": "not-a-timestamp" }), + Some(&token), + ) + .await?; + assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST); + let malformed_body = body_to_vec(malformed_timestamp.into_body()).await?; + let malformed_error: ApiErrorResponse = serde_json::from_slice(&malformed_body)?; + assert!( + malformed_error + .error + .starts_with("issued_at must be an RFC3339 timestamp"), + "unexpected error: {}", + malformed_error.error + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_updates_multiple_fields() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-multi"; + app.insert_user("planner", TestUserRole::Owner).await?; + let token = app.login_token("planner", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "multi.pdf", + "application/pdf", + b"multi", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let response = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "title": "Annual Report", + "issued_at": "2022-05-01T12:00:00Z", + "metadata": { + "value": { + "department": "finance", + "year": 2022 + } + } + }), + Some(&token), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let response_body = body_to_vec(response.into_body()).await?; + let updated: DocumentDetail = serde_json::from_slice(&response_body)?; + assert_eq!(updated.document.title, "Annual Report"); + assert_eq!(updated.document.filename, "Annual Report.pdf"); + assert_eq!( + updated.document.issued_at.as_deref(), + Some("2022-05-01T12:00:00+00:00") + ); + assert_eq!( + updated.document.metadata, + json!({ + "department": "finance", + "year": 2022 + }) + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn list_documents_by_status_filter() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "statusfilter"; + app.insert_user("statususer", TestUserRole::Owner).await?; + let token = app.login_token("statususer", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "trash.txt", + "text/plain", + b"trash", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let delete_resp = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT); + + let active_resp = app.get("/api/documents", Some(&token)).await?; + assert!(active_resp.status().is_success()); + let active_body = body_to_vec(active_resp.into_body()).await?; + let active_docs: Vec = serde_json::from_slice(&active_body)?; + assert!(active_docs.iter().all(|doc| doc.id != detail.document.id)); + + let deleted_resp = app + .get("/api/documents?status=deleted", Some(&token)) + .await?; + assert!(deleted_resp.status().is_success()); + let deleted_body = body_to_vec(deleted_resp.into_body()).await?; + let deleted_docs: Vec = serde_json::from_slice(&deleted_body)?; + assert!(deleted_docs.iter().any(|doc| doc.id == detail.document.id)); + + let all_resp = app.get("/api/documents?status=all", Some(&token)).await?; + assert!(all_resp.status().is_success()); + let all_body = body_to_vec(all_resp.into_body()).await?; + let all_docs: Vec = serde_json::from_slice(&all_body)?; + assert!(all_docs.iter().any(|doc| doc.id == detail.document.id)); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn trash_document_requires_active_state() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "trashstate"; + app.insert_user("trashstate", TestUserRole::Owner).await?; + let token = app.login_token("trashstate", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "trash-once.txt", + "text/plain", + b"trash", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let first = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(first.status(), StatusCode::NO_CONTENT); + + let second = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(second.status(), StatusCode::CONFLICT); + + app.cleanup().await?; + Ok(()) +} +#[tokio::test] +async fn purge_document_removes_data() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "purge"; + app.insert_user("purger", TestUserRole::Owner).await?; + let token = app.login_token("purger", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "purge.bin", + "application/octet-stream", + b"permanent", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + let document_id = detail.document.id; + + assert_eq!(app.storage().object_count().await, 1); + + let trash_resp = app + .post_json( + &format!("/api/documents/{}/trash", document_id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(trash_resp.status(), StatusCode::NO_CONTENT); + + let delete_resp = app + .delete(&format!("/api/documents/{}", document_id), Some(&token)) + .await?; + assert_eq!(delete_resp.status(), StatusCode::ACCEPTED); + + let duplicate_delete = app + .delete(&format!("/api/documents/{}", document_id), Some(&token)) + .await?; + assert_eq!(duplicate_delete.status(), StatusCode::ACCEPTED); + + let purge_job_count: i64 = app + .with_conn(|conn| { + use diesel::dsl::count_star; + use diesel::prelude::*; + use papercrate::schema::jobs::dsl::*; + + let count: i64 = jobs + .filter(job_type.eq(JOB_PURGE_DOCUMENT)) + .select(count_star()) + .get_result(conn)?; + Ok(count) + }) + .await?; + assert_eq!(purge_job_count, 1); + + let job: Job = app + .with_conn(|conn| { + use diesel::prelude::*; + use papercrate::schema::jobs::dsl::*; + + let job = jobs + .filter(job_type.eq(JOB_PURGE_DOCUMENT)) + .order(created_at.desc()) + .first(conn)?; + Ok(job) + }) + .await?; + + let handler = PurgeDocumentJob::new(); + let state = Arc::new(app.state.clone()); + let storage = app + .state + .storage_for_tenant(job.tenant_id.expect("job should have tenant")) + .map_err(|err| anyhow!("tenant storage unavailable: {err:?}"))?; + let execution = handler.handle(state, job.clone(), storage).await; + assert!(matches!(execution, JobExecution::Success)); + + app.with_conn(move |conn| { + mark_job_succeeded(conn, job.id)?; + Ok(()) + }) + .await?; + + let fetch = app + .get(&format!("/api/documents/{}", document_id), Some(&token)) + .await?; + assert_eq!(fetch.status(), StatusCode::NOT_FOUND); + + assert_eq!(app.storage().object_count().await, 0); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_document_requires_trash() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "conflict"; + app.insert_user("conflict-user", TestUserRole::Owner) + .await?; + let token = app.login_token("conflict-user", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "conflict.bin", + "application/octet-stream", + b"restore", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let delete_resp = app + .delete( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + assert_eq!(delete_resp.status(), StatusCode::CONFLICT); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn restore_document_to_original_and_custom_folder() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "restoretest"; + app.insert_user("restorer", TestUserRole::Owner).await?; + let token = app.login_token("restorer", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "to-restore.txt", + "text/plain", + b"restore", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let delete_resp = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT); + + let restore_resp = app + .post_json( + &format!("/api/documents/{}/restore", detail.document.id), + &serde_json::json!({}), + Some(&token), + ) + .await?; + assert_eq!(restore_resp.status(), StatusCode::NO_CONTENT); + + let fetched = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + assert!(fetched.status().is_success()); + let fetched_body = body_to_vec(fetched.into_body()).await?; + let fetched_detail: DocumentDetail = serde_json::from_slice(&fetched_body)?; + assert!(fetched_detail.document.deleted_at.is_none()); + + let folder_resp = app + .post_json( + "/api/folders", + &CreateFolderRequest { + name: "Restored", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert!(folder_resp.status().is_success()); + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder: FolderResponse = serde_json::from_slice(&folder_body)?; + + let delete_again = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(delete_again.status(), StatusCode::NO_CONTENT); + + let restore_custom = app + .post_json( + &format!("/api/documents/{}/restore", detail.document.id), + &serde_json::json!({ + "folder_id": folder.folder.id + }), + Some(&token), + ) + .await?; + assert_eq!(restore_custom.status(), StatusCode::NO_CONTENT); + + let fetched_custom = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + let fetched_custom_body = body_to_vec(fetched_custom.into_body()).await?; + let fetched_custom_detail: DocumentDetail = serde_json::from_slice(&fetched_custom_body)?; + assert_eq!( + fetched_custom_detail.document.folder_id, + Some(folder.folder.id) + ); + assert!(fetched_custom_detail.document.deleted_at.is_none()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn list_document_versions_and_fetch_detail() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "versionlist"; + app.insert_user("versions", TestUserRole::Owner).await?; + let token = app.login_token("versions", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "versioned.txt", + "text/plain", + b"versioned", + None, + &token, + ) + .await?; + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + + let version_id = detail + .document + .current_version + .as_ref() + .expect("current version") + .id; + + let list_resp = app + .get( + &format!("/api/documents/{}/versions", detail.document.id), + Some(&token), + ) + .await?; + assert!(list_resp.status().is_success()); + let list_body = body_to_vec(list_resp.into_body()).await?; + let versions: Vec = serde_json::from_slice(&list_body)?; + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].id, version_id); + assert_eq!(versions[0].version_number, 1); + + let detail_resp = app + .get( + &format!( + "/api/documents/{}/versions/{}", + detail.document.id, version_id + ), + Some(&token), + ) + .await?; + assert!(detail_resp.status().is_success()); + let detail_body = body_to_vec(detail_resp.into_body()).await?; + let version_detail: DocumentVersionPayload = serde_json::from_slice(&detail_body)?; + assert_eq!(version_detail.id, version_id); + assert!(version_detail.download.url.starts_with("/api/download/")); + assert!(version_detail.download.expires_at > 0); + assert!(version_detail.assets.is_empty()); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/download_flow.rs b/backend/tests/download_flow.rs new file mode 100644 index 0000000..148fcf6 --- /dev/null +++ b/backend/tests/download_flow.rs @@ -0,0 +1,84 @@ +use anyhow::Result; +use axum::http::StatusCode; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +struct DocumentDetail { + document: DocumentInfo, +} + +#[derive(Clone, Deserialize)] +struct DocumentInfo { + current_version: Option, +} + +#[derive(Clone, Deserialize)] +struct DocumentVersion { + download: DownloadLink, +} + +#[derive(Clone, Deserialize)] +struct DownloadLink { + url: String, + expires_at: i64, +} + +#[tokio::test] +async fn document_download_redirects_when_proxy_disabled() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "download-user"; + let password = "secret"; + app.insert_user(username, TestUserRole::Owner).await?; + let token = app.login_token(username, password).await?; + + let upload = app + .upload_document( + "/api/documents", + "download.pdf", + "application/pdf", + b"dummy", + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::CREATED); + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + let download_link = detail + .document + .current_version + .as_ref() + .expect("missing version") + .download + .clone(); + assert!(download_link.expires_at > 0); + let download_path = download_link.url.clone(); + + let redirect = app.get(&download_path, None).await?; + assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT); + let location = redirect + .headers() + .get("location") + .expect("redirect location header") + .to_str() + .expect("location utf8"); + assert!(location.starts_with("https://fake-storage/")); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn download_with_invalid_token_is_rejected() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let response = app.get("/api/download/not-a-token", None).await?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/folders_flow.rs b/backend/tests/folders_flow.rs new file mode 100644 index 0000000..a6808ae --- /dev/null +++ b/backend/tests/folders_flow.rs @@ -0,0 +1,562 @@ +use anyhow::Result; +use axum::http::StatusCode; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use uuid::Uuid; + +#[derive(Deserialize)] +struct FolderResponse { + folder: FolderInfo, +} + +#[derive(Deserialize)] +struct FolderInfo { + id: Uuid, + name: String, + parent_id: Option, +} + +#[derive(Deserialize)] +struct FolderContents { + folder: Option, + subfolders: Vec, + documents: Vec, +} + +#[derive(Deserialize)] +struct DocSummary { + id: Uuid, +} + +#[derive(Deserialize)] +struct FolderTreeNodeResponse { + name: String, + children: Vec, +} + +#[derive(Serialize)] +struct CreateFolder<'a> { + name: &'a str, + parent_id: Option, +} + +#[derive(Serialize)] +struct EnsureFolderPath<'a> { + parent_id: Option, + segments: &'a [&'a str], +} + +#[derive(Serialize)] +struct MoveDocumentRequest { + folder_id: Option, +} + +#[derive(Deserialize)] +struct DocumentDetail { + document: DocSummary, +} + +#[tokio::test] +async fn folder_move_and_delete_flow() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "folderpass"; + app.insert_user("folder-admin", TestUserRole::Owner).await?; + let token = app.login_token("folder-admin", password).await?; + + let folder_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Projects", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(folder_resp.status(), StatusCode::CREATED); + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder: FolderResponse = serde_json::from_slice(&folder_body)?; + + let upload = app + .upload_document( + "/api/documents", + "plan.pdf", + "application/pdf", + b"dummy", + None, + &token, + ) + .await?; + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + + let move_resp = app + .patch_json( + &format!("/api/documents/{}/folder", detail.document.id), + &MoveDocumentRequest { + folder_id: Some(folder.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(move_resp.status(), StatusCode::NO_CONTENT); + + let contents = app + .get( + &format!("/api/folders/{}/contents", folder.folder.id), + Some(&token), + ) + .await?; + assert_eq!(contents.status(), StatusCode::OK); + let contents_body = body_to_vec(contents.into_body()).await?; + let contents: FolderContents = serde_json::from_slice(&contents_body)?; + assert_eq!(contents.documents.len(), 1); + assert_eq!(contents.documents[0].id, detail.document.id); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + assert!(root + .documents + .iter() + .all(|doc| doc.id != detail.document.id)); + + let delete_attempt = app + .delete(&format!("/api/folders/{}", folder.folder.id), Some(&token)) + .await?; + assert_eq!(delete_attempt.status(), StatusCode::BAD_REQUEST); + + let move_back = app + .patch_json( + &format!("/api/documents/{}/folder", detail.document.id), + &MoveDocumentRequest { folder_id: None }, + Some(&token), + ) + .await?; + assert_eq!(move_back.status(), StatusCode::NO_CONTENT); + + let delete = app + .delete(&format!("/api/folders/{}", folder.folder.id), Some(&token)) + .await?; + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn folder_tree_lists_hierarchy() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "folderpass"; + app.insert_user("folder-tree", TestUserRole::Owner).await?; + let token = app.login_token("folder-tree", password).await?; + + let alpha_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Alpha", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(alpha_resp.status(), StatusCode::CREATED); + let alpha_body = body_to_vec(alpha_resp.into_body()).await?; + let alpha: FolderResponse = serde_json::from_slice(&alpha_body)?; + + let archive_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Archive", + parent_id: Some(alpha.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(archive_resp.status(), StatusCode::CREATED); + + let beta_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Beta", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(beta_resp.status(), StatusCode::CREATED); + + let tree_resp = app.get("/api/folders/tree", Some(&token)).await?; + assert_eq!(tree_resp.status(), StatusCode::OK); + let tree_body = body_to_vec(tree_resp.into_body()).await?; + let tree: Vec = serde_json::from_slice(&tree_body)?; + + assert_eq!(tree.len(), 2); + assert_eq!(tree[0].name, "Alpha"); + assert_eq!(tree[0].children.len(), 1); + assert_eq!(tree[0].children[0].name, "Archive"); + assert!(tree[0].children[0].children.is_empty()); + + assert_eq!(tree[1].name, "Beta"); + assert!(tree[1].children.is_empty()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn update_folder_parent_to_root() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "rootpass"; + app.insert_user("root-admin", TestUserRole::Owner).await?; + let token = app.login_token("root-admin", password).await?; + + // Create a parent folder under root + let parent_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Parent", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(parent_resp.status(), StatusCode::CREATED); + let parent_body = body_to_vec(parent_resp.into_body()).await?; + let parent: FolderResponse = serde_json::from_slice(&parent_body)?; + + // Create a child folder inside the parent + let child_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Child", + parent_id: Some(parent.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(child_resp.status(), StatusCode::CREATED); + let child_body = body_to_vec(child_resp.into_body()).await?; + let child: FolderResponse = serde_json::from_slice(&child_body)?; + + // Move the child back to the root by setting parent_id to null + let update_resp = app + .patch_json( + &format!("/api/folders/{}", child.folder.id), + &json!({ "parent_id": null }), + Some(&token), + ) + .await?; + assert_eq!(update_resp.status(), StatusCode::NO_CONTENT); + + // Fetch the child folder and ensure parent_id is now null + let updated_resp = app + .get(&format!("/api/folders/{}", child.folder.id), Some(&token)) + .await?; + assert_eq!(updated_resp.status(), StatusCode::OK); + let updated_body = body_to_vec(updated_resp.into_body()).await?; + let updated_folder: FolderResponse = serde_json::from_slice(&updated_body)?; + assert!(updated_folder.folder.parent_id.is_none()); + + // Root contents should include the child folder by name + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + assert_eq!(root_contents.status(), StatusCode::OK); + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + assert!(root + .subfolders + .iter() + .any(|folder| folder.id == child.folder.id)); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn ensure_path_creates_nested_folders() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "pathpass"; + app.insert_user("path-admin", TestUserRole::Owner).await?; + let token = app.login_token("path-admin", password).await?; + + let base_path = EnsureFolderPath { + parent_id: None, + segments: &["Team", "Engineering", "Backend"], + }; + let first_resp = app + .post_json("/api/folders/path", &base_path, Some(&token)) + .await?; + assert!(first_resp.status().is_success()); + let first_body = body_to_vec(first_resp.into_body()).await?; + let first_folder: FolderResponse = serde_json::from_slice(&first_body)?; + + let second_resp = app + .post_json("/api/folders/path", &base_path, Some(&token)) + .await?; + assert!(second_resp.status().is_success()); + let second_body = body_to_vec(second_resp.into_body()).await?; + let second_folder: FolderResponse = serde_json::from_slice(&second_body)?; + assert_eq!(second_folder.folder.id, first_folder.folder.id); + + let engineering_resp = app + .post_json( + "/api/folders/path", + &EnsureFolderPath { + parent_id: None, + segments: &["Team", "Engineering"], + }, + Some(&token), + ) + .await?; + assert_eq!(engineering_resp.status(), StatusCode::OK); + let engineering_body = body_to_vec(engineering_resp.into_body()).await?; + let engineering_folder: FolderResponse = serde_json::from_slice(&engineering_body)?; + assert_ne!(engineering_folder.folder.id, first_folder.folder.id); + + let infra_resp = app + .post_json( + "/api/folders/path", + &EnsureFolderPath { + parent_id: Some(engineering_folder.folder.id), + segments: &["Infrastructure"], + }, + Some(&token), + ) + .await?; + assert_eq!(infra_resp.status(), StatusCode::OK); + let infra_body = body_to_vec(infra_resp.into_body()).await?; + let infra_folder: FolderResponse = serde_json::from_slice(&infra_body)?; + assert_ne!(infra_folder.folder.id, engineering_folder.folder.id); + + let infra_dupe_resp = app + .post_json( + "/api/folders/path", + &EnsureFolderPath { + parent_id: Some(engineering_folder.folder.id), + segments: &["Infrastructure"], + }, + Some(&token), + ) + .await?; + assert_eq!(infra_dupe_resp.status(), StatusCode::OK); + let infra_dupe_body = body_to_vec(infra_dupe_resp.into_body()).await?; + let infra_dupe_folder: FolderResponse = serde_json::from_slice(&infra_dupe_body)?; + assert_eq!(infra_dupe_folder.folder.id, infra_folder.folder.id); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn create_folder_is_idempotent() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "idempotent"; + app.insert_user("folders-idem", TestUserRole::Owner).await?; + let token = app.login_token("folders-idem", password).await?; + + let payload = CreateFolder { + name: "Archive", + parent_id: None, + }; + + let first_resp = app + .post_json("/api/folders", &payload, Some(&token)) + .await?; + assert_eq!(first_resp.status(), StatusCode::CREATED); + let first_body = body_to_vec(first_resp.into_body()).await?; + let first_folder: FolderResponse = serde_json::from_slice(&first_body)?; + + let second_resp = app + .post_json("/api/folders", &payload, Some(&token)) + .await?; + assert_eq!(second_resp.status(), StatusCode::OK); + let second_body = body_to_vec(second_resp.into_body()).await?; + let second_folder: FolderResponse = serde_json::from_slice(&second_body)?; + + assert_eq!(first_folder.folder.id, second_folder.folder.id); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + let occurrences = root + .subfolders + .iter() + .filter(|folder| folder.id == first_folder.folder.id) + .count(); + assert_eq!(occurrences, 1); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn ensure_folder_path_is_idempotent() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "pathpass"; + app.insert_user("path-admin", TestUserRole::Owner).await?; + let token = app.login_token("path-admin", password).await?; + + let segments = ["500 Immobilien", "501 Kreuzweg 2", "501.01 Rechtliches"]; + let payload = EnsureFolderPath { + parent_id: None, + segments: &segments, + }; + + let first_resp = app + .post_json("/api/folders/path", &payload, Some(&token)) + .await?; + assert!(first_resp.status().is_success()); + let first_body = body_to_vec(first_resp.into_body()).await?; + let first_folder: FolderResponse = serde_json::from_slice(&first_body)?; + + let second_resp = app + .post_json("/api/folders/path", &payload, Some(&token)) + .await?; + assert!(second_resp.status().is_success()); + let second_body = body_to_vec(second_resp.into_body()).await?; + let second_folder: FolderResponse = serde_json::from_slice(&second_body)?; + + assert_eq!(first_folder.folder.id, second_folder.folder.id); + + // Verify intermediate folders are not duplicated + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + let root_occurrences = root + .subfolders + .iter() + .filter(|folder| folder.name == segments[0]) + .count(); + assert_eq!(root_occurrences, 1); + + let level_one = root + .subfolders + .iter() + .find(|folder| folder.name == segments[0]) + .map(|folder| folder.id) + .expect("root segment not created"); + + let level_one_contents = app + .get( + &format!("/api/folders/{}/contents", level_one), + Some(&token), + ) + .await?; + let level_one_body = body_to_vec(level_one_contents.into_body()).await?; + let level_one_folders: FolderContents = serde_json::from_slice(&level_one_body)?; + let level_one_occurrences = level_one_folders + .subfolders + .iter() + .filter(|folder| folder.name == segments[1]) + .count(); + assert_eq!(level_one_occurrences, 1); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn folder_rename_updates_name_and_child_paths() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "renamepass"; + app.insert_user("rename-admin", TestUserRole::Owner).await?; + let token = app.login_token("rename-admin", password).await?; + + let parent_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Projects", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(parent_resp.status(), StatusCode::CREATED); + let parent_body = body_to_vec(parent_resp.into_body()).await?; + let parent: FolderResponse = serde_json::from_slice(&parent_body)?; + + let child_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Q1", + parent_id: Some(parent.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(child_resp.status(), StatusCode::CREATED); + let child_body = body_to_vec(child_resp.into_body()).await?; + let child: FolderResponse = serde_json::from_slice(&child_body)?; + + let rename_resp = app + .patch_json( + &format!("/api/folders/{}", parent.folder.id), + &json!({ "name": "Archive" }), + Some(&token), + ) + .await?; + assert_eq!(rename_resp.status(), StatusCode::NO_CONTENT); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + assert_eq!(root_contents.status(), StatusCode::OK); + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + let renamed = root + .subfolders + .iter() + .find(|f| f.id == parent.folder.id) + .expect("renamed folder present"); + assert_eq!(renamed.name, "Archive"); + + let folders_only = app + .get( + &format!( + "/api/folders/{}/contents?include_documents=false", + parent.folder.id + ), + Some(&token), + ) + .await?; + assert_eq!(folders_only.status(), StatusCode::OK); + let folders_only_body = body_to_vec(folders_only.into_body()).await?; + let folders_only_contents: FolderContents = serde_json::from_slice(&folders_only_body)?; + assert!(folders_only_contents.documents.is_empty()); + + let child_contents = app + .get( + &format!("/api/folders/{}/contents", child.folder.id), + Some(&token), + ) + .await?; + assert_eq!(child_contents.status(), StatusCode::OK); + let child_contents_body = body_to_vec(child_contents.into_body()).await?; + let child_details: FolderContents = serde_json::from_slice(&child_contents_body)?; + let child_folder = child_details.folder.expect("child folder info"); + assert_eq!(child_folder.name, "Q1"); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs new file mode 100644 index 0000000..2103072 --- /dev/null +++ b/backend/tests/tags_flow.rs @@ -0,0 +1,271 @@ +use anyhow::{anyhow, Result}; +use axum::http::StatusCode; +use diesel::prelude::*; +use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities}; +use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus}; +use papercrate::schema::{ + tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl, + users::dsl as users_dsl, +}; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Serialize)] +struct CreateTagPayload<'a> { + label: &'a str, + color: Option<&'a str>, +} + +#[derive(Deserialize)] +struct DocumentDetail { + document: DocumentInfo, +} + +#[derive(Deserialize)] +struct DocumentInfo { + id: Uuid, + tags: Vec, +} + +#[derive(Deserialize)] +struct TagInfo { + label: String, + #[allow(dead_code)] + color: Option, +} + +#[derive(Deserialize)] +struct TagResponse { + id: Uuid, + label: String, + color: Option, + usage_count: i64, +} + +#[derive(Serialize)] +struct AssignTagsRequest { + tag_ids: Vec, +} + +#[tokio::test] +async fn tag_assignment_flow() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "tagpass"; + app.insert_user("tagger", TestUserRole::Owner).await?; + let token = app.login_token("tagger", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "tagged.txt", + "text/plain", + b"tag me", + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::CREATED); + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + + let create_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Important", + color: Some("#FF0000"), + }, + Some(&token), + ) + .await?; + assert_eq!(create_tag.status(), StatusCode::OK); + let body = body_to_vec(create_tag.into_body()).await?; + let tag: TagResponse = serde_json::from_slice(&body)?; + assert_eq!(tag.label, "Important"); + assert_eq!(tag.color.as_deref(), Some("#FF0000")); + assert_eq!(tag.usage_count, 0); + + let update = app + .patch_json( + &format!("/api/tags/{}", tag.id), + &serde_json::json!({ + "label": "Critical", + "color": "#00FF00" + }), + Some(&token), + ) + .await?; + let updated_status = update.status(); + let updated_body = body_to_vec(update.into_body()).await?; + if updated_status != StatusCode::OK { + panic!( + "update tag failed: {}", + String::from_utf8_lossy(&updated_body) + ); + } + let updated: TagResponse = serde_json::from_slice(&updated_body)?; + assert_eq!(updated.label, "Critical"); + assert_eq!(updated.color.as_deref(), Some("#00FF00")); + assert_eq!(updated.usage_count, 0); + + let clear_color = app + .patch_json( + &format!("/api/tags/{}", tag.id), + &serde_json::json!({ + "color": null + }), + Some(&token), + ) + .await?; + let cleared_status = clear_color.status(); + let cleared_body = body_to_vec(clear_color.into_body()).await?; + if cleared_status != StatusCode::OK { + panic!( + "clear color failed: {}", + String::from_utf8_lossy(&cleared_body) + ); + } + let cleared: TagResponse = serde_json::from_slice(&cleared_body)?; + assert_eq!(cleared.color, None); + assert_eq!(cleared.usage_count, 0); + + let assign = app + .post_json( + &format!("/api/documents/{}/tags", detail.document.id), + &AssignTagsRequest { + tag_ids: vec![tag.id], + }, + Some(&token), + ) + .await?; + assert_eq!(assign.status(), StatusCode::NO_CONTENT); + + let refreshed = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + assert_eq!(refreshed.status(), StatusCode::OK); + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let refreshed_detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + assert_eq!(refreshed_detail.document.tags.len(), 1); + assert_eq!(refreshed_detail.document.tags[0].label, "Critical"); + + let remove = app + .delete( + &format!("/api/documents/{}/tags/{}", detail.document.id, tag.id), + Some(&token), + ) + .await?; + assert_eq!(remove.status(), StatusCode::NO_CONTENT); + + let final_check = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + let final_body = body_to_vec(final_check.into_body()).await?; + let final_detail: DocumentDetail = serde_json::from_slice(&final_body)?; + assert!(final_detail.document.tags.is_empty()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn tags_are_isolated_between_tenants() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password_a = "tenant-a"; + app.insert_user("alice", TestUserRole::Owner).await?; + let token_a = app.login_token("alice", password_a).await?; + + let shared_label = "Shared Label"; + + let create_a = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: shared_label, + color: Some("#123456"), + }, + Some(&token_a), + ) + .await?; + assert_eq!(create_a.status(), StatusCode::OK); + + let tenant_b_id = Uuid::new_v4(); + let user_b_id = Uuid::new_v4(); + app.with_conn(move |conn| { + let storage_root = format!("test-tenants/{tenant_b_id}/"); + diesel::insert_into(tenants_dsl::tenants) + .values(( + tenants_dsl::id.eq(tenant_b_id), + tenants_dsl::name.eq("tenant-b"), + tenants_dsl::storage_root.eq(Some(storage_root)), + tenants_dsl::status.eq(TenantStatus::Active), + )) + .execute(conn)?; + + let new_user = NewUser { + id: user_b_id, + username: "bob".to_string(), + }; + diesel::insert_into(users_dsl::users) + .values(&new_user) + .execute(conn)?; + + let owner_capability_set_id = + ensure_capability_set(conn, tenant_b_id, owner_capabilities()) + .map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))? + .id; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id: user_b_id, + tenant_id: tenant_b_id, + capability_set_id: Some(owner_capability_set_id), + }; + diesel::insert_into(memberships_dsl::user_memberships) + .values(&membership) + .execute(conn)?; + + Ok::<_, anyhow::Error>(()) + }) + .await?; + + let token_b = app.login_token("bob", "").await?; + + let create_b = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: shared_label, + color: Some("#654321"), + }, + Some(&token_b), + ) + .await?; + assert_eq!(create_b.status(), StatusCode::OK); + + app.with_conn(move |conn| { + let tags: Vec = tags_dsl::tags + .filter(tags_dsl::label.eq(shared_label)) + .order(tags_dsl::tenant_id.asc()) + .load(conn)?; + + assert_eq!(tags.len(), 2); + assert_ne!(tags[0].tenant_id, tags[1].tenant_id); + Ok::<_, anyhow::Error>(()) + }) + .await?; + + Ok(()) +} diff --git a/backend/tests/tenant_deletion.rs b/backend/tests/tenant_deletion.rs new file mode 100644 index 0000000..1f4aab5 --- /dev/null +++ b/backend/tests/tenant_deletion.rs @@ -0,0 +1,715 @@ +use anyhow::{anyhow, bail, Result}; +use axum::http::StatusCode; +use chrono::{Duration as ChronoDuration, Utc}; +use diesel::dsl::{count_star, exists, select}; +use diesel::prelude::*; +use papercrate::jobs::{ + enqueue_job, mark_job_failed, mark_job_succeeded, JOB_DELETE_TENANT, STATUS_FAILED, + STATUS_SUCCEEDED, +}; +use papercrate::models::TenantStatus; +use papercrate::schema::{documents, jobs, tenants, user_memberships}; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use papercrate::workers::tenants::{ + build_delete_proof_message, sign_delete_proof, DeleteAction, DeleteTenantJob, +}; +use papercrate::workers::{JobExecution, JobHandler}; +use serde_json::{json, Value}; +use std::sync::Arc; +use uuid::Uuid; + +#[tokio::test] +async fn delete_tenant_job_keeps_tenant_when_requested() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-keep"; + app.insert_user("tenant-keep", TestUserRole::Owner).await?; + let token = app.login_token("tenant-keep", password).await?; + + upload_fixture(&app, &token, "keep.pdf", b"keep").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(before.doc_count, 1); + assert!(before.membership_count > 0); + assert!(!before.storage_keys.is_empty()); + assert_storage_keys_present(&app, &before.storage_keys).await?; + + let job = enqueue_delete_job(&app, tenant_id, false).await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + assert_job_success(&execution); + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_SUCCEEDED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, 0); + assert!(after.membership_count > 0); + assert_storage_keys_absent(&app, &before.storage_keys).await?; + assert_eq!(storage_object_count(&app, &storage_prefix).await?, 0); + assert_eq!( + fetch_tenant_status(&app, tenant_id).await?, + TenantStatus::Suspended + ); + assert!(tenant_exists(&app, tenant_id).await?); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_can_reset_tenant_to_active() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-reset"; + app.insert_user("tenant-reset", TestUserRole::Owner).await?; + let token = app.login_token("tenant-reset", password).await?; + upload_fixture(&app, &token, "reset.pdf", b"reset").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(before.doc_count, 1); + assert!(before.membership_count > 0); + assert!(!before.storage_keys.is_empty()); + assert_storage_keys_present(&app, &before.storage_keys).await?; + + let job = enqueue_delete_job_with_status(&app, tenant_id, false, Some("active")).await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + assert_job_success(&execution); + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_SUCCEEDED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, 0); + assert!(after.membership_count > 0); + assert_storage_keys_absent(&app, &before.storage_keys).await?; + assert_eq!(storage_object_count(&app, &storage_prefix).await?, 0); + assert_eq!( + fetch_tenant_status(&app, tenant_id).await?, + TenantStatus::Active + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_removes_tenant_entirely() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-remove"; + app.insert_user("tenant-remove", TestUserRole::Owner) + .await?; + let token = app.login_token("tenant-remove", password).await?; + + upload_fixture(&app, &token, "remove-1.pdf", b"remove-1").await?; + upload_fixture(&app, &token, "remove-2.pdf", b"remove-2").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert!(before.doc_count >= 2); + assert!(before.membership_count > 0); + assert!(before.storage_keys.len() >= 2); + assert_storage_keys_present(&app, &before.storage_keys).await?; + + let job = enqueue_delete_job(&app, tenant_id, true).await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + assert_job_success(&execution); + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_SUCCEEDED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, 0); + assert_eq!(after.membership_count, 0); + assert!(after.storage_keys.is_empty()); + assert_storage_keys_absent(&app, &before.storage_keys).await?; + assert_eq!(storage_object_count(&app, &storage_prefix).await?, 0); + assert!(!tenant_exists(&app, tenant_id).await?); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_rejects_invalid_signature() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-invalid"; + app.insert_user("tenant-invalid", TestUserRole::Owner) + .await?; + let token = app.login_token("tenant-invalid", password).await?; + + upload_fixture(&app, &token, "invalid.pdf", b"invalid").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(before.doc_count, 1); + assert!(before.membership_count > 0); + assert!(!before.storage_keys.is_empty()); + assert_storage_keys_present(&app, &before.storage_keys).await?; + + let job = enqueue_delete_job_with_invalid_signature(&app, tenant_id, false).await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + match execution { + JobExecution::Failed { ref error } => { + assert!(error.contains("signature"), "unexpected error: {error}"); + } + _ => bail!("delete job should fail when signature is invalid"), + } + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, before.doc_count); + assert_eq!(after.membership_count, before.membership_count); + assert_storage_keys_present(&app, &before.storage_keys).await?; + assert_eq!( + storage_object_count(&app, &storage_prefix).await?, + before.storage_keys.len() + ); + assert_eq!( + fetch_tenant_status(&app, tenant_id).await?, + TenantStatus::Deleting + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_rejects_invalid_final_status() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-invalid-status"; + app.insert_user("tenant-invalid-status", TestUserRole::Owner) + .await?; + let token = app.login_token("tenant-invalid-status", password).await?; + + upload_fixture(&app, &token, "invalid-status.pdf", b"payload").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(before.doc_count, 1); + assert!(before.membership_count > 0); + + let job = enqueue_delete_job_with_overrides( + &app, + tenant_id, + false, + None, + PayloadOverrides { + final_status: Some("weird"), + ..PayloadOverrides::default() + }, + ) + .await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + match execution { + JobExecution::Failed { ref error } => { + assert!(error.contains("payload"), "unexpected error: {error}"); + } + _ => bail!("delete job should fail when final_status is invalid"), + } + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, before.doc_count); + assert_eq!(after.membership_count, before.membership_count); + assert_storage_keys_present(&app, &before.storage_keys).await?; + assert_eq!( + fetch_tenant_status(&app, tenant_id).await?, + TenantStatus::Deleting + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_rejects_malformed_issued_at() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-bad-issued-at"; + app.insert_user("tenant-issued", TestUserRole::Owner) + .await?; + let token = app.login_token("tenant-issued", password).await?; + + upload_fixture(&app, &token, "issued.pdf", b"issued").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let job = enqueue_delete_job_with_overrides( + &app, + tenant_id, + false, + None, + PayloadOverrides { + issued_at: Some("definitely-not-time"), + ..PayloadOverrides::default() + }, + ) + .await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + match execution { + JobExecution::Failed { ref error } => { + assert!(error.contains("issued_at"), "unexpected error: {error}"); + } + _ => bail!("delete job should fail when issued_at is malformed"), + } + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_rejects_stale_confirmation() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-stale"; + app.insert_user("tenant-stale", TestUserRole::Owner).await?; + let token = app.login_token("tenant-stale", password).await?; + + upload_fixture(&app, &token, "stale.pdf", b"stale").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let stale_time = (Utc::now() - ChronoDuration::minutes(10)).to_rfc3339(); + let job = enqueue_delete_job_with_overrides( + &app, + tenant_id, + false, + None, + PayloadOverrides { + issued_at: Some(&stale_time), + ..PayloadOverrides::default() + }, + ) + .await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + match execution { + JobExecution::Failed { ref error } => { + assert!(error.contains("expired"), "unexpected error: {error}"); + } + _ => bail!("delete job should fail when confirmation is stale"), + } + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED); + + app.cleanup().await?; + Ok(()) +} + +async fn upload_fixture(app: &TestApp, token: &str, filename: &str, contents: &[u8]) -> Result<()> { + let response = app + .upload_document( + "/api/documents", + filename, + "application/pdf", + contents, + None, + token, + ) + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + body_to_vec(response.into_body()).await?; + Ok(()) +} + +fn default_tenant_id(app: &TestApp) -> Result { + Ok(app + .state + .tenants + .get_by_name("test_tenant") + .map_err(|err| anyhow!("tenant lookup failed: {err:?}"))? + .id) +} + +async fn set_tenant_status(app: &TestApp, tenant_id: Uuid, status: TenantStatus) -> Result<()> { + app.with_conn(move |conn| { + diesel::update(tenants::table.find(tenant_id)) + .set(tenants::status.eq(status)) + .execute(conn)?; + Ok(()) + }) + .await +} + +async fn assert_tenant_status( + app: &TestApp, + tenant_id: Uuid, + expected: TenantStatus, +) -> Result<()> { + let status = fetch_tenant_status(app, tenant_id).await?; + assert_eq!(status, expected); + Ok(()) +} + +async fn tenant_snapshot( + app: &TestApp, + tenant_id: Uuid, + storage_prefix: &str, +) -> Result { + let (doc_count, membership_count) = app + .with_conn(move |conn| { + let doc_count: i64 = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .select(count_star()) + .get_result(conn)?; + let membership_count: i64 = user_memberships::table + .filter(user_memberships::tenant_id.eq(tenant_id)) + .select(count_star()) + .get_result(conn)?; + Ok::<_, anyhow::Error>((doc_count, membership_count)) + }) + .await?; + + let storage_keys = app.storage().keys_with_prefix(storage_prefix).await; + + Ok(TenantSnapshot { + doc_count, + membership_count, + storage_keys, + }) +} + +async fn tenant_exists(app: &TestApp, tenant_id: Uuid) -> Result { + app.with_conn(move |conn| { + let exists_value: bool = + select(exists(tenants::table.filter(tenants::id.eq(tenant_id)))).get_result(conn)?; + Ok(exists_value) + }) + .await +} + +async fn fetch_tenant_status(app: &TestApp, tenant_id: Uuid) -> Result { + app.with_conn(move |conn| { + tenants::table + .find(tenant_id) + .select(tenants::status) + .first(conn) + .map_err(Into::into) + }) + .await +} + +async fn record_job_outcome(app: &TestApp, job_id: Uuid, execution: &JobExecution) -> Result<()> { + match execution { + JobExecution::Success => { + app.with_conn(move |conn| { + mark_job_succeeded(conn, job_id) + .map_err(|err| anyhow!("mark succeeded failed: {err}")) + }) + .await? + } + JobExecution::Failed { error } => { + let error = error.clone(); + app.with_conn(move |conn| { + mark_job_failed(conn, job_id, &error) + .map_err(|err| anyhow!("mark failed failed: {err}")) + }) + .await? + } + JobExecution::Retry { .. } => bail!("retry outcome not expected in tenant deletion tests"), + } + Ok(()) +} + +async fn fetch_job_status(app: &TestApp, job_id: Uuid) -> Result { + app.with_conn(move |conn| { + jobs::table + .find(job_id) + .select(jobs::status) + .first::(conn) + .map_err(Into::into) + }) + .await +} + +fn tenant_storage(app: &TestApp, tenant_id: Uuid) -> Result { + app.state + .storage_for_tenant(tenant_id) + .map_err(|err| anyhow!("storage unavailable: {err:?}")) +} + +async fn storage_object_count(app: &TestApp, prefix: &str) -> Result { + Ok(app.storage().object_count_with_prefix(prefix).await) +} + +async fn assert_storage_keys_present(app: &TestApp, keys: &[String]) -> Result<()> { + let storage = app.storage(); + for key in keys { + assert!( + storage.contains_key(key).await, + "expected storage object '{}' to exist", + key + ); + } + Ok(()) +} + +async fn assert_storage_keys_absent(app: &TestApp, keys: &[String]) -> Result<()> { + let storage = app.storage(); + for key in keys { + assert!( + !storage.contains_key(key).await, + "expected storage object '{}' to be deleted", + key + ); + } + Ok(()) +} + +async fn enqueue_delete_job( + app: &TestApp, + tenant_id: Uuid, + remove_tenant: bool, +) -> Result { + enqueue_delete_job_with_status(app, tenant_id, remove_tenant, None).await +} + +async fn enqueue_delete_job_with_status( + app: &TestApp, + tenant_id: Uuid, + remove_tenant: bool, + final_status: Option<&'static str>, +) -> Result { + enqueue_delete_job_with_overrides( + app, + tenant_id, + remove_tenant, + final_status, + PayloadOverrides::default(), + ) + .await +} + +async fn enqueue_delete_job_with_invalid_signature( + app: &TestApp, + tenant_id: Uuid, + remove_tenant: bool, +) -> Result { + let mut job = enqueue_delete_job_with_overrides( + app, + tenant_id, + remove_tenant, + None, + PayloadOverrides::default(), + ) + .await?; + + let job_id = job.id; + let mut payload = job.payload.clone(); + payload["signature"] = json!("deadbeefdeadbeefdeadbeefdeadbeef"); + let payload_for_db = payload.clone(); + + app.with_conn(move |conn| { + diesel::update(jobs::table.find(job_id)) + .set(jobs::payload.eq(payload_for_db)) + .execute(conn)?; + Ok(()) + }) + .await?; + + job.payload = payload; + Ok(job) +} + +async fn enqueue_delete_job_with_overrides( + app: &TestApp, + tenant_id: Uuid, + remove_tenant: bool, + final_status: Option<&str>, + overrides: PayloadOverrides<'_>, +) -> Result { + let secret = app.state.config.jwt_secret.clone(); + let overrides_owned = PayloadOverridesOwned::from(overrides); + let requested_final_status = final_status.map(|value| value.to_string()); + + app.with_conn(move |conn| { + let tenant_name: String = tenants::table + .find(tenant_id) + .select(tenants::name) + .first(conn) + .map_err(|err| anyhow!("tenant lookup failed: {err}"))?; + + let payload = build_signed_delete_payload( + tenant_id, + &tenant_name, + remove_tenant, + requested_final_status.as_deref(), + &secret, + overrides_owned.as_borrowed(), + )?; + + enqueue_job(conn, tenant_id, JOB_DELETE_TENANT, payload, None) + .map_err(|err| anyhow!("enqueue failed: {err}")) + }) + .await +} + +fn build_signed_delete_payload( + tenant_id: Uuid, + tenant_name: &str, + remove_tenant: bool, + requested_final_status: Option<&str>, + secret: &str, + overrides: PayloadOverrides<'_>, +) -> Result { + let action = if remove_tenant { + DeleteAction::Delete + } else { + DeleteAction::Reset + }; + + let nonce = overrides + .nonce + .map(|value| value.to_string()) + .unwrap_or_else(|| format!("test-delete-nonce-{}", Uuid::new_v4())); + let issued_at = overrides + .issued_at + .map(|value| value.to_string()) + .unwrap_or_else(|| Utc::now().to_rfc3339()); + let payload_final_status = if remove_tenant { + None + } else if let Some(value) = overrides.final_status { + Some(value.to_string()) + } else { + Some(requested_final_status.unwrap_or("suspended").to_string()) + }; + + let message = build_delete_proof_message( + tenant_id, + tenant_name, + action, + &nonce, + &issued_at, + payload_final_status.as_deref(), + ); + let signature = sign_delete_proof(secret, &message) + .map_err(|err| anyhow!("failed to sign delete proof: {err}"))?; + + let mut payload = json!({ + "remove_tenant": remove_tenant, + "tenant_name": tenant_name, + "action": action.as_str(), + "nonce": nonce, + "issued_at": issued_at, + "signature": signature, + }); + if let Some(status) = payload_final_status { + payload["final_status"] = json!(status); + } + + Ok(payload) +} + +struct TenantSnapshot { + doc_count: i64, + membership_count: i64, + storage_keys: Vec, +} + +#[derive(Default)] +struct PayloadOverrides<'a> { + final_status: Option<&'a str>, + issued_at: Option<&'a str>, + nonce: Option<&'a str>, +} + +fn assert_job_success(execution: &JobExecution) { + match execution { + JobExecution::Success => {} + JobExecution::Failed { error } => panic!("delete job failed unexpectedly: {error}"), + JobExecution::Retry { error, .. } => { + panic!("delete job asked for retry unexpectedly: {error}") + } + } +} + +#[derive(Default, Clone)] +struct PayloadOverridesOwned { + final_status: Option, + issued_at: Option, + nonce: Option, +} + +impl<'a> From> for PayloadOverridesOwned { + fn from(value: PayloadOverrides<'a>) -> Self { + Self { + final_status: value.final_status.map(|s| s.to_string()), + issued_at: value.issued_at.map(|s| s.to_string()), + nonce: value.nonce.map(|s| s.to_string()), + } + } +} + +impl PayloadOverridesOwned { + fn as_borrowed(&self) -> PayloadOverrides<'_> { + PayloadOverrides { + final_status: self.final_status.as_deref(), + issued_at: self.issued_at.as_deref(), + nonce: self.nonce.as_deref(), + } + } +} diff --git a/backend/tests/tenants_flow.rs b/backend/tests/tenants_flow.rs new file mode 100644 index 0000000..c1bd1b1 --- /dev/null +++ b/backend/tests/tenants_flow.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +use axum::http::StatusCode; +use chrono::Utc; +use diesel::prelude::*; +use papercrate::models::TenantStatus; +use papercrate::schema::tenants::dsl as tenants_dsl; +use papercrate::test_support::{acquire_db_lock, TestApp, TestUserRole}; +use serde_json::json; +use uuid::Uuid; + +#[tokio::test] +async fn tenant_management_is_scoped_to_memberships() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "tenant-owner"; + app.insert_user(username, TestUserRole::Owner).await?; + let token = app.login_token(username, "irrelevant").await?; + + let other_tenant_id = app + .with_conn(|conn| { + let other_id = Uuid::new_v4(); + let now = Utc::now().naive_utc(); + diesel::insert_into(tenants_dsl::tenants) + .values(( + tenants_dsl::id.eq(other_id), + tenants_dsl::name.eq(format!("foreign-{other_id}")), + tenants_dsl::storage_root.eq(Some(format!("test-tenants/{other_id}/"))), + tenants_dsl::quickwit_index.eq(None::), + tenants_dsl::config.eq(json!({})), + tenants_dsl::created_at.eq(now), + tenants_dsl::updated_at.eq(now), + tenants_dsl::status.eq(TenantStatus::Active), + tenants_dsl::created_by.eq(None::), + )) + .execute(conn)?; + Ok(other_id) + }) + .await?; + + let response = app + .patch_json( + &format!("/api/tenants/{other_tenant_id}"), + &json!({ "name": "should-not-work" }), + Some(&token), + ) + .await?; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + Ok(()) +} diff --git a/branding/README.md b/branding/README.md new file mode 100644 index 0000000..d11468a --- /dev/null +++ b/branding/README.md @@ -0,0 +1,4 @@ +# Branding Assets + +- `logo.afphoto`: Affinity Photo source for the Papercrate logo. +- Export updated web assets to `frontend/src/assets/logo.webp` and `frontend/src/assets/logo_small.webp` to keep the UI logos in sync. diff --git a/branding/logo.afphoto b/branding/logo.afphoto new file mode 100644 index 0000000..67c7d26 Binary files /dev/null and b/branding/logo.afphoto differ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..ece5879 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,95 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: papercrate + POSTGRES_PASSWORD: papercrate_dev + POSTGRES_DB: papercrate + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backend/postgres-init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U papercrate"] + interval: 5s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" # S3 API + - "9001:9001" # Console + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + + createbuckets: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; + /usr/bin/mc mb myminio/documents --ignore-existing; + /usr/bin/mc anonymous set download myminio/documents; + exit 0; + " + + quickwit: + image: quickwit/quickwit:0.8.2 + command: ["run"] + environment: + QW_ENABLE_API_AUTH: "false" + QW_DATA_DIR: /quickwit/data + ports: + - "7280:7280" + volumes: + - quickwit_data:/quickwit/data + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:7280/api/v1/version"] + interval: 10s + timeout: 5s + retries: 5 + + admin-bootstrap: + build: + context: ./backend + depends_on: + postgres: + condition: service_healthy + quickwit: + condition: service_healthy + environment: + DATABASE_URL: postgres://papercrate:papercrate_dev@postgres:5432/papercrate + DATABASE_MAX_POOL_SIZE: 2 + AWS_ENDPOINT_URL: http://minio:9000 + AWS_ACCESS_KEY_ID: minioadmin + AWS_SECRET_ACCESS_KEY: minioadmin + AWS_REGION: us-east-1 + S3_BUCKET: documents + JWT_SECRET: change-me-super-secret + QUICKWIT_ENDPOINT: http://quickwit:7280 + entrypoint: [] + command: > + /bin/sh -c " + echo 'Running database migrations' && + diesel migration run + " + user: root + restart: "no" + +volumes: + postgres_data: + minio_data: + quickwit_data: diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..dc8425a --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,58 @@ +services: + postgres-test: + image: postgres:16-alpine + environment: + POSTGRES_USER: papercrate + POSTGRES_PASSWORD: papercrate_test + POSTGRES_DB: papercrate_test + ports: + - "5433:5432" + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate_test"] + interval: 5s + timeout: 5s + retries: 5 + + quickwit-test: + image: quickwit/quickwit:0.8.2 + command: ["run"] + environment: + QW_ENABLE_API_AUTH: "false" + QW_DATA_DIR: /quickwit/data + ports: + - "7281:7280" + volumes: + - quickwit_test_data:/quickwit/data + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:7280/api/v1/version"] + interval: 10s + timeout: 5s + retries: 5 + + admin-bootstrap: + build: + context: ./backend + depends_on: + postgres-test: + condition: service_healthy + quickwit-test: + condition: service_healthy + environment: + DATABASE_URL: postgres://papercrate:papercrate_test@postgres-test:5432/papercrate_test + DATABASE_MAX_POOL_SIZE: 1 + S3_BUCKET: documents + JWT_SECRET: change-me-super-secret + QUICKWIT_ENDPOINT: http://quickwit-test:7280 + entrypoint: [] + command: > + /bin/sh -c " + echo 'Running database migrations' && + diesel migration run + " + user: root + restart: "no" + +volumes: + quickwit_test_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9320927 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,182 @@ +version: "3.9" + +x-app-env: &app-env + DATABASE_URL: postgres://papercrate_app_login:${APP_DATABASE_PASSWORD:-papercrate_app}@postgres:5432/papercrate + DATABASE_MAX_POOL_SIZE: ${DATABASE_MAX_POOL_SIZE:-8} + SERVER_HOST: 0.0.0.0 + SERVER_PORT: 3000 + WEBDAV_HOST: 0.0.0.0 + WEBDAV_PORT: 3001 + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set} + JWT_ISSUER: ${JWT_ISSUER:-papercrate} + JWT_AUDIENCE: ${JWT_AUDIENCE:-papercrate-clients} + JWT_EXPIRY_MINUTES: ${JWT_EXPIRY_MINUTES:-60} + DOWNLOAD_TOKEN_AUDIENCE: ${DOWNLOAD_TOKEN_AUDIENCE:-papercrate-download} + DOWNLOAD_TOKEN_EXPIRY_MINUTES: ${DOWNLOAD_TOKEN_EXPIRY_MINUTES:-60} + REFRESH_TOKEN_EXPIRY_DAYS: ${REFRESH_TOKEN_EXPIRY_DAYS:-30} + REFRESH_COOKIE_SECURE: ${REFRESH_COOKIE_SECURE:-false} + REFRESH_COOKIE_DOMAIN: ${REFRESH_COOKIE_DOMAIN:-} + CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-} + AWS_ENDPOINT_URL: http://minio:9000 + AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-papercrate} + AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set} + AWS_REGION: ${AWS_REGION:-us-east-1} + S3_BUCKET: ${S3_BUCKET:-documents} + QUICKWIT_ENDPOINT: http://quickwit:7280 + QUICKWIT_INDEX: ${QUICKWIT_INDEX:-documents} + WORKER_MAX_DOCUMENT_BYTES: ${WORKER_MAX_DOCUMENT_BYTES:-209715200} + UPLOAD_BODY_LIMIT_BYTES: ${UPLOAD_BODY_LIMIT_BYTES:-134217728} + WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-papercrate.local} + WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://papercrate.local} + WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Papercrate} + RUST_LOG: ${RUST_LOG:-info} + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: papercrate + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set} + POSTGRES_DB: papercrate + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backend/postgres-init:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U papercrate -d papercrate"] + interval: 10s + timeout: 5s + retries: 5 + ports: + - "${POSTGRES_PORT:-5432}:5432" + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + restart: unless-stopped + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-papercrate} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set} + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + + createbuckets: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-papercrate} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set} + S3_BUCKET: ${S3_BUCKET:-documents} + entrypoint: > + /bin/sh -c " + set -e; + /usr/bin/mc alias set papercrate http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; + /usr/bin/mc mb papercrate/$${S3_BUCKET} --ignore-existing; + exit 0; + " + restart: "no" + + quickwit: + image: quickwit/quickwit:0.8.2 + command: ["run"] + restart: unless-stopped + environment: + QW_ENABLE_API_AUTH: "false" + QW_DATA_DIR: /quickwit/data + volumes: + - quickwit_data:/quickwit/data + healthcheck: + test: ["CMD", "curl", "-sf", "http://127.0.0.1:7280/api/v1/version"] + interval: 10s + timeout: 5s + retries: 5 + ports: + - "${QUICKWIT_PORT:-7280}:7280" + + backend: + build: + context: ./backend + image: papercrate/backend:latest + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + createbuckets: + condition: service_completed_successfully + quickwit: + condition: service_healthy + migrator: + condition: service_completed_successfully + environment: + <<: *app-env + ports: + - "${API_PORT:-3000}:3000" + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:3000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + worker: + image: papercrate/backend:latest + depends_on: + backend: + condition: service_healthy + environment: + <<: *app-env + entrypoint: ["/usr/local/bin/papercrate-worker"] + restart: unless-stopped + + webdav: + image: papercrate/backend:latest + depends_on: + backend: + condition: service_healthy + environment: + <<: *app-env + entrypoint: ["/usr/local/bin/papercrate-webdav"] + ports: + - "${WEBDAV_PORT:-3001}:3001" + restart: unless-stopped + + frontend: + build: + context: ./frontend + image: papercrate/frontend:latest + environment: + API_PROXY_PASS: http://backend:3000 + depends_on: + backend: + condition: service_healthy + ports: + - "${FRONTEND_PORT:-8080}:80" + restart: unless-stopped + + migrator: + image: papercrate/backend:latest + depends_on: + postgres: + condition: service_healthy + environment: + <<: *app-env + DATABASE_URL: postgres://papercrate:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/papercrate + entrypoint: ["/usr/local/bin/diesel"] + command: ["migration", "run"] + restart: "no" + +volumes: + postgres_data: + minio_data: + quickwit_data: diff --git a/docs/api.txt b/docs/api.txt new file mode 100644 index 0000000..7553f32 --- /dev/null +++ b/docs/api.txt @@ -0,0 +1,71 @@ +Papercrate REST API +=================== + +Unless noted otherwise, endpoints below require a valid `Authorization: Bearer ` header. + +Authentication +-------------- +- POST /api/auth/login - Exchange username/password for an access token and refresh cookie (public). Returns the active tenant as `{ tenant: { id, name } }`. When multiple tenants are available, the response contains an `access_token` (tenant-selector token) and tenant list instead. +- POST /api/auth/refresh - Rotate the refresh cookie and return a new access token (public, requires refresh cookie). Response also includes the current tenant `{ tenant: { id, name } }`. +- POST /api/auth/logout - Revoke the caller's refresh tokens and clear the cookie. +- GET /api/auth/me - Return the authenticated principal payload. + +Health +------ +- GET /api/health - Lightweight liveness probe (no authentication required). + +Documents +--------- +- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_descendants` (defaults to true unless explicitly set to `false` without filters), `status` (`active`, `deleted`, or `all`; defaults to `active`), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info. +- GET /api/documents/check?checksum= - Lightweight checksum preflight. Returns `exists=false` when no document with the supplied SHA-256 checksum is present; otherwise returns `exists=true` plus the current document metadata. +- POST /api/documents - Upload a document via multipart form-data. Required field: `file`. Optional fields: `title`, `folder_id`, JSON `metadata`, JSON array `tag_ids`, JSON array `correspondents` (each with `correspondent_id`), and `issued_at` (RFC3339). When `title` is supplied, the stored filename becomes `<original_extension>`. Include `skip_existing=true` to receive `204 No Content` instead of reusing a matching document. +- POST /api/documents/bulk/move - Move multiple documents to a target folder. +- POST /api/documents/bulk/tags - Add or remove tags across multiple documents. +- POST /api/documents/bulk/correspondents - Bulk correspondent actions. Use `action=add` (default) to attach correspondents or `action=remove` to detach the provided correspondents. +- POST /api/documents/bulk/reanalyze - Queue re-analysis jobs for selected documents. +- GET /api/documents/:id - Retrieve metadata and current version details for a document. +- PATCH /api/documents/:id - Update document metadata (currently title). +- POST /api/documents/:id/trash - Move a document to trash (soft delete, reversible). +- DELETE /api/documents/:id - Permanently erase a trashed document. Returns 202 Accepted, queues a purge job, and fails with 409 if the document is still active. +- PATCH /api/documents/:id/folder - Move a document to another folder. +- POST /api/documents/:id/restore - Restore a soft-deleted document. Optional body `{ "folder_id": <uuid> }` to send it to a specific folder; defaults to the original folder or root if missing. +- GET /api/documents/:id/versions - List version history for a document. +- GET /api/documents/:id/versions/:version_id - Fetch metadata and assets for a specific version. +- POST /api/documents/:id/tags - Assign one or more tags to a document. +- DELETE /api/documents/:id/tags/:tag_id - Remove a single tag from a document. +- POST /api/documents/:id/correspondents - Assign correspondents (`assignments[]` with `correspondent_id`; optional `replace=true` overwrites existing assignments). +- DELETE /api/documents/:id/correspondents/:correspondent_id - Remove a correspondent assignment. + +Document Assets +--------------- +- GET /api/documents/:id/assets - List generated assets for the current version. +- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag. +- GET /api/assets/:asset_id - Fetch asset metadata plus a presigned URL for a range of objects (query params: `start` and `limit`, defaulting to the first object). + +Downloads +--------- +- GET /api/download/:token - Follow a one-time download token; redirects to a pre-signed URL (public token required). + +Folders +------- +- POST /api/folders - Create a folder (optionally under a parent). +- POST /api/folders/path - Ensure a nested folder path exists, creating missing segments. +- GET /api/folders/:id - Fetch folder metadata. +- GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root. +- DELETE /api/folders/:id - Soft-delete a folder. +- PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`). +- GET /api/docs/openapi.json - Generated OpenAPI specification (JSON). + +Tags +---- +- GET /api/tags - List all tags with usage counts. +- POST /api/tags - Create a new tag. +- PATCH /api/tags/:id - Update a tag's label or color. +- DELETE /api/tags/:id - Remove a tag; fails with 400 if still assigned to any document. + +Correspondents +-------------- +- GET /api/correspondents - List correspondents with usage totals. +- POST /api/correspondents - Create a correspondent (name + optional metadata JSON). +- PATCH /api/correspondents/:id - Update name and/or metadata. +- DELETE /api/correspondents/:id - Remove a correspondent; fails with 400 if referenced by any document. diff --git a/docs/api_responses.md b/docs/api_responses.md new file mode 100644 index 0000000..997c22b --- /dev/null +++ b/docs/api_responses.md @@ -0,0 +1,17 @@ +# API response helpers + +The backend now exposes `crate::http::responders`, which wraps common success and +error patterns for routes: + +- `ok_json`, `created_json`, `accepted_json` return `JsonResponse<T>` with the + respective status codes. +- `no_content`/`empty` provide shared empty responses. +- `JsonResponse<T>` implements `IntoResponse`, so any handler can return + `AppResult<JsonResponse<T>>` without pairing tuples manually. +- `IntoAppResult`, `RowsAffectedExt`, and friends convert Diesel results into + `AppResult<T>` with consistent `AppError` handling. + +When adding new routes, import from `crate::http::responders` instead of +constructing `(StatusCode, Json<T>)` tuples directly. The folders, documents, +auth, capability-set, correspondent, tag, and profile routers now all share +these helpers; WebDAV keeps its bespoke streaming responses for now. diff --git a/docs/capability_sets.md b/docs/capability_sets.md new file mode 100644 index 0000000..0419a43 --- /dev/null +++ b/docs/capability_sets.md @@ -0,0 +1,100 @@ +# Capability Sets + +Capability sets are the tenant-scoped bundles of REST and WebDAV permissions. Every user membership and API token now references one of these sets, and the capability guard middleware enforces the scopes on every route. + +## Enumerated Capabilities + +All capabilities live in the `ApiCapability` enum. The current list is: + +- `documents:read` +- `documents:edit` +- `documents:write` +- `documents:upload` +- `folders:read` +- `folders:edit` +- `folders:write` +- `tags:read` +- `tags:edit` +- `tags:write` +- `correspondents:read` +- `correspondents:edit` +- `correspondents:write` +- `profile:read` +- `profile:write` +- `webdav:read` +- `webdav:write` +- `capability_sets:read` +- `capability_sets:write` + +## Default Sets + +Provisioning (and the test harness) seed four system capability sets per tenant: + +- `owner` — contains the full set above. Tenant owners, admin users, and freshly minted API tokens effectively get unrestricted access. +- `user` — the default interactive role: full document/tag/correspondent/profile access, but no capability-set or WebDAV write privileges. +- `readonly` — interactive but read-only: document/folder/tag/correspondent reads plus WebDAV downloads, but no modifying routes. +- `webdav` — contains only `webdav:read`. WebDAV backup scripts can bind to this set for read-only access. + +System sets are flagged with `is_system = true` and cannot be modified or deleted via the API. + +## REST API + +The capability-set endpoints live at `/api/capability-sets` and require the new admin capabilities: + +| Method & Path | Capability | Description | +|----------------------------------------|-------------------------|-----------------------------------------| +| `GET /api/capability-sets` | `capability_sets:read` | List all sets for the tenant | +| `POST /api/capability-sets` | `capability_sets:write` | Create a new set | +| `GET /api/capability-sets/{id}` | `capability_sets:read` | Fetch details of a specific set | +| `PATCH /api/capability-sets/{id}` | `capability_sets:write` | Replace capabilities / rename the set | +| `DELETE /api/capability-sets/{id}` | `capability_sets:write` | Remove a custom set (must be unused) | + +### Examples + +Create a read-only set: + +```bash +curl -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + https://app.papercrate.org/api/capability-sets \ + -d '{ + "slug": "api_readonly", + "capabilities": [ + "documents:read", + "folders:read", + "tags:read" + ] + }' +``` + +Update an existing set: + +```bash +curl -X PATCH \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + https://app.papercrate.org/api/capability-sets/$SET_ID \ + -d '{ + "capabilities": ["documents:read", "documents:edit"] + }' +``` + +Delete (fails if still referenced by memberships or tokens): + +```bash +curl -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + https://app.papercrate.org/api/capability-sets/$SET_ID +``` + +## Assigning Sets + +- **User memberships**: change the `capability_set_id` column (via future admin APIs or direct SQL) to reassign a user. The authentication pipeline will enforce the new capabilities automatically. +- **API tokens**: `POST /api/profile/api-tokens` requires a `capability_set_id`. Tokens are bound to the selected set; raw capability arrays are no longer accepted. + +## Guard Coverage + +The `RequireCapabilitiesLayer` middleware wraps all protected routers (documents, folders, tags, correspondents, profile, capability sets, assets). Requests missing the necessary capability now terminate with a 403 containing `missing_capability` details. + +Integration tests in `backend/tests/capability_guards_flow.rs` ensure read-only users cannot upload or manage capability sets, and WebDAV tokens without `webdav:read` are rejected (`backend/tests/api_tokens_flow.rs`). diff --git a/docs/desktopworkspace.md b/docs/desktopworkspace.md new file mode 100644 index 0000000..ec49111 --- /dev/null +++ b/docs/desktopworkspace.md @@ -0,0 +1,15 @@ +# Desktop Workspace Interaction Spec + +The desktop workspace should apply the following selection and drag behaviours: + +- **Click on a non-selected card**: clear any existing selection, then select the clicked card only. +- **Click on a selected card**: keep the selection and open the detail panel for that card (no selection change). +- **Drag on a non-selected card**: clear the selection, select the dragged card, then drag that single card. +- **Drag on a selected card**: drag the entire current selection without altering which cards are selected. +- **Cmd/Ctrl + click on a non-selected card**: add that card to the existing selection. +- **Cmd/Ctrl + click on a selected card**: expand the selection by adding the stack of cards beneath the clicked card. +- **Cmd/Ctrl + drag on a non-selected card**: replace the current selection with the entire stack beneath the pointer, then drag that stack. +- **Cmd/Ctrl + drag on a selected card**: replace the current selection with the stack beneath the pointer, then drag that stack. +- **Touch long-press**: behaves like a stack-select gesture, expanding the selection to the stack under the pressed card without requiring modifier keys. + +These rules ensure the selection model remains predictable while supporting stack-aware gestures unique to the desktop workspace. diff --git a/docs/deskview.png b/docs/deskview.png new file mode 100644 index 0000000..9203ae2 Binary files /dev/null and b/docs/deskview.png differ diff --git a/docs/detailview.png b/docs/detailview.png new file mode 100644 index 0000000..4b1cda1 Binary files /dev/null and b/docs/detailview.png differ diff --git a/docs/document-model.md b/docs/document-model.md new file mode 100644 index 0000000..4bfc2a5 --- /dev/null +++ b/docs/document-model.md @@ -0,0 +1,92 @@ +# Document Data Model + +This note describes the core persistence model for documents: the metadata held in +`documents`, how versions are tracked, and the way auxiliary assets are stored. + +## documents + +Each row represents the logical document a user interacts with in the UI. Key +fields: + +- `id (uuid)` – Stable identifier used in API paths. +- `tenant_id (uuid)` – Multi-tenancy boundary; all joins filter by this. +- `title (varchar)` – Display name editable via PATCH. +- `filename / original_name (varchar)` – Current storage filename vs. the name + captured during upload. +- `folder_id (uuid, nullable)` – Parent folder, `NULL` means root. +- `metadata (jsonb)` – Arbitrary structured metadata (source import details, + custom fields, etc.). +- `issued_at (timestamptz, nullable)` – User-provided timestamp for when the + document was issued (invoice date, etc.). +- `current_version_id (uuid)` – FK pointing at the active `document_versions` + row; updated whenever a new version is promoted. +- `deleted_at (timestamptz, nullable)` – Soft-delete marker; non-NULL rows are + treated as living in the trash. +- `created_at / updated_at (timestamptz)` – Audit stamps; `updated_at` reflects + metadata or version changes. + +Other indexes enforce per-tenant uniqueness for `(folder, filename)` and support +common queries (folder listing, trash filtering). + +## document_versions + +Every binary revision lives here. Fields of interest: + +- `document_id (uuid)` – Back-reference to the logical document. +- `version_number (int)` – Monotonic per document (1, 2, …); enforced via + `UNIQUE(document_id, version_number)`. +- `s3_key (varchar)` – Object storage path for the binary (used for download). +- `size_bytes`, `checksum` – Stored metadata about the binary; checksum is a + hex-encoded SHA-256 hash used for dedupe/conflicts. +- `metadata (jsonb)` – Small metadata blob specific to the version (extracted + text summary, processing hints, etc.). +- `tenant_id (uuid)` – Mirrors the owning document’s tenant. + +The row referenced by `documents.current_version_id` is treated as the latest +revision. Older versions remain queryable for download or audit. + +## Assets + +A document version can have zero or more derived artifacts (thumbnails, OCR +output, previews). These are modelled via: + +- `document_assets` + - `document_version_id` – FK to the owning version. + - `asset_type (text)` – Logical type identifier (e.g. `thumbnail`, `ocr_text`). + - `mime_type (text)` – Media type for consumers. + - `metadata (jsonb)` – Asset-specific metadata (dimensions, page count, etc.). + - `cardinality (int, nullable)` – Optional hint for multi-object assets. + - `tenant_id (uuid)` – Tenant scoping. + - Uniqueness on `(document_version_id, asset_type)` ensures one logical asset + per type; multi-object cases are stored in `document_asset_objects`. + +- `document_asset_objects` + - `asset_id` – FK to `document_assets`. + - `ordinal (int)` – 1-based position for multi-part assets. + - `s3_key (text)` – Object storage key for the binary blob. + - `metadata (jsonb)` – Per-object metadata if needed (e.g. page number). + +Simple assets (single thumbnail) live solely in `document_assets`. Complex ones +(e.g. per-page previews) use `document_asset_objects` to point at multiple S3 +objects under a single logical asset. + +## Related tables + +- `document_tags` and `document_correspondents` provide many-to-many + relationships for categorisation. +- `jobs` records background work (OCR, thumbnails, indexing) keyed by tenant. +- `api_tokens`, `user_sessions`, and `user_passkeys` live alongside but do + not alter the document schema directly. + +## Lifecycle summary + +1. Upload creates a `documents` row and an initial `document_versions` entry. +2. Workers generate derived assets, inserting rows into `document_assets` + (and possibly `document_asset_objects`). +3. When a new version is promoted, a fresh `document_versions` row is written + and `documents.current_version_id` is updated atomically. +4. Soft-deleting the document sets `deleted_at`; restore clears it and the + document reappears in listings. + +This schema allows arbitrary metadata expansion while maintaining a clear +separation between logical documents, their version history, and derived assets. diff --git a/docs/documentview.png b/docs/documentview.png new file mode 100644 index 0000000..274ca6f Binary files /dev/null and b/docs/documentview.png differ diff --git a/docs/jobs.md b/docs/jobs.md new file mode 100644 index 0000000..a570e0c --- /dev/null +++ b/docs/jobs.md @@ -0,0 +1,40 @@ +# Job Catalogue + +Papercrate stores asynchronous work in the shared `jobs` table. Each job carries a +`tenant_id`, a small JSON payload, and one of the statuses defined in +`backend/src/jobs.rs` (`queued`, `processing`, `succeeded`, `failed`). Workers +continuously reserve jobs by type and execute the appropriate handler. This +document lists every job type that is currently recognized by the backend and +briefly describes what it does. + +| Job type | Payload shape | When it is enqueued | Work performed | +| --- | --- | --- | --- | +| `analyze-document` | `{ "document_id": Uuid, "document_version_id": Uuid, "force": bool }` | Uploading a document, calling the re-analyze bulk action, or after a metadata edit (e.g. title change) | Runs the taskflow pipeline (`GenerateThumbnailsTask`, `GenerateOcrTask`, `DetermineIssuedAtTask`, `IndexDocumentTask`) for the specified document version. The handler refuses to run if the tenant is not `Active`. | +| `purge-document` | `{ "document_id": Uuid }` | `DELETE /api/documents/{id}` after the document has been trashed | Removes every version and asset object from tenant storage, deletes database rows (`documents`, `document_versions`, associated assets/tags/correspondents), and leaves the system ready for GC. | +| `provision-tenant` | `{ "members": [Uuid, ...] }` | When a tenant is created with status `creating` | Creates/ensures the tenant’s Quickwit index, materializes the system capability sets (`owner`, `user`, `readonly`, `webdav`), attaches the initial member list, and flips the tenant status to `active`. | +| `delete-tenant` | `{ "remove_tenant": bool, "tenant_name": string, "action": "delete"\|"reset", "nonce": string, "issued_at": RFC3339 datetime, "signature": hex(HMAC-SHA256), "final_status"?: "active"\|"suspended" }` | Administrative action after a tenant has been marked `deleting` | Deletes all tenant-scoped storage objects, wipes the tenant’s Quickwit index (and optionally deletes it entirely), truncates the tenant schemas/tables, removes queued jobs for that tenant, and either deletes the tenant row or leaves it in the requested final status (defaults to `suspended`) while recreating an empty Quickwit index. | + +## Retired job types + +`generate-thumbnails` and `generate-ocr-text` once existed as standalone jobs. +Those behaviors now run as tasks inside `analyze-document`. No worker is +registered for the legacy types; keep them out of new payloads. + +### Tenant delete/reset safety checks + +The `delete-tenant` job refuses to run without a signed payload. The admin CLI +derives a message of the form `v1|tenant_id|tenant_name|action|nonce|issued_at|final_status` +and signs it with an HMAC-SHA256 key based on the server’s JWT secret. +Workers verify the signature, ensure the payload matches the job flags, and +require the `issued_at` timestamp to be no more than five minutes old. This +protects against accidental wipes triggered by stale requests or insufficiently +scoped API calls. + +## Operational notes + +* Every job handler calls `ensure_active_tenant` (or an equivalent guard) before + touching tenant data. If a tenant is suspended or deleting, the job will fail + immediately. +* Jobs are only enqueued for the tenant they operate on. Consequently, wiping a + tenant with `delete-tenant` also removes any remaining queued jobs for that + tenant so workers do not waste effort on work that can no longer succeed. diff --git a/docs/s3_cors_presign.md b/docs/s3_cors_presign.md new file mode 100644 index 0000000..f7a2cba --- /dev/null +++ b/docs/s3_cors_presign.md @@ -0,0 +1,56 @@ +# Bucket CORS for Presigned Asset Fetches + +The frontend loads certain assets (e.g. OCR text) with `fetch()` against their presigned URLs +(see `frontend/src/preview/DocumentViewerPanel.jsx`). Browsers will block that request unless +the storage bucket sends CORS headers that allow the frontend origin. Configure a rule that +includes: + +* the list of allowed origins (your production, staging, or local domains) +* `GET` (and optionally other methods you expose) +* permissive request headers (usually `"*"` is fine for presigned URLs) +* exposed response headers if the frontend needs them (`etag`, `content-length`, etc.) + +## Example CORS document + +```json +{ + "CORSRules": [ + { + "AllowedOrigins": ["https://app.example"], + "AllowedMethods": ["GET"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["etag", "content-length", "content-type"], + "MaxAgeSeconds": 300 + } + ] +} +``` + +Replace `https://app.example` with each domain that must fetch presigned assets. Add additional +rules if different origins require different methods. + +## Applying the rule + +### AWS S3 CLI +```bash +aws s3api put-bucket-cors \ + --bucket <bucket-name> \ + --cors-configuration file://cors.json \ + [--endpoint-url <custom-endpoint>] +``` +Save the JSON payload as `cors.json`. When targeting S3-compatible providers (e.g. Hetzner, Ceph RGW), +pass their endpoint via `--endpoint-url`. + +### s3cmd (Ceph RGW / generic S3) +```bash +s3cmd setcors cors.json s3://<bucket-name> +``` + +### MinIO Client (`mc`) +```bash +mc alias set storage <endpoint> <access-key> <secret-key> +mc anonymous set-json storage/<bucket-name> cors.json +``` + +Most dashboards expose a similar form—paste the JSON rule into the CORS section for the bucket. +Once the rule is active, browsers will allow the frontend to read presigned assets with fetch(). diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..ca01ccd Binary files /dev/null and b/docs/screenshot.png differ diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..6bc1547 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,27 @@ +# Integration Test Setup + +The backend integration tests talk to a real Postgres database. To spin up an ephemeral instance locally, use the dedicated compose file: + +```bash +docker compose -f docker-compose.test.yml up -d +``` + +This starts Postgres on port `5433` with the database/user both named `papercrate` and password `papercrate_test`. Point the test harness at it: + +```bash +export TEST_DATABASE_URL=postgres://papercrate:papercrate_test@localhost:5433/papercrate_test +``` + +Run the tests as usual: + +```bash +cargo test +``` + +When you are done, stop the container: + +```bash +docker compose -f docker-compose.test.yml down +``` + +The compose file uses a tmpfs volume, so each run starts with a clean database. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..8a4acd7 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules +/dist +/.env.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..21efd0b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1.6 + +ARG NODE_IMAGE=node:20-alpine +ARG NGINX_IMAGE=nginx:alpine + +FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +COPY . . +RUN npm run build + +FROM ${NGINX_IMAGE} +WORKDIR /usr/share/nginx/html + +COPY --from=build /app/dist ./ + +ENV API_PROXY_PASS="" + +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +EXPOSE 80 +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..68f7742 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,56 @@ +# Papercrate Frontend + +A minimal Webpack-powered SPA to interact with the Papercrate Milestone 1 backend. + +## Prerequisites + +- Node.js 18+ +- Backend API running locally on `http://127.0.0.1:3000` + +## Setup + +```bash +cd frontend +npm install +``` + +## Development + +```bash +npm run dev +``` + +- Starts `webpack-dev-server` on <http://localhost:5173> +- Proxies `/api` requests to the backend (no CORS needed) +- Edit files in `src/` and the browser reloads automatically + +## Production Build + +```bash +npm run build +``` + +- Output written to `dist/` +- Set `API_BASE_URL` in `.env.local` if the API is not served from the same origin. + +## Code Quality + +```bash +npm run check +``` + +- Runs TypeScript type checking (`tsc`) and ESLint. +- Use this before committing changes. + +## Features + +- Finder-style layout: folder tree, document table, and detail pane with metadata & tags +- Drag-and-drop moves (documents between folders) and file uploads (window-wide or onto a folder) +- Search box plus tag chips filter documents across the selected folder and all descendants +- Tag management (create/assign/remove) from the detail panel +- Login with a WebAuthn passkey created through the signup flow (no baked-in demo account) +- Inline status banner for quick feedback on API interactions + +## Assets + +- The folder icon (`src/assets/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/). diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 0000000..86e4d21 --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,17 @@ +module.exports = { + presets: [ + [ + '@babel/preset-env', + { + targets: 'defaults', + }, + ], + [ + '@babel/preset-react', + { + runtime: 'automatic', + }, + ], + '@babel/preset-typescript', + ], +}; diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh new file mode 100644 index 0000000..8b7ed69 --- /dev/null +++ b/frontend/docker-entrypoint.sh @@ -0,0 +1,59 @@ +#!/bin/sh +set -euo pipefail + +# Add mjs to mime.types if not present +sed -i 's|application/javascript|application/javascript mjs|' /etc/nginx/mime.types + +API_PROXY_PASS_TRIMMED="${API_PROXY_PASS:-}" +API_PROXY_PASS_TRIMMED="${API_PROXY_PASS_TRIMMED%%/}" + +MAX_BODY_SIZE_RAW="${UPLOAD_BODY_LIMIT_BYTES:-}" +if [ -n "$MAX_BODY_SIZE_RAW" ]; then + MAX_BODY_SIZE=$(printf '%sm' "$((MAX_BODY_SIZE_RAW / (1024 * 1024)))") +else + MAX_BODY_SIZE="128m" +fi + +cat <<BASE > /etc/nginx/conf.d/default.conf +server { + listen 80; + server_name _; + + client_max_body_size ${MAX_BODY_SIZE}; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files \$uri /index.html; + } +BASE + +if [ -n "$API_PROXY_PASS_TRIMMED" ]; then +cat <<PROXY >> /etc/nginx/conf.d/default.conf + + location /api/ { + client_max_body_size ${MAX_BODY_SIZE}; + proxy_pass ${API_PROXY_PASS_TRIMMED}; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } + + location /download/ { + client_max_body_size ${MAX_BODY_SIZE}; + proxy_pass ${API_PROXY_PASS_TRIMMED}; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } +PROXY +fi + +cat <<'ENDCFG' >> /etc/nginx/conf.d/default.conf +} +ENDCFG + +exec "$@" diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..9109a77 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,86 @@ +import js from '@eslint/js'; +import pluginReact from 'eslint-plugin-react'; +import pluginReactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import tsPluginImport from '@typescript-eslint/eslint-plugin'; + +const tsPlugin = tsPluginImport.default ?? tsPluginImport; + +const sharedRules = { + ...js.configs.recommended.rules, + ...pluginReact.configs.recommended.rules, + ...pluginReactHooks.configs.recommended.rules, + 'no-use-before-define': [ + 'error', + { functions: false, classes: true, variables: true }, + ], + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + 'react-hooks/set-state-in-effect': 'off', + 'react-hooks/refs': 'off', + 'react-hooks/preserve-manual-memoization': 'off', +}; + +const sharedLanguageOptions = { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + ...globals.browser, + ...globals.node, + }, +}; + +export default [ + { + ignores: ['dist', 'node_modules'], + }, + { + files: ['src/**/*.{js,jsx}', 'tests/**/*.{js,jsx}'], + languageOptions: sharedLanguageOptions, + plugins: { + react: pluginReact, + 'react-hooks': pluginReactHooks, + '@typescript-eslint': tsPlugin, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: sharedRules, + }, + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { + ...sharedLanguageOptions, + parser: tsParser, + }, + plugins: { + react: pluginReact, + 'react-hooks': pluginReactHooks, + '@typescript-eslint': tsPlugin, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: { + ...sharedRules, + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + }, + }, +]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..65e1d8b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,11580 @@ +{ + "name": "papercrate-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "papercrate-frontend", + "version": "0.1.0", + "dependencies": { + "@fontsource/inter": "^5.2.8", + "@tabler/icons-react": "^3.35.0", + "axios": "^1.13.2", + "pdfjs-dist": "^5.4.394", + "prop-types": "^15.8.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-redux": "^9.2.0", + "react-router-dom": "^7.9.5" + }, + "devDependencies": { + "@babel/core": "^7.28.5", + "@babel/preset-env": "^7.28.5", + "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", + "@eslint/js": "^9.39.1", + "@svgr/webpack": "^8.1.0", + "@typescript-eslint/eslint-plugin": "^8.46.4", + "@typescript-eslint/parser": "^8.18.1", + "babel-loader": "^10.0.0", + "copy-webpack-plugin": "^13.0.1", + "css-loader": "^7.1.2", + "eslint": "^9.39.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^16.5.0", + "html-webpack-plugin": "^5.6.4", + "knip": "^5.71.0", + "style-loader": "^4.0.0", + "typescript": "^5.7.3", + "webpack": "^5.102.1", + "webpack-cli": "^6.0.1", + "webpack-dev-server": "^5.2.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", + "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", + "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "regexpu-core": "^6.2.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", + "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", + "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz", + "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", + "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz", + "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.5.tgz", + "integrity": "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", + "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", + "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz", + "integrity": "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz", + "integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", + "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz", + "integrity": "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.5.tgz", + "integrity": "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.5", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.28.3", + "@babel/plugin-transform-classes": "^7.28.4", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.28.5", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.5", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.4", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.4", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", + "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.0.tgz", + "integrity": "sha512-6RX+W5a+ZUY/c/7J5s5jK9UinLfJo5oWKh84fb4X0yK2q4WXEWUWZWuEMjvCb1YNUQhEAhUfr5scEGOH7jC4YQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.20.0.tgz", + "integrity": "sha512-adcXFVorSQULtT4XDL0giRLr2EVGIcyWm6eQKZWTrRA4EEydGOY8QVQtL0PaITQpUyu+lOd/QOicw6vdy1v8QQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.82.tgz", + "integrity": "sha512-FGjyUBoF0sl1EenSiE4UV2WYu76q6F9GSYedq5EiOCOyGYoQ/Owulcv6rd7v/tWOpljDDtefXXIaOCJrVKem4w==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.82", + "@napi-rs/canvas-darwin-arm64": "0.1.82", + "@napi-rs/canvas-darwin-x64": "0.1.82", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.82", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.82", + "@napi-rs/canvas-linux-arm64-musl": "0.1.82", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.82", + "@napi-rs/canvas-linux-x64-gnu": "0.1.82", + "@napi-rs/canvas-linux-x64-musl": "0.1.82", + "@napi-rs/canvas-win32-x64-msvc": "0.1.82" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.82.tgz", + "integrity": "sha512-bvZhN0iI54ouaQOrgJV96H2q7J3ZoufnHf4E1fUaERwW29Rz4rgicohnAg4venwBJZYjGl5Yl3CGmlAl1LZowQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.82.tgz", + "integrity": "sha512-InuBHKCyuFqhNwNr4gpqazo5Xp6ltKflqOLiROn4hqAS8u21xAHyYCJRgHwd+a5NKmutFTaRWeUIT/vxWbU/iw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.82.tgz", + "integrity": "sha512-aQGV5Ynn96onSXcuvYb2y7TRXD/t4CL2EGmnGqvLyeJX1JLSNisKQlWN/1bPDDXymZYSdUqbXehj5qzBlOx+RQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.82.tgz", + "integrity": "sha512-YIUpmHWeHGGRhWitT1KJkgj/JPXPfc9ox8oUoyaGPxolLGPp5AxJkq8wIg8CdFGtutget968dtwmx71m8o3h5g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.82.tgz", + "integrity": "sha512-AwLzwLBgmvk7kWeUgItOUor/QyG31xqtD26w1tLpf4yE0hiXTGp23yc669aawjB6FzgIkjh1NKaNS52B7/qEBQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.82.tgz", + "integrity": "sha512-moZWuqepAwWBffdF4JDadt8TgBD02iMhG6I1FHZf8xO20AsIp9rB+p0B8Zma2h2vAF/YMjeFCDmW5un6+zZz9g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.82.tgz", + "integrity": "sha512-w9++2df2kG9eC9LWYIHIlMLuhIrKGQYfUxs97CwgxYjITeFakIRazI9LYWgVzEc98QZ9x9GQvlicFsrROV59MQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.82.tgz", + "integrity": "sha512-lZulOPwrRi6hEg/17CaqdwWEUfOlIJuhXxincx1aVzsVOCmyHf+xFq4i6liJl1P+x2v6Iz2Z/H5zHvXJCC7Bwg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.82.tgz", + "integrity": "sha512-Be9Wf5RTv1w6GXlTph55K3PH3vsAh1Ax4T1FQY1UYM0QfD0yrwGdnJ8/fhqw7dEgMjd59zIbjJQC8C3msbGn5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.82", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.82.tgz", + "integrity": "sha512-LN/i8VrvxTDmEEK1c10z2cdOTkWT76LlTGtyZe5Kr1sqoSomKeExAjbilnu1+oee5lZUgS5yfZ2LNlVhCeARuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", + "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.14.2.tgz", + "integrity": "sha512-bTrdE4Z1JcGwPxBOaGbxRbpOHL8/xPVJTTq3/bAZO2euWX0X7uZ+XxsbC+5jUDMhLenqdFokgE1akHEU4xsh6A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.14.2.tgz", + "integrity": "sha512-bL7/f6YGKUvt/wzpX7ZrHCf1QerotbSG+IIb278AklXuwr6yQdfQHt7KQ8hAWqSYpB2TAbPbAa9HE4wzVyxL9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.14.2.tgz", + "integrity": "sha512-0zhMhqHz/kC6/UzMC4D9mVBz3/M9UTorbaULfHjAW5b8SUC08H01lZ5fR3OzfDbJI0ByLfiQZmbovuR/pJ8Wzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.14.2.tgz", + "integrity": "sha512-kRJBTCQnrGy1mjO+658yMrlGYWEKi6j4JvKt92PRCoeDX0vW4jvzgoJXzZXNxZL1pCY6jIdwsn9u53v4jwpR6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.14.2.tgz", + "integrity": "sha512-lpKiya7qPq5EAV5E16SJbxfhNYRCBZATGngn9mZxR2fMLDVbHISDIP2Br8eWA8M1FBJFsOGgBzxDo+42ySSNZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.14.2.tgz", + "integrity": "sha512-zRIf49IGs4cE9rwpVM3NxlHWquZpwQLebtc9dY9S+4+B+PSLIP95BrzdRfkspwzWC5DKZsOWpvGQjxQiLoUwGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.14.2.tgz", + "integrity": "sha512-sF1fBrcfwoRkv1pR3Kp6D5MuBeHRPxYuzk9rhaun/50vq5nAMOaomkEm4hBbTSubfU86CoBIEbLUQ+1f7NvUVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.14.2.tgz", + "integrity": "sha512-O8iTBqz6oxf1k93Rn6WMGGQYo2jV1K81hq4N/Nke3dHE25EIEg2RKQqMz1dFrvVb2RkvD7QaUTEevbx0Lq+4wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.14.2.tgz", + "integrity": "sha512-HOfzpS6eUxvdch9UlXCMx2kNJWMNBjUpVJhseqAKDB1dlrfCHgexeLyBX977GLXkq2BtNXKsY3KCryy1QhRSRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.14.2.tgz", + "integrity": "sha512-0uLG6F2zljUseQAUmlpx/9IdKpiLsSirpmrr8/aGVfiEurIJzC/1lo2HQskkM7e0VVOkXg37AjHUDLE23Fi8SA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.14.2.tgz", + "integrity": "sha512-Pdh0BH/E0YIK7Qg95IsAfQyU9rAoDoFh50R19zCTNfjSnwsoDMGHjmUc82udSfPo2YMnuxA+/+aglxmLQVSu2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.14.2.tgz", + "integrity": "sha512-3DLQhJ2r53rCH5cudYFqD7nh+Z6ABvld3GjbiqHhT43GMIPw3JcHekC2QunLRNjRr1G544fo1HtjTJz9rCBpyg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.14.2.tgz", + "integrity": "sha512-G5BnAOQ5f+RUG1cvlJ4BvV+P7iKLYBv67snqgcfwD5b2N4UwJj32bt4H5JfolocWy4x3qUjEDWTIjHdE+2uZ9w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.14.2.tgz", + "integrity": "sha512-VirQAX2PqKrhWtQGsSDEKlPhbgh3ggjT1sWuxLk4iLFwtyA2tLEPXJNAsG0kfAS2+VSA8OyNq16wRpQlMPZ4yA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.14.2.tgz", + "integrity": "sha512-q4ORcwMkpzu4EhZyka/s2TuH2QklEHAr/mIQBXzu5BACeBJZIFkICp8qrq4XVnkEZ+XhSFTvBECqfMTT/4LSkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.14.2.tgz", + "integrity": "sha512-ZsMIpDCxSFpUM/TwOovX5vZUkV0IukPFnrKTGaeJRuTKXMcJxMiQGCYTwd6y684Y3j55QZqIMkVM9NdCGUX6Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.14.2.tgz", + "integrity": "sha512-Lvq5ZZNvSjT3Jq/buPFMtp55eNyGlEWsq30tN+yLOfODSo6T6yAJNs6+wXtqu9PiMj4xpVtgXypHtbQ1f+t7kw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.14.2.tgz", + "integrity": "sha512-7w7WHSLSSmkkYHH52QF7TrO0Z8eaIjRUrre5M56hSWRAZupCRzADZxBVMpDnHobZ8MAa2kvvDEfDbERuOK/avQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.14.2.tgz", + "integrity": "sha512-hIrdlWa6tzqyfuWrxUetURBWHttBS+NMbBrGhCupc54NCXFy2ArB+0JOOaLYiI2ShKL5a3uqB7EWxmjzOuDdPQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.14.2.tgz", + "integrity": "sha512-dP9aV6AZRRpg5mlg0eMuTROtttpQwj3AiegNJ/NNmMSjs+0+aLNcgkWRPhskK3vjTsthH4/+kKLpnQhSxdJkNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz", + "integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^8.1.3", + "deepmerge": "^4.3.1", + "svgo": "^3.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/webpack": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz", + "integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@babel/plugin-transform-react-constant-elements": "^7.21.3", + "@babel/preset-env": "^7.20.2", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.0", + "@svgr/core": "8.1.0", + "@svgr/plugin-jsx": "8.1.0", + "@svgr/plugin-svgo": "8.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@tabler/icons": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.35.0.tgz", + "integrity": "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.35.0.tgz", + "integrity": "sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.35.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.16", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", + "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", + "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.14.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", + "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", + "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", + "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/type-utils": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.46.4", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", + "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", + "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.4", + "@typescript-eslint/types": "^8.46.4", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", + "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", + "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", + "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4", + "@typescript-eslint/utils": "8.46.4", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", + "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", + "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.4", + "@typescript-eslint/tsconfig-utils": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/visitor-keys": "8.46.4", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", + "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.4", + "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/typescript-estree": "8.46.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", + "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.4", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", + "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz", + "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz", + "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-loader": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz", + "integrity": "sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": "^18.20.0 || ^20.10.0 || >=22.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5.61.0" + } + }, + "node_modules/babel-loader/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/babel-loader/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/babel-loader/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/babel-loader/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", + "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001749", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", + "integrity": "sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-webpack-plugin": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz", + "integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/core-js-compat": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", + "integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.26.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", + "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.233", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.233.tgz", + "integrity": "sha512-iUdTQSf7EFXsDdQsp8MwJz5SVk4APEFqXU/S47OtQ0YLqacSwPXdZ5vRlMX3neb07Cy2vgioNuRnWUXFwuslkg==", + "dev": true, + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.17.0.tgz", + "integrity": "sha512-GpfViocsFM7viwClFgxK26OtjMlKN67GCR5v6ASFkotxtpBWd9d+vNy+AH7F2E1TUkMDZ8P/dDPZX71/NG8xnQ==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.4.tgz", + "integrity": "sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-network-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/knip": { + "version": "5.71.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.71.0.tgz", + "integrity": "sha512-hwgdqEJ+7DNJ5jE8BCPu7b57TY7vUwP6MzWYgCgPpg6iPCee/jKPShDNIlFER2koti4oz5xF88VJbKCb4Wl71g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.3.0", + "jiti": "^2.6.0", + "js-yaml": "^4.1.1", + "minimist": "^1.2.8", + "oxc-resolver": "^11.13.2", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.5.2", + "strip-json-comments": "5.0.3", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "@types/node": ">=18", + "typescript": ">=5.0.4 <7" + } + }, + "node_modules/knip/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/launch-editor": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.1.tgz", + "integrity": "sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.3" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.49.0.tgz", + "integrity": "sha512-L9uC9vGuc4xFybbdOpRLoOAOq1YEBBsocCs5NVW32DfU+CZWWIn3OVF+lB8Gp4ttBVSMazwrTrjv8ussX/e3VQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oxc-resolver": { + "version": "11.14.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.14.2.tgz", + "integrity": "sha512-M5fERQKcrCngMZNnk1gRaBbYcqpqXLgMcoqAo7Wpty+KH0I18i03oiy2peUsGJwFaKAEbmo+CtAyhXh08RZ1RA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.14.2", + "@oxc-resolver/binding-android-arm64": "11.14.2", + "@oxc-resolver/binding-darwin-arm64": "11.14.2", + "@oxc-resolver/binding-darwin-x64": "11.14.2", + "@oxc-resolver/binding-freebsd-x64": "11.14.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.14.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.14.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.14.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.14.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.14.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.14.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.14.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.14.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.14.2", + "@oxc-resolver/binding-linux-x64-musl": "11.14.2", + "@oxc-resolver/binding-openharmony-arm64": "11.14.2", + "@oxc-resolver/binding-wasm32-wasi": "11.14.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.14.2", + "@oxc-resolver/binding-win32-ia32-msvc": "11.14.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.14.2" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.394", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz", + "integrity": "sha512-9ariAYGqUJzx+V/1W4jHyiyCep6IZALmDzoaTLZ6VNu8q9LWi1/ukhzHgE2Xsx96AZi0mbZuK4/ttIbqSbLypg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.81" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", + "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz", + "integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.5" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smol-toml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/spdy-transport/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/spdy-transport/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/spdy/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/spdy/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svgo": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/svgo/node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/svgo/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/svgo/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/svgo/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/thingies": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", + "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/webpack": { + "version": "5.102.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", + "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.26.3", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.4", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", + "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.6.1", + "@webpack-cli/configtest": "^3.0.1", + "@webpack-cli/info": "^3.0.1", + "@webpack-cli/serve": "^3.0.1", + "colorette": "^2.0.14", + "commander": "^12.1.0", + "cross-spawn": "^7.0.3", + "envinfo": "^7.14.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.82.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", + "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.43.1", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", + "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.21", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.21.2", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.6.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^2.4.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3d9e490 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,48 @@ +{ + "name": "papercrate-frontend", + "version": "0.1.0", + "private": true, + "description": "Rudimentary Webpack SPA for Papercrate Milestone 1", + "scripts": { + "dev": "webpack serve --mode development --open", + "build": "webpack --mode production", + "lint": "eslint src --ext .js,.jsx,.ts,.tsx", + "check": "tsc --noEmit && npx knip && npm run lint", + "test:engine": "node --test tests/workspaceEngine.test.js" + }, + "dependencies": { + "@fontsource/inter": "^5.2.8", + "@tabler/icons-react": "^3.35.0", + "axios": "^1.13.2", + "pdfjs-dist": "^5.4.394", + "prop-types": "^15.8.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-redux": "^9.2.0", + "react-router-dom": "^7.9.5" + }, + "devDependencies": { + "@babel/core": "^7.28.5", + "@babel/preset-env": "^7.28.5", + "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", + "@eslint/js": "^9.39.1", + "@svgr/webpack": "^8.1.0", + "@typescript-eslint/eslint-plugin": "^8.46.4", + "@typescript-eslint/parser": "^8.18.1", + "babel-loader": "^10.0.0", + "copy-webpack-plugin": "^13.0.1", + "css-loader": "^7.1.2", + "eslint": "^9.39.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "globals": "^16.5.0", + "html-webpack-plugin": "^5.6.4", + "knip": "^5.71.0", + "style-loader": "^4.0.0", + "typescript": "^5.7.3", + "webpack": "^5.102.1", + "webpack-cli": "^6.0.1", + "webpack-dev-server": "^5.2.2" + } +} diff --git a/frontend/src/app/DocumentsRoute.tsx b/frontend/src/app/DocumentsRoute.tsx new file mode 100644 index 0000000..a9877a4 --- /dev/null +++ b/frontend/src/app/DocumentsRoute.tsx @@ -0,0 +1,112 @@ +import React, { useCallback, useEffect } from 'react'; +import type { ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + DocumentsFilterProvider, +} from '../documents/context/DocumentsFilterContext'; +import { FullscreenPreviewProvider, useFullscreenPreviewContext } from '../viewer/FullscreenPreviewContext'; +import { DocumentOpenProvider } from '../lib/context/DocumentOpenContext'; +import { useWorkspaceSurface } from './useWorkspaceSurface'; +import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext'; +import { PanelManagerProvider, usePanelManager } from './PanelManagerContext'; +import Sidebar from '../sidebar/Sidebar'; +import useDocumentsShell from './useDocumentsShell'; + +const DocumentsInner: React.FC<{ + surfaceConfig: any; + onNavigate: (documentId: string) => void; +}> = ({ surfaceConfig, onNavigate }) => { + const { openFullscreenPreview } = useFullscreenPreviewContext(); + const { collapsed: sidebarCollapsed } = useSidebarContext(); + const { + sidebarSuppressed, + expandSidebar, + } = usePanelManager(); + + const { openDetailPanel } = surfaceConfig; + const sidebarHidden = sidebarCollapsed || sidebarSuppressed; + + const { surface } = useWorkspaceSurface({ + sidebarHidden, + onExpandSidebar: expandSidebar, + ...surfaceConfig, + }); + + useEffect(() => { + document.body.classList.add('has-main-content'); + return () => { + document.body.classList.remove('has-main-content'); + }; + }, []); + + const renderSurface = () => { + const detailMode = surface?.detailMode ?? null; + const layoutClass = `documents-main${sidebarHidden ? ' documents-main--sidebar-hidden' : ''}${detailMode === 'overlay' ? ' documents-main--overlay-detail' : ''}`; + const sidebarNode = !sidebarHidden ? <Sidebar /> : null; + const surfaceDetail = surface && (surface as { detail?: ReactNode }).detail ? (surface as { detail?: ReactNode }).detail : null; + const surfaceBody = surface ? surface.content : null; + + return ( + <main className={layoutClass}> + {sidebarNode} + <div className="main-content"> + {surfaceBody} + </div> + {surfaceDetail} + </main> + ); + }; + + return ( + <DocumentOpenProvider + onOpenViewer={onNavigate} + onOpenFullscreenPreview={openFullscreenPreview} + onOpenDetailPanel={openDetailPanel} + > + {renderSurface()} + </DocumentOpenProvider> + ); +}; + +const DocumentsRouteContent: React.FC = () => { + const { + surfaceConfig, + documentsFilter, + } = useDocumentsShell(); + const navigate = useNavigate(); + + const handleDocumentNavigate = useCallback((documentId: string) => { + navigate(`/documents/${documentId}`); + }, [navigate]); + + return ( + <DocumentsFilterProvider value={documentsFilter}> + <FullscreenPreviewProvider + onNavigate={handleDocumentNavigate} + > + <DocumentsInner + surfaceConfig={surfaceConfig} + onNavigate={handleDocumentNavigate} + /> + </FullscreenPreviewProvider> + </DocumentsFilterProvider> + ); +}; + +const DocumentsRoute: React.FC = () => { + const { surfaceConfig } = useDocumentsShell(); + const { detailPanelOpen, closeDetailPanel } = surfaceConfig; + + return ( + <SidebarProvider> + <PanelManagerProvider + isOpen={detailPanelOpen} + onClose={closeDetailPanel} + > + <DocumentsRouteContent /> + </PanelManagerProvider> + </SidebarProvider> + ); +}; + +export default DocumentsRoute; diff --git a/frontend/src/app/DropOverlay.tsx b/frontend/src/app/DropOverlay.tsx new file mode 100644 index 0000000..16ef85c --- /dev/null +++ b/frontend/src/app/DropOverlay.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +interface DropOverlayProps { + active?: boolean; + folderName?: string | null; +} + +const DropOverlay: React.FC<DropOverlayProps> = ({ active = false, folderName }) => ( + <div className={`drop-overlay${active ? ' active' : ''}`}> + <div className="drop-overlay__content"> + Drop files to upload to <strong>{folderName || 'this location'}</strong> + </div> + </div> +); + +export default DropOverlay; diff --git a/frontend/src/app/LoginRoute.tsx b/frontend/src/app/LoginRoute.tsx new file mode 100644 index 0000000..3347532 --- /dev/null +++ b/frontend/src/app/LoginRoute.tsx @@ -0,0 +1,535 @@ +/* global PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import LoginView from '../login/LoginView'; +import useApiError from '../hooks/useApiError'; +import { + isWebAuthnAvailable, + preparePublicKeyRequestOptions, + preparePublicKeyCreationOptions, + serializeAuthenticationCredential, + serializeRegistrationCredential, +} from '../utils/webauthn'; +import { useAppDispatch, useAppState } from '../lib/store/appState'; +import { + finishPasskeyLogin, + finishSignup, + performLogin, + selectTenant, + startPasskeyLogin, + startSignup, +} from '../lib/api/apiClient'; + +type StatusVariant = 'info' | 'success' | 'error'; + +interface StatusMessage { + message: string; + variant: StatusVariant; +} + +interface TenantOption { + id?: string | null; + name?: string | null; +} + +interface TenantSelectionState { + selectionToken?: string | null; + tenants?: TenantOption[]; +} + +type AuthResponse = { + access_token?: string; + tenant?: TenantOption | null; + tenants?: TenantOption[]; +}; + +const LoginRoute: React.FC = () => { + const appState = useAppState(); + const { status: appStatus } = appState; + const tenantSelection = (appState.tenantSelection ?? null) as TenantSelectionState | null; + const appDispatch = useAppDispatch(); + const location = useLocation(); + const [status, setStatus] = useState<StatusMessage | null>(null); + const [selectingTenantId, setSelectingTenantId] = useState(null); + const passkeySupported = isWebAuthnAvailable(); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const signupSupported = passkeySupported; + const [signupLoading, setSignupLoading] = useState(false); + const [magicLoginPending, setMagicLoginPending] = useState(false); + const magicLoginParams = useMemo(() => { + const extract = (searchString) => { + const params = new URLSearchParams(searchString || ''); + const token = (params.get('magic_token') || '').trim(); + const usernameHint = (params.get('username') || '').trim(); + const preferredTenantId = (params.get('preferred_tenant_id') || '').trim(); + return { + token: token || null, + username: usernameHint || null, + preferredTenantId: preferredTenantId || null, + }; + }; + + let combined = extract(location.search); + + const hash = window.location.hash || ''; + const queryIndex = hash.indexOf('?'); + if (queryIndex !== -1) { + const hashQuery = hash.slice(queryIndex + 1); + const hashParams = extract(`?${hashQuery}`); + combined = { + token: combined.token || hashParams.token, + username: combined.username || hashParams.username, + preferredTenantId: combined.preferredTenantId || hashParams.preferredTenantId, + }; + } + if (!combined.token) { + const searchParams = extract(window.location.search); + combined = { + token: combined.token || searchParams.token, + username: combined.username || searchParams.username, + preferredTenantId: combined.preferredTenantId || searchParams.preferredTenantId, + }; + } + + return combined; + }, [location.search]); + const preferredTenantRef = useRef(null); + const attemptedMagicTokenRef = useRef(null); + const magicLoginPendingRef = useRef(false); + const appStatusRef = useRef(appStatus); + + const { + token: magicToken, + username: magicUsername, + preferredTenantId: magicPreferredTenantId, + } = magicLoginParams; + + useEffect(() => { + appStatusRef.current = appStatus; + }, [appStatus]); + + const setStatusMessage = useCallback((message, variant = 'info') => { + setStatus(message ? { message, variant } : null); + }, []); + + const handleLoginApiReport = useCallback( + ({ message, variant }) => setStatusMessage(message, variant), + [setStatusMessage], + ); + + const reportLoginError = useApiError({ + onReport: handleLoginApiReport, + }); + + const notifyLoginError = useCallback( + (error, fallbackMessage, variant = 'error') => + reportLoginError(error, { message: fallbackMessage, variant }), + [reportLoginError], + ); + + const clearMagicParamsFromUrl = useCallback(() => { + const removableKeys = ['magic_token', 'username', 'preferred_tenant_id']; + const currentSearch = new URLSearchParams(window.location.search); + let searchChanged = false; + removableKeys.forEach((key) => { + if (currentSearch.has(key)) { + currentSearch.delete(key); + searchChanged = true; + } + }); + + const hash = window.location.hash || ''; + let nextHash = hash; + const hashQuestionIndex = hash.indexOf('?'); + if (hashQuestionIndex !== -1) { + const hashPath = hash.slice(0, hashQuestionIndex); + const hashQuery = hash.slice(hashQuestionIndex + 1); + const hashParams = new URLSearchParams(hashQuery); + let hashChanged = false; + removableKeys.forEach((key) => { + if (hashParams.has(key)) { + hashParams.delete(key); + hashChanged = true; + } + }); + if (hashChanged) { + const nextQuery = hashParams.toString(); + nextHash = nextQuery ? `${hashPath}?${nextQuery}` : hashPath; + } + } + + if (!searchChanged && nextHash === hash) { + return; + } + + const nextSearch = currentSearch.toString(); + const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${nextHash}`; + window.history.replaceState(window.history.state, document.title, nextUrl); + }, []); + + const handleTenantSelect = useCallback( + async (tenant) => { + if (!tenantSelection?.selectionToken || !tenant?.id) { + return; + } + + try { + setSelectingTenantId(tenant.id); + const data = await selectTenant( + { tenant_id: tenant.id }, + tenantSelection.selectionToken, + ) as AuthResponse; + + const accessToken = data?.access_token; + if (!accessToken) { + throw new Error('Invalid tenant selection response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: accessToken, + tenant: data.tenant || null, + }); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + const message = error?.response?.data?.error || 'Failed to finalize login.'; + notifyLoginError(error, message, 'error'); + } finally { + setSelectingTenantId(null); + } + }, + [appDispatch, notifyLoginError, setStatusMessage, tenantSelection], + ); + + const handleCancelSelection = useCallback(() => { + appDispatch({ type: 'CLEAR_TENANT_SELECTION' }); + setStatusMessage(null); + }, [appDispatch, setStatusMessage]); + + const handlePasskeyLogin = useCallback( + async (rawUsername) => { + const username = rawUsername?.trim?.() || ''; + if (!username) { + setStatusMessage('Enter your username before using a passkey.', 'error'); + return; + } + if (!passkeySupported) { + setStatusMessage('Passkeys are not supported in this browser.', 'error'); + return; + } + + setPasskeyLoading(true); + appDispatch({ type: 'LOGIN_REQUEST' }); + try { + const startData = await startPasskeyLogin(username); + const challengeId = (startData as { challengeId?: string })?.challengeId; + const publicKeyOptions = (startData as { publicKey?: PublicKeyCredentialRequestOptions })?.publicKey; + + if (!challengeId || !publicKeyOptions) { + throw new Error('Invalid passkey challenge response.'); + } + + const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions }); + setStatusMessage('Confirm the passkey prompt to continue.', 'info'); + const assertion = await navigator.credentials.get({ publicKey }) as PublicKeyCredential | null; + + if (!assertion) { + setStatusMessage('Passkey login cancelled.', 'info'); + return; + } + + if (!(assertion instanceof PublicKeyCredential)) { + setStatusMessage('Unexpected credential response.', 'error'); + return; + } + + const serialized = serializeAuthenticationCredential(assertion); + const finishPayload = { + challengeId, + credential: serialized, + }; + + const finishData = await finishPasskeyLogin(finishPayload) as AuthResponse; + + if (finishData?.access_token && Array.isArray(finishData.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: finishData.access_token, + tenants: finishData.tenants || [], + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!finishData?.access_token) { + throw new Error('Invalid login response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: finishData.access_token, + tenant: finishData.tenant || null, + }); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + if (error?.name === 'NotAllowedError') { + setStatusMessage('Passkey login cancelled.', 'info'); + } else if (error?.response?.status === 404) { + setStatusMessage('No passkey registered for this username. Create an account first.', 'error'); + appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' }); + } else if (error?.response?.status === 400 && error?.response?.data?.error) { + setStatusMessage(error.response.data.error, 'error'); + appDispatch({ type: 'LOGIN_FAILURE', error: error.response.data.error }); + } else { + const message = error?.response?.data?.error || error.message || 'Passkey login failed.'; + notifyLoginError(error, message); + appDispatch({ type: 'LOGIN_FAILURE', error: message }); + } + } finally { + setPasskeyLoading(false); + } + }, + [appDispatch, notifyLoginError, passkeySupported, setStatusMessage], + ); + + useEffect(() => { + if (!magicToken) { + return; + } + if (magicLoginPendingRef.current) { + return; + } + if (attemptedMagicTokenRef.current === magicToken) { + return; + } + if (appStatusRef.current === 'authenticated') { + clearMagicParamsFromUrl(); + return; + } + + let cancelled = false; + attemptedMagicTokenRef.current = magicToken; + + const attemptMagicLogin = async () => { + magicLoginPendingRef.current = true; + setMagicLoginPending(true); + preferredTenantRef.current = magicPreferredTenantId; + appDispatch({ type: 'LOGIN_REQUEST' }); + setStatusMessage('Signing you in…', 'info'); + + try { + const payload: { + magic_token: string; + username?: string; + preferred_tenant_id?: string; + } = { + magic_token: magicToken, + }; + if (magicUsername) { + payload.username = magicUsername; + } + if (magicPreferredTenantId) { + payload.preferred_tenant_id = magicPreferredTenantId; + } + + const data = await performLogin(payload) as AuthResponse; + if (cancelled) { + return; + } + + if (data?.access_token && Array.isArray(data.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: data.access_token, + tenants: data.tenants || [], + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!data?.access_token) { + throw new Error('Invalid login response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); + setStatusMessage('Login successful.', 'success'); + preferredTenantRef.current = null; + } catch (error) { + if (cancelled) { + return; + } + const message = error?.response?.data?.error || 'Magic link login failed.'; + notifyLoginError(error, message); + appDispatch({ type: 'LOGIN_FAILURE', error: message }); + preferredTenantRef.current = null; + } finally { + if (!cancelled) { + setMagicLoginPending(false); + magicLoginPendingRef.current = false; + clearMagicParamsFromUrl(); + } + } + }; + + attemptMagicLogin(); + + return () => { + cancelled = true; + magicLoginPendingRef.current = false; + setMagicLoginPending(false); + }; + }, [ + appDispatch, + clearMagicParamsFromUrl, + magicToken, + magicUsername, + magicPreferredTenantId, + notifyLoginError, + setStatusMessage, + ]); + + useEffect(() => { + const preferredTenantId = preferredTenantRef.current; + if (!preferredTenantId) { + return; + } + if (!tenantSelection?.tenants?.length) { + return; + } + if (selectingTenantId) { + return; + } + const match = tenantSelection.tenants.find((tenant) => tenant.id === preferredTenantId); + if (!match) { + preferredTenantRef.current = null; + return; + } + handleTenantSelect(match); + preferredTenantRef.current = null; + }, [handleTenantSelect, selectingTenantId, tenantSelection]); + + const handleSignup = useCallback( + async (rawUsername) => { + const username = rawUsername?.trim?.() || ''; + if (!username) { + setStatusMessage('Choose a username to create your account.', 'error'); + return; + } + if (!passkeySupported) { + setStatusMessage('Passkeys are not supported in this browser.', 'error'); + return; + } + + setSignupLoading(true); + try { + const startData = await startSignup(username) as { + signup_token?: string; + challenge?: { challengeId?: string; publicKey?: PublicKeyCredentialCreationOptions }; + }; + const signupToken = startData.signup_token; + const challengePayload = startData.challenge; + const challengeId = challengePayload?.challengeId; + const publicKeyOptions = challengePayload?.publicKey; + + if (!signupToken || !challengeId || !publicKeyOptions) { + throw new Error('Invalid signup challenge response.'); + } + + const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions }); + setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info'); + const credential = await navigator.credentials.create({ publicKey }) as PublicKeyCredential | null; + + if (!credential) { + setStatusMessage('Signup cancelled.', 'info'); + return; + } + + if (!(credential instanceof PublicKeyCredential)) { + setStatusMessage('Unexpected credential response.', 'error'); + return; + } + + const serialized = serializeRegistrationCredential(credential); + const finishPayload = { + signup_token: signupToken, + credential: serialized, + }; + + const finishData = await finishSignup(finishPayload) as AuthResponse; + + if (finishData?.access_token && Array.isArray(finishData.tenants)) { + appDispatch({ + type: 'TENANT_SELECTION_REQUIRED', + selectionToken: finishData.access_token, + tenants: finishData.tenants || [], + }); + setStatusMessage('Select a tenant to continue.', 'info'); + return; + } + + if (!finishData?.access_token) { + throw new Error('Invalid signup response.'); + } + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: finishData.access_token, + tenant: finishData.tenant || null, + }); + setStatusMessage('Account created. Welcome!', 'success'); + } catch (error) { + if (error?.name === 'NotAllowedError') { + setStatusMessage('Signup cancelled.', 'info'); + } else if (error?.response?.status === 409) { + setStatusMessage('This passkey is already registered. Try signing in instead.', 'error'); + } else if (error?.response?.data?.error) { + setStatusMessage(error.response.data.error, 'error'); + } else { + const message = error?.message || 'Failed to create account.'; + setStatusMessage(message, 'error'); + } + } finally { + setSignupLoading(false); + } + }, + [appDispatch, passkeySupported, setStatusMessage], + ); + + const redirectTarget = useMemo(() => { + const target = String(location.state?.from ?? ''); + if (target.startsWith('/')) { + return target; + } + return '/documents'; + }, [location.state]); + + if (!['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { + return <Navigate to={redirectTarget} replace />; + } + + return ( + <div className="app-shell"> + <LoginView + status={status} + tenantSelection={tenantSelection} + onSelectTenant={handleTenantSelect} + onCancelSelection={handleCancelSelection} + selectingTenantId={selectingTenantId} + onPasskeyLogin={handlePasskeyLogin} + passkeySupported={passkeySupported} + passkeyLoading={passkeyLoading} + onSignup={handleSignup} + signupSupported={signupSupported} + signupLoading={signupLoading} + magicLoginPending={magicLoginPending} + initialUsername={magicLoginParams.username || ''} + /> + </div> + ); +}; + +export default LoginRoute; diff --git a/frontend/src/app/PanelManagerContext.tsx b/frontend/src/app/PanelManagerContext.tsx new file mode 100644 index 0000000..5198557 --- /dev/null +++ b/frontend/src/app/PanelManagerContext.tsx @@ -0,0 +1,406 @@ +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react'; +import { useSidebarContext } from '../sidebar/SidebarContext'; +import { + DEFAULT_DETAIL_WIDTH, + DEFAULT_SIDEBAR_WIDTH, + MINIMAL_FREE_RATIO, + MINIMUM_MAIN_CONTENT_WIDTH, + PANEL_LIMITS, + PANEL_STORAGE_KEYS, + SIDEBAR_SOLO_THRESHOLD, + type PanelKey, +} from '../constants/layout'; +import { createSafeContext } from '../utils/createSafeContext'; + +interface SetPanelWidthOptions { + commit?: boolean; + log?: boolean; +} + +interface PanelManagerContextValue { + sidebarWidth: number; + detailWidth: number; + sidebarSuppressed: boolean; + resizingPanel: PanelKey | null; + setPanelWidth: (panel: PanelKey, width: number, options?: SetPanelWidthOptions) => number; + startPanelResize: (panel: PanelKey) => void; + stopPanelResize: () => void; + getPanelWidth: (panel: PanelKey) => number; + setDetailActive: (isOpen: boolean) => void; + closeDetailPanel: () => void; + collapseSidebar: () => void; + expandSidebar: () => void; + registerDetailCloseHandler: (handler?: (() => void) | null) => void; + detailPanelOpen: boolean; +} + +type PanelResizeBindings = { + panelStyle?: CSSProperties; + handleProps: { + onPointerDown?: (event: ReactPointerEvent<HTMLDivElement>) => void; + }; + isPanelResizing: boolean; +}; + +const [PanelManagerContext, usePanelManager] = createSafeContext<PanelManagerContextValue>('PanelManager'); + +const clampPanelWidth = (panel: PanelKey, value: number): number => { + const numeric = Number(value); + const limits = PANEL_LIMITS[panel]; + const viewport = window.innerWidth; + const minLimit = Math.max(0, limits.minPx); + const ratioMax = Math.round(viewport * limits.maxRatio); + const rawMax = Math.max(ratioMax, minLimit); + const maxAllowed = Math.min(rawMax, viewport - MINIMUM_MAIN_CONTENT_WIDTH); + const targetMax = Math.min(viewport, Math.max(minLimit, maxAllowed)); + return Math.min(Math.max(numeric, minLimit), Math.max(0, targetMax)); +}; + +const readStoredWidth = (panel: PanelKey, fallback: number): number => { + const raw = window.localStorage.getItem(PANEL_STORAGE_KEYS[panel]); + if (!raw) { + return fallback; + } + const parsed = Number.parseFloat(raw); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const persistWidth = (panel: PanelKey, value: number): void => { + window.localStorage.setItem(PANEL_STORAGE_KEYS[panel], String(Math.round(value))); +}; + +const applyPanelWidthToRoot = (panel: PanelKey, width: number, active: boolean): void => { + const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width'; + const resolvedValue = panel === 'detail' && !active ? '0px' : `${width}px`; + document.documentElement.style.setProperty(varName, resolvedValue); +}; + +interface PanelManagerProviderProps { + children: ReactNode; + isOpen?: boolean; + onClose?: () => void; +} + +export const PanelManagerProvider: React.FC<PanelManagerProviderProps> = ({ children, isOpen, onClose }) => { + const { collapsed, setCollapsed } = useSidebarContext(); + const initialSidebarWidth = readStoredWidth('sidebar', DEFAULT_SIDEBAR_WIDTH); + const initialDetailWidth = readStoredWidth('detail', DEFAULT_DETAIL_WIDTH); + + const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth)); + const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth)); + const [resizingPanel, setResizingPanel] = useState(null); + const [sidebarSuppressed, setSidebarSuppressed] = useState(false); + const [detailPanelOpen, setDetailPanelOpen] = useState(Boolean(isOpen)); + + useEffect(() => { + if (isOpen !== undefined) { + setDetailPanelOpen(isOpen); + } + }, [isOpen]); + + const detailCloseHandlerRef = useRef(null); + const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth }); + const preferredPanelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth }); + + const closeDetailPanel = useCallback(() => { + const handler = detailCloseHandlerRef.current; + handler?.(); + onClose?.(); + if (isOpen === undefined) { + setDetailPanelOpen(false); + } + }, [onClose, isOpen]); + + useEffect(() => { + panelWidthsRef.current.sidebar = sidebarWidth; + applyPanelWidthToRoot('sidebar', sidebarWidth, true); + }, [sidebarWidth]); + + useEffect(() => { + panelWidthsRef.current.detail = detailWidth; + applyPanelWidthToRoot('detail', detailWidth, detailPanelOpen); + }, [detailWidth, detailPanelOpen]); + + const handlePanelLayoutChange = useCallback(( + panel: PanelKey, + action: 'opened' | 'closed' | 'resized', + value?: number, + { detailOpen }: { detailOpen?: boolean } = {}, + ) => { + const viewportWidth = window.innerWidth; + const sidebarWidth = panelWidthsRef.current.sidebar; + const detailWidth = panelWidthsRef.current.detail; + const freeSpace = viewportWidth - sidebarWidth - detailWidth; + const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0; + + const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO; + const effectiveDetailOpen = detailOpen ?? detailPanelOpen; + + if (panel === 'detail' && !collapsed) { + if (action === 'opened' || action === 'resized') { + setSidebarSuppressed(!meetsThreshold); + } else if (action === 'closed') { + setSidebarSuppressed(false); + } + } + + if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && effectiveDetailOpen) { + closeDetailPanel(); + } + }, [collapsed, closeDetailPanel, detailPanelOpen]); + + const collapseSidebar = useCallback(() => { + if (!collapsed) { + setCollapsed(true); + handlePanelLayoutChange('sidebar', 'closed'); + } + }, [collapsed, setCollapsed, handlePanelLayoutChange]); + + const setPanelWidth = useCallback( + (panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => { + const clamped = clampPanelWidth(panel, width); + + if (panel === 'sidebar') { + setSidebarWidthState((prev) => (prev === clamped ? prev : clamped)); + } else { + setDetailWidthState((prev) => (prev === clamped ? prev : clamped)); + } + panelWidthsRef.current[panel] = clamped; + if (commit) { + preferredPanelWidthsRef.current[panel] = clamped; + persistWidth(panel, clamped); + } + + if (log) { + handlePanelLayoutChange(panel, 'resized', clamped); + } + return clamped; + }, + [handlePanelLayoutChange], + ); + + const resetSidebarPreferredWidth = useCallback(() => { + if (preferredPanelWidthsRef.current.sidebar === DEFAULT_SIDEBAR_WIDTH) { + return; + } + preferredPanelWidthsRef.current.sidebar = DEFAULT_SIDEBAR_WIDTH; + persistWidth('sidebar', DEFAULT_SIDEBAR_WIDTH); + }, []); + + const clampPanelsWithinViewport = useCallback(() => { + const viewportWidth = Math.max(0, Number(window.innerWidth) || 0); + if (viewportWidth === 0) { + return; + } + + const desiredSidebarWidth = preferredPanelWidthsRef.current.sidebar; + const desiredDetailWidth = preferredPanelWidthsRef.current.detail; + + let sidebarDisplayWidth = Math.min(desiredSidebarWidth, viewportWidth); + let remainingWidth = Math.max(0, viewportWidth - sidebarDisplayWidth); + let detailDisplayWidth = Math.min(desiredDetailWidth, remainingWidth); + if (detailPanelOpen && sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) { + sidebarDisplayWidth = 0; + detailDisplayWidth = Math.min(desiredDetailWidth || viewportWidth, viewportWidth); + } else if (sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) { + sidebarDisplayWidth = viewportWidth; + detailDisplayWidth = 0; + resetSidebarPreferredWidth(); + } + + panelWidthsRef.current.sidebar = sidebarDisplayWidth; + panelWidthsRef.current.detail = detailDisplayWidth; + + setSidebarWidthState((prev) => (prev === sidebarDisplayWidth ? prev : sidebarDisplayWidth)); + setDetailWidthState((prev) => (prev === detailDisplayWidth ? prev : detailDisplayWidth)); + }, [detailPanelOpen, resetSidebarPreferredWidth]); + + useEffect(() => { + clampPanelsWithinViewport(); + window.addEventListener('resize', clampPanelsWithinViewport); + return () => window.removeEventListener('resize', clampPanelsWithinViewport); + }, [clampPanelsWithinViewport]); + + const registerDetailCloseHandler = useCallback((handler: (() => void) | null = null) => { + detailCloseHandlerRef.current = handler ?? null; + }, []); + + const setDetailActive = useCallback( + (active) => { + if (isOpen === undefined) { + setDetailPanelOpen(Boolean(active)); + } + if (!active) { + onClose?.(); + } + handlePanelLayoutChange('detail', active ? 'opened' : 'closed'); + }, + [handlePanelLayoutChange, isOpen, onClose], + ); + + const expandSidebar = useCallback(() => { + if (collapsed) { + setCollapsed(false); + } + handlePanelLayoutChange('sidebar', 'opened'); + }, [collapsed, setCollapsed, handlePanelLayoutChange]); + + const startPanelResize = useCallback((panel) => { + setResizingPanel(panel); + }, []); + + const stopPanelResize = useCallback(() => { + setResizingPanel(null); + }, []); + + const getPanelWidth = useCallback((panel) => panelWidthsRef.current[panel] || 0, []); + + const contextValue = useMemo( + () => ({ + sidebarWidth, + detailWidth, + sidebarSuppressed, + resizingPanel, + setPanelWidth, + startPanelResize, + stopPanelResize, + getPanelWidth, + setDetailActive, + closeDetailPanel, + collapseSidebar, + expandSidebar, + registerDetailCloseHandler, + detailPanelOpen, + }), + [ + sidebarWidth, + detailWidth, + sidebarSuppressed, + resizingPanel, + setPanelWidth, + startPanelResize, + stopPanelResize, + getPanelWidth, + setDetailActive, + closeDetailPanel, + collapseSidebar, + expandSidebar, + registerDetailCloseHandler, + detailPanelOpen, + ], + ); + + return <PanelManagerContext.Provider value={contextValue}>{children}</PanelManagerContext.Provider>; +}; + +export { usePanelManager }; + +export const usePanelResizeBindings = ( + panel: PanelKey, + { panelRef = null, enabled = true }: { panelRef?: RefObject<HTMLElement> | null; enabled?: boolean } = {}, +): PanelResizeBindings => { + const { + sidebarWidth, + detailWidth, + resizingPanel, + setPanelWidth, + startPanelResize, + stopPanelResize, + getPanelWidth, + } = usePanelManager(); + + const liveWidth = panel === 'sidebar' ? sidebarWidth : detailWidth; + const latestWidthRef = useRef(liveWidth); + const cleanupRef = useRef(null); + + useEffect(() => { + latestWidthRef.current = liveWidth; + }, [liveWidth]); + + const teardownListeners = useCallback(() => { + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + stopPanelResize(); + }, [stopPanelResize]); + + useEffect(() => () => teardownListeners(), [teardownListeners]); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent<HTMLDivElement>) => { + if (!enabled || !panelRef?.current) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const rect = panelRef.current.getBoundingClientRect(); + const startWidth = rect?.width ?? getPanelWidth(panel); + const pointerId = event.pointerId ?? 'mouse'; + const startX = event.clientX; + startPanelResize(panel); + event.currentTarget?.setPointerCapture?.(pointerId); + let lastWidth = startWidth; + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) { + return; + } + const delta = panel === 'sidebar' + ? moveEvent.clientX - startX + : startX - moveEvent.clientX; + lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false }); + latestWidthRef.current = lastWidth; + }; + + const handlePointerUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) { + return; + } + event.currentTarget?.releasePointerCapture?.(pointerId); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + setPanelWidth(panel, lastWidth); + teardownListeners(); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + cleanupRef.current = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + }; + }, + [ + enabled, + panelRef, + panel, + getPanelWidth, + startPanelResize, + setPanelWidth, + teardownListeners, + ], + ); + + const panelStyle = enabled ? { width: `${liveWidth}px` } : undefined; + + const handleProps = enabled + ? { + onPointerDown: handlePointerDown, + } + : {}; + + return { + panelStyle, + handleProps, + isPanelResizing: resizingPanel === panel, + }; +}; diff --git a/frontend/src/app/SettingsRoute.tsx b/frontend/src/app/SettingsRoute.tsx new file mode 100644 index 0000000..43ca189 --- /dev/null +++ b/frontend/src/app/SettingsRoute.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useCallback } from 'react'; +import SettingsModal from '../settings/SettingsModal'; +import { useAppShell } from '../lib/context/AppShellContext'; +import useApiTokens from '../settings/useApiTokens'; +import useCapabilitySets from '../settings/useCapabilitySets'; +import useCapabilities from '../settings/useCapabilities'; + +interface SettingsRouteProps { + open?: boolean; + onClose?: () => void; +} + +const SettingsRoute: React.FC<SettingsRouteProps> = ({ open = true, onClose }) => { + const shell = useAppShell() as any; + const { token } = shell.session || {}; + const { notifyApiError, setStatusMessage } = shell.ui || {}; + const { + passkeys, + passkeysSupported, + passkeysLoading, + registeringPasskey, + revokingPasskeyId, + refreshPasskeys, + registerPasskey, + revokePasskey, + } = shell.passkeys || {}; + + const { + tokens, + loading: tokensLoading, + creating: creatingToken, + deletingId, + regeneratingId, + createdSecret, + refresh: refreshTokens, + create: createToken, + revoke: revokeToken, + regenerate: regenerateToken, + dismissSecret, + } = useApiTokens({ token, notifyApiError, setStatusMessage }); + + const { + capabilitySets, + capabilitySetsLoading, + creatingCapabilitySet, + savingCapabilitySetId, + deletingCapabilitySetId, + supportsCapabilitySetLabels, + refreshCapabilitySets, + createCapabilitySet, + updateCapabilitySet, + deleteCapabilitySet, + } = useCapabilitySets({ token, notifyApiError, setStatusMessage }); + + const { + capabilities, + capabilitiesLoading, + refreshCapabilities, + } = useCapabilities({ notifyApiError, token }); + + useEffect(() => { + refreshTokens(); + refreshCapabilitySets(); + refreshCapabilities(); + refreshPasskeys(); + }, [refreshTokens, refreshCapabilitySets, refreshCapabilities, refreshPasskeys]); + + const handleRefresh = useCallback(() => { + refreshTokens(); + refreshCapabilitySets(); + refreshCapabilities(); + }, [refreshTokens, refreshCapabilitySets, refreshCapabilities]); + + const handleClose = useCallback(() => { + dismissSecret(); + onClose?.(); + }, [dismissSecret, onClose]); + + useEffect(() => () => { + dismissSecret(); + }, [dismissSecret]); + + if (!open) { + return null; + } + + return ( + <SettingsModal + open={open} + onClose={handleClose} + tokens={tokens} + loading={tokensLoading} + creating={creatingToken} + deletingId={deletingId} + regeneratingId={regeneratingId} + onRefresh={handleRefresh} + onCreate={createToken} + onDelete={revokeToken} + onRegenerate={regenerateToken} + createdToken={createdSecret} + onDismissCreatedToken={dismissSecret} + capabilitySets={capabilitySets} + capabilitySetsLoading={capabilitySetsLoading} + creatingCapabilitySet={creatingCapabilitySet} + savingCapabilitySetId={savingCapabilitySetId} + deletingCapabilitySetId={deletingCapabilitySetId} + supportsCapabilitySetLabels={supportsCapabilitySetLabels} + onRefreshCapabilitySets={refreshCapabilitySets} + capabilities={capabilities} + capabilitiesLoading={capabilitiesLoading} + onRefreshCapabilities={refreshCapabilities} + onCreateCapabilitySet={createCapabilitySet} + onUpdateCapabilitySet={updateCapabilitySet} + onDeleteCapabilitySet={deleteCapabilitySet} + passkeys={passkeys} + passkeysSupported={passkeysSupported} + passkeysLoading={passkeysLoading} + registeringPasskey={registeringPasskey} + revokingPasskeyId={revokingPasskeyId} + onRefreshPasskeys={refreshPasskeys} + onRegisterPasskey={registerPasskey} + onRevokePasskey={revokePasskey} + /> + ); +}; + +export default SettingsRoute; diff --git a/frontend/src/app/UploadQueueOverlay.tsx b/frontend/src/app/UploadQueueOverlay.tsx new file mode 100644 index 0000000..be9704b --- /dev/null +++ b/frontend/src/app/UploadQueueOverlay.tsx @@ -0,0 +1,208 @@ +import { useMemo, useState, useEffect } from 'react'; +import type { JSX } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + CloseIcon, + LoaderIcon, + CheckIcon, + InfoIcon, + WarningIcon, + BottombarCollapseIcon, + BottombarExpandIcon, +} from '../components/icons'; +import PanelHeader from '../components/PanelHeader'; + +type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {}); + +interface UploadQueueItem { + id: string; + name: string; + status: UploadStatus; + error?: string | null; + document?: { id?: string; title?: string }; + conflictDocumentId?: string; +} + +interface UploadQueueOverlayProps { + queue?: UploadQueueItem[]; + onClearQueue?: () => void; +} + +const STATUS_META: Record<string, { label: string; tone: string; icon: JSX.Element }> = { + pending: { + label: 'Queued', + tone: 'muted', + icon: <LoaderIcon className="icon icon--spin" size={16} />, + }, + uploading: { + label: 'Uploading', + tone: 'accent', + icon: <LoaderIcon className="icon icon--spin" size={16} />, + }, + success: { + label: 'Uploaded', + tone: 'success', + icon: <CheckIcon size={16} />, + }, + duplicate: { + label: 'Duplicate', + tone: 'info', + icon: <InfoIcon size={16} />, + }, + error: { + label: 'Failed', + tone: 'danger', + icon: <WarningIcon size={16} />, + }, +}; + +const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProps): JSX.Element | null => { + const navigate = useNavigate(); + const [collapsed, setCollapsed] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (queue.length > 0) { + setDismissed(false); + } + }, [queue.length]); + + const summary = useMemo(() => { + if (!queue.length) { + return 'No uploads'; + } + const uploadingCount = queue.filter((item) => item.status === 'uploading').length; + const pendingCount = queue.filter((item) => item.status === 'pending').length; + const errorCount = queue.filter((item) => item.status === 'error').length; + if (uploadingCount > 0 || pendingCount > 0) { + return `${uploadingCount} uploading · ${pendingCount} queued`; + } + if (errorCount > 0) { + return `${errorCount} failed · ${queue.length} total`; + } + return `${queue.length} completed`; + }, [queue]); + + const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending'); + + const handleDismissOverlay = () => { + if (!queue.length) { + setDismissed(true); + return; + } + + if (hasActiveUploads) { + const confirmed = window.confirm('Uploads are still running. Clear the queue and hide the overlay?'); + if (!confirmed) { + return; + } + } + + onClearQueue?.(); + setDismissed(true); + }; + + if (!queue.length || dismissed) { + return null; + } + + return ( + <div className={`upload-queue-overlay${collapsed ? ' upload-queue-overlay--collapsed' : ''}`}> + <PanelHeader + title={( + <span> + <span>Uploads</span> + <span className="panel-header__subtitle">{summary}</span> + </span> + )} + actions={( + <div className="upload-queue-overlay__controls"> + <button + type="button" + className="icon-button ghost" + onClick={() => setCollapsed(!collapsed)} + aria-label={collapsed ? 'Expand upload queue' : 'Collapse upload queue'} + > + {collapsed ? <BottombarExpandIcon size={16} /> : <BottombarCollapseIcon size={16} />} + </button> + <button + type="button" + className="icon-button" + onClick={handleDismissOverlay} + aria-label="Clear uploads and hide overlay" + > + <CloseIcon size={16} /> + </button> + </div> + )} + /> + {!collapsed ? ( + <div className="upload-queue-overlay__body"> + <ul className="upload-queue-overlay__list"> + {[...queue] + .slice() + .reverse() + .map((item) => { + const meta = STATUS_META[item.status] || STATUS_META.pending; + const fileLabel = item.name; + const documentTitle = item.document?.title || null; + const duplicateLabel = item.status === 'duplicate' ? documentTitle : null; + const documentId = item.document?.id || item.conflictDocumentId || null; + const hasLink = Boolean(documentId); + const handleNavigate = () => { + if (!documentId) { + return; + } + navigate(`/documents/${documentId}`); + }; + return ( + <li key={item.id} className={`upload-queue-overlay__item upload-queue-overlay__item--${item.status}`}> + <span className={`upload-queue-overlay__status upload-queue-overlay__status--${meta.tone}`}> + {meta.icon} + </span> + <div className="upload-queue-overlay__details"> + {item.status === 'success' && hasLink ? ( + <button + type="button" + className="upload-queue-overlay__name-link" + onClick={handleNavigate} + > + {fileLabel} + </button> + ) : ( + <div className="upload-queue-overlay__name"> + {fileLabel} + </div> + )} + <div className="upload-queue-overlay__meta-line"> + {item.status === 'duplicate' && duplicateLabel ? ( + <span className="upload-queue-overlay__meta-duplicate"> + Duplicate of{' '} + <button + type="button" + className="upload-queue-overlay__meta-link" + onClick={handleNavigate} + > + {duplicateLabel} + </button> + </span> + ) : item.status === 'error' && item.error ? ( + <span className="upload-queue-overlay__meta-error" title={item.error}> + {item.error} + </span> + ) : ( + <span>{meta.label}</span> + )} + </div> + </div> + </li> + ); + })} + </ul> + </div> + ) : null} + </div> + ); +}; + +export default UploadQueueOverlay; diff --git a/frontend/src/app/WorkspaceSelectionContext.tsx b/frontend/src/app/WorkspaceSelectionContext.tsx new file mode 100644 index 0000000..c562f31 --- /dev/null +++ b/frontend/src/app/WorkspaceSelectionContext.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import type { useWorkspaceSelection } from './useWorkspaceSelection'; +import { createSafeContext } from '../utils/createSafeContext'; + +type WorkspaceSelectionValue = ReturnType<typeof useWorkspaceSelection>; + +const [WorkspaceSelectionContext, useWorkspaceSelectionContext] = createSafeContext<WorkspaceSelectionValue>('WorkspaceSelection'); + +interface WorkspaceSelectionProviderProps { + value: WorkspaceSelectionValue; + children: React.ReactNode; +} + +export const WorkspaceSelectionProvider: React.FC<WorkspaceSelectionProviderProps> = ({ value, children }) => ( + <WorkspaceSelectionContext.Provider value={value}> + {children} + </WorkspaceSelectionContext.Provider> +); + +export { useWorkspaceSelectionContext }; diff --git a/frontend/src/app/entryKey.ts b/frontend/src/app/entryKey.ts new file mode 100644 index 0000000..74e304b --- /dev/null +++ b/frontend/src/app/entryKey.ts @@ -0,0 +1,25 @@ +import type { DocumentId, FolderId } from '../types/identifiers'; +import { ENTRY_KEY_SEPARATOR } from '../constants/app'; + +// Entry key utilities for workspace selection +// Entry keys are strings in the format "document:id" or "folder:id" + +// Create entry key strings +export const createDocumentEntryKey = (documentId: DocumentId): string => + `document${ENTRY_KEY_SEPARATOR}${documentId}`; + +export const createFolderEntryKey = (folderId: FolderId): string => + `folder${ENTRY_KEY_SEPARATOR}${folderId}`; + +// Type guards for entry key strings +export const isDocumentEntry = (key: string): boolean => + key.split(ENTRY_KEY_SEPARATOR, 1)[0] === 'document'; + +export const isFolderEntry = (key: string): boolean => + key.split(ENTRY_KEY_SEPARATOR, 1)[0] === 'folder'; + +// Extract ID from entry key string +export const getEntryId = (key: string): string => { + const parts = key.split(ENTRY_KEY_SEPARATOR); + return parts.slice(1).join(ENTRY_KEY_SEPARATOR); +}; diff --git a/frontend/src/app/useDetailPanel.ts b/frontend/src/app/useDetailPanel.ts new file mode 100644 index 0000000..3df14c1 --- /dev/null +++ b/frontend/src/app/useDetailPanel.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { Identifier } from '../types/identifiers'; + +interface DetailDocument { + id?: Identifier; + [key: string]: unknown; +} + +interface UseDetailPanelOptions { + documentLookup: Map<Identifier, DetailDocument>; +} + +export const useDetailPanel = ({ + documentLookup, +}: UseDetailPanelOptions) => { + const [detailPanelDocId, setDetailPanelDocId] = useState<Identifier | null>(null); + const [detailPanelDocument, setDetailPanelDocument] = useState<DetailDocument | null>(null); + + const detailPanelOpen = detailPanelDocId !== null; + + useEffect(() => { + if (!detailPanelDocId) { + setDetailPanelDocument(null); + return; + } + const resolved = documentLookup.get(detailPanelDocId) ?? null; + if (resolved !== detailPanelDocument) { + setDetailPanelDocument(resolved); + } + }, [detailPanelDocId, documentLookup, detailPanelDocument]); + + const openDetailPanel = useCallback( + (documentId: Identifier) => { + if (!documentId) { + return; + } + setDetailPanelDocId(documentId); + }, + [], + ); + + const closeDetailPanel = useCallback(() => { + setDetailPanelDocId(null); + }, []); + + return { + detailPanelOpen, + detailPanelDocument, + openDetailPanel, + closeDetailPanel, + }; +}; diff --git a/frontend/src/app/useDocumentSelection.ts b/frontend/src/app/useDocumentSelection.ts new file mode 100644 index 0000000..a96d92c --- /dev/null +++ b/frontend/src/app/useDocumentSelection.ts @@ -0,0 +1,287 @@ +import { useCallback, useRef, useState } from 'react'; +import { + createDocumentEntryKey, + createFolderEntryKey, + isDocumentEntry, + isFolderEntry, + getEntryId, +} from './entryKey'; +import type { DocumentId } from '../types/identifiers'; + +interface SelectionEventLike { + shiftKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; + preventDefault?: () => void; +} + +interface UseDocumentSelectionOptions { + initialEntries?: string[]; +} + +interface ApplySelectionOptions { + anchor: string | null; + interactedKeys?: string[]; +} + +const DEFAULT_INITIAL_ENTRIES: string[] = []; + +export const useDocumentSelection = ({ + initialEntries = DEFAULT_INITIAL_ENTRIES, +}: UseDocumentSelectionOptions = {}) => { + const [selectedEntries, setSelectedEntries] = useState<string[]>(initialEntries); + const [selectionOrder, setSelectionOrder] = useState<string[]>(initialEntries); + const selectionOrderRef = useRef<string[]>(initialEntries); + const selectionAnchorRef = useRef<string | null>(null); + const selectionInitializedRef = useRef(false); + const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | null>(null); + const [focusedEntryKey, setFocusedEntryKey] = useState<string | null>(null); + + const visibleEntryKeySetRef = useRef<Set<string>>(new Set()); + const navigableEntryKeysRef = useRef<string[]>([]); + + const configureSelectionEnvironment = useCallback(({ + visibleEntryKeySet, + navigableEntryKeys, + }: { + visibleEntryKeySet?: Set<string>; + navigableEntryKeys?: string[]; + }) => { + if (visibleEntryKeySet) { + visibleEntryKeySetRef.current = visibleEntryKeySet; + } + if (Array.isArray(navigableEntryKeys)) { + navigableEntryKeysRef.current = navigableEntryKeys; + } + }, []); + + const updateSelectionOrder = useCallback((nextSelection: string[], interactedKeys: string[] = []) => { + const nextSet = new Set(nextSelection); + const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id)); + const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index); + + const base = previousOrder.filter((id) => !interacted.includes(id)); + const result = [...base]; + + interacted.forEach((id) => { + if (nextSet.has(id) && !result.includes(id)) { + result.push(id); + } + }); + + nextSelection.forEach((id) => { + if (!result.includes(id)) { + result.push(id); + } + }); + + if ( + result.length !== selectionOrderRef.current.length + || result.some((id, index) => selectionOrderRef.current[index] !== id) + ) { + selectionOrderRef.current = result; + setSelectionOrder(result); + } else { + selectionOrderRef.current = result; + } + }, []); + + const applySelection = useCallback( + ( + entryKeys: Array<string | null>, + { anchor = null, interactedKeys = [] }: ApplySelectionOptions = { anchor: null, interactedKeys: [] }, + ) => { + const visibleEntryKeySet = visibleEntryKeySetRef.current; + const unique: string[] = []; + + (entryKeys || []).forEach((key) => { + if (!key) return; + let canonicalKey: string | null = null; + if (visibleEntryKeySet.has(key)) { + canonicalKey = key; + } else if (isDocumentEntry(key)) { + const id = getEntryId(key); + canonicalKey = id ? createDocumentEntryKey(id) : null; + } else if (isFolderEntry(key)) { + const id = getEntryId(key); + canonicalKey = id ? createFolderEntryKey(id) : null; + } + + if (!canonicalKey || !visibleEntryKeySet.has(canonicalKey)) { + return; + } + + if (!unique.includes(canonicalKey)) { + unique.push(canonicalKey); + } + }); + + let resolvedAnchor: string | null = anchor ?? null; + if (resolvedAnchor && !unique.includes(resolvedAnchor)) { + resolvedAnchor = null; + } + + setSelectedEntries(unique); + updateSelectionOrder(unique, interactedKeys); + + const nextFocusedDocumentId: DocumentId | null = (() => { + if (focusedDocumentId) { + const focusKey = createDocumentEntryKey(focusedDocumentId); + if (focusKey && unique.includes(focusKey)) { + return focusedDocumentId; + } + } + + if (resolvedAnchor && isDocumentEntry(resolvedAnchor)) { + return getEntryId(resolvedAnchor) ?? null; + } + + const lastDocKey = [...unique].reverse().find((key) => isDocumentEntry(key)) ?? null; + return lastDocKey ? getEntryId(lastDocKey) ?? null : null; + })(); + + setFocusedDocumentId(nextFocusedDocumentId); + + if (resolvedAnchor) { + selectionAnchorRef.current = resolvedAnchor; + } else if (!unique.length) { + selectionAnchorRef.current = null; + } else if (!selectionAnchorRef.current || !unique.includes(selectionAnchorRef.current)) { + selectionAnchorRef.current = unique[unique.length - 1]; + } + + return { selection: unique, focusKey: selectionAnchorRef.current }; + }, + [ + focusedDocumentId, + updateSelectionOrder, + ], + ); + + const clearSelection = useCallback(() => { + setFocusedEntryKey(null); + applySelection([], { anchor: null, interactedKeys: [] }); + }, [applySelection]); + + const handleEntrySelection = useCallback( + (entryKeyOrKeys: string | string[], event?: SelectionEventLike) => { + const visibleEntryKeySet = visibleEntryKeySetRef.current; + const navigableEntryKeys = navigableEntryKeysRef.current; + + const entryKeys = Array.isArray(entryKeyOrKeys) ? entryKeyOrKeys : [entryKeyOrKeys]; + const validKeys = entryKeys.filter((key) => key && visibleEntryKeySet.has(key)); + + if (validKeys.length === 0) { + return; + } + + // Focus the last valid key + const lastKey = validKeys[validKeys.length - 1]; + setFocusedEntryKey(lastKey); + + const shiftKey = Boolean(event?.shiftKey); + const metaKey = Boolean(event?.metaKey); + const ctrlKey = Boolean(event?.ctrlKey); + const additive = metaKey || ctrlKey; + + if (shiftKey) { + event?.preventDefault?.(); + } + + let anchorKey = selectionAnchorRef.current; + if (!anchorKey && shiftKey && selectedEntries.length) { + anchorKey = selectedEntries[selectedEntries.length - 1]; + } + if (!anchorKey) { + anchorKey = lastKey; + } + + let nextKeys: string[] = []; + let interactedKeys: string[] = []; + + // Shift selection logic (range) - primarily for single click + shift + if (shiftKey && anchorKey && validKeys.length === 1) { + const entryKey = validKeys[0]; + const anchorIndex = navigableEntryKeys.indexOf(anchorKey); + const targetIndex = navigableEntryKeys.indexOf(entryKey); + if (anchorIndex !== -1 && targetIndex !== -1) { + const [start, end] = anchorIndex <= targetIndex + ? [anchorIndex, targetIndex] + : [targetIndex, anchorIndex]; + const range = navigableEntryKeys.slice(start, end + 1); + nextKeys = range; + + const previousSet = new Set(selectedEntries); + interactedKeys = range.filter((key) => key === entryKey || !previousSet.has(key)); + if (!interactedKeys.includes(entryKey)) { + interactedKeys.push(entryKey); + } + } else { + nextKeys = [entryKey]; + interactedKeys = [entryKey]; + } + } else if (additive) { + // Additive batch + const previousSet = new Set(selectedEntries); + if (validKeys.length === 1) { + const entryKey = validKeys[0]; + if (previousSet.has(entryKey)) { + nextKeys = selectedEntries.filter((key) => key !== entryKey); + interactedKeys = []; + } else { + nextKeys = [...selectedEntries, entryKey]; + interactedKeys = [entryKey]; + } + } else { + // Batch add + validKeys.forEach(key => previousSet.add(key)); + nextKeys = Array.from(previousSet) as string[]; + interactedKeys = validKeys; + } + anchorKey = lastKey; + } else { + // Replace with batch + nextKeys = validKeys; + interactedKeys = validKeys; + anchorKey = lastKey; + } + + applySelection(nextKeys, { anchor: anchorKey, interactedKeys }); + }, + [applySelection, selectedEntries], + ); + + const promoteSelectionOrder = useCallback( + (docId?: DocumentId | null) => { + if (!docId) return; + const entryKey = createDocumentEntryKey(docId); + if (!entryKey) return; + + if (!selectedEntries.includes(entryKey)) { + return; + } + + updateSelectionOrder(selectedEntries, [entryKey]); + }, + [selectedEntries, updateSelectionOrder], + ); + + return { + selectedEntries, + setSelectedEntries, + selectionOrder, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + selectionInitializedRef, + focusedDocumentId, + setFocusedDocumentId, + focusedEntryKey, + setFocusedEntryKey, + applySelection, + clearSelection, + handleEntrySelection, + promoteSelectionOrder, + configureSelectionEnvironment, + }; +}; diff --git a/frontend/src/app/useDocumentViewer.ts b/frontend/src/app/useDocumentViewer.ts new file mode 100644 index 0000000..344cc20 --- /dev/null +++ b/frontend/src/app/useDocumentViewer.ts @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useRef, useMemo } from 'react'; +import type { + Dispatch, + MutableRefObject, + SetStateAction, +} from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { DocumentId } from '../types/identifiers'; + +type FolderId = DocumentId | 'root'; + +import type { Document } from '../types/documents'; +import useNotifyApiError from '../hooks/useNotifyApiError'; + +interface UseDocumentViewerArgs { + routeDocumentId?: DocumentId | null; + documentsManager: { + getById: (id: DocumentId) => Document | null; + ensure: (id: DocumentId) => Promise<Document | null>; + getMany: (ids: DocumentId[]) => Document[]; + subscribe: (listener: () => void) => () => void; + ingest: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; + }; + selectedFolder?: FolderId | null; + locationPathname: string; + locationSearch: string; + detailPanelControlRef: MutableRefObject<{ + open?: (args?: { documentIds?: DocumentId[] }) => void; + close?: () => void; + } | null>; + setActiveViewerId: Dispatch<SetStateAction<DocumentId | null>>; +} + +interface UseDocumentViewerResult { + ensureViewerData: (documentId: DocumentId) => Promise<Document | null>; + openDocumentViewer: (documentId: DocumentId, options?: { replace?: boolean }) => void; + closeDocumentViewer: (folderId?: FolderId) => void; + resetViewerState: () => void; + viewerWorkspaceDocument: Document | null; + viewerActive: boolean; +} + +const useDocumentViewer = ({ + routeDocumentId, + documentsManager, + selectedFolder, + locationPathname, + locationSearch, + detailPanelControlRef, + setActiveViewerId, +}: UseDocumentViewerArgs): UseDocumentViewerResult => { + const viewerReturnPathRef = useRef<string | null>(null); + const notifyApiError = useNotifyApiError(); + const navigate = useNavigate(); + + const resetViewerState = useCallback(() => { + viewerReturnPathRef.current = null; + }, []); + + const ensureViewerData = useCallback( + async (documentId: DocumentId): Promise<Document | null> => { + if (!documentId) return null; + + const doc = await documentsManager.ensure(documentId); + + if (!doc) { + throw new Error('Document metadata unavailable.'); + } + + if (!viewerReturnPathRef.current) { + const fallbackFolderId = doc?.folder_id || 'root'; + viewerReturnPathRef.current = + fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`; + } + + setActiveViewerId(documentId); + return doc; + }, + [ + documentsManager, + setActiveViewerId, + ], + ); + + const openDocumentViewer = useCallback( + (documentId: DocumentId, { replace = false }: { replace?: boolean } = {}) => { + if (!documentId) return; + detailPanelControlRef.current?.close?.(); + viewerReturnPathRef.current = `${locationPathname}${locationSearch}`; + navigate(`/documents/${documentId}`, { replace }); + }, + [navigate, locationPathname, locationSearch, detailPanelControlRef], + ); + + const closeDocumentViewer = useCallback( + (folderId?: FolderId) => { + const fallbackPath = viewerReturnPathRef.current; + viewerReturnPathRef.current = null; + + if (fallbackPath) { + navigate(fallbackPath, { replace: false }); + return; + } + + const targetId = folderId || selectedFolder || 'root'; + const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; + navigate(path, { replace: false }); + }, + [navigate, selectedFolder], + ); + + useEffect(() => { + if (!routeDocumentId) { + return undefined; + } + + let cancelled = false; + + ensureViewerData(routeDocumentId).catch((error) => { + if (cancelled) { + return; + } + notifyApiError(error, 'Failed to open document preview.'); + closeDocumentViewer(); + }); + + return () => { + cancelled = true; + }; + }, [routeDocumentId, ensureViewerData, notifyApiError, closeDocumentViewer]); + + const viewerWorkspaceDocument = useMemo(() => { + if (!routeDocumentId) { + return null; + } + return documentsManager.getById(routeDocumentId); + }, [routeDocumentId, documentsManager]); + + const viewerActive = Boolean(routeDocumentId && viewerWorkspaceDocument); + + return { + ensureViewerData, + openDocumentViewer, + closeDocumentViewer, + resetViewerState, + viewerWorkspaceDocument, + viewerActive, + }; +}; + +export default useDocumentViewer; diff --git a/frontend/src/app/useDocumentsPreferences.ts b/frontend/src/app/useDocumentsPreferences.ts new file mode 100644 index 0000000..036d47c --- /dev/null +++ b/frontend/src/app/useDocumentsPreferences.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + DEFAULT_SORT_DIRECTION, + DEFAULT_SORT_FIELD, + SORT_FIELD_VALUES, +} from './workspaceUtils'; +import { + INCLUDE_DESCENDANTS_STORAGE_KEY, + SORT_DIRECTION_STORAGE_KEY, + SORT_FIELD_STORAGE_KEY, + VIEW_MODE_STORAGE_KEY, +} from '../constants/workspace'; + +const readSessionStorage = (key: string): string | null => { + try { + return window.sessionStorage.getItem(key); + } catch (error) { + console.warn(`[session-storage] failed to read ${key}`, error); + return null; + } +}; + +const writeSessionStorage = (key: string, value: string): void => { + try { + window.sessionStorage.setItem(key, value); + } catch (error) { + console.warn(`[session-storage] failed to persist ${key}`, error); + } +}; + +export const useDocumentsPreferences = () => { + const [documentsViewMode, setDocumentsViewModeState] = useState<'list' | 'grid' | 'desk'>(() => { + const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY); + if (stored === 'grid' || stored === 'desk') { + return stored; + } + return 'list'; + }); + + const lastNonDeskViewRef = useRef<'list' | 'grid'>((documentsViewMode === 'desk' ? 'list' : documentsViewMode) as 'list' | 'grid'); + + const setDocumentsViewMode = useCallback((mode: string) => { + const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list'; + setDocumentsViewModeState((previous) => { + if (next !== previous) { + writeSessionStorage(VIEW_MODE_STORAGE_KEY, next); + } + return next; + }); + }, []); + + const handleDeskExit = useCallback(() => { + const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk' + ? lastNonDeskViewRef.current + : 'list'; + setDocumentsViewMode(fallback); + }, [setDocumentsViewMode]); + + const [documentsSortField, setDocumentsSortField] = useState(() => { + const stored = readSessionStorage(SORT_FIELD_STORAGE_KEY); + return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD; + }); + useEffect(() => { + writeSessionStorage(SORT_FIELD_STORAGE_KEY, documentsSortField); + }, [documentsSortField]); + + const [documentsSortDirection, setDocumentsSortDirection] = useState(() => { + const stored = readSessionStorage(SORT_DIRECTION_STORAGE_KEY); + return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION; + }); + + useEffect(() => { + writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection); + }, [documentsSortDirection]); + + const handleDocumentsSortFieldChange = useCallback((field: string) => { + const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD; + setDocumentsSortField((previous) => (previous === nextField ? previous : nextField)); + }, []); + + const handleDocumentsSortDirectionToggle = useCallback(() => { + setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc')); + }, []); + + const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => { + const stored = readSessionStorage(INCLUDE_DESCENDANTS_STORAGE_KEY); + if (stored === 'true') return true; + if (stored === 'false') return false; + return true; + }); + useEffect(() => { + writeSessionStorage( + INCLUDE_DESCENDANTS_STORAGE_KEY, + searchIncludeDescendants ? 'true' : 'false', + ); + }, [searchIncludeDescendants]); + + const toggleSearchIncludeDescendants = useCallback(() => { + setSearchIncludeDescendants((previous) => !previous); + }, []); + + return { + documentsViewMode, + handleDocumentsViewModeChange: setDocumentsViewMode, + handleDeskExit, + documentsSortField, + documentsSortDirection, + handleDocumentsSortFieldChange, + handleDocumentsSortDirectionToggle, + searchIncludeDescendants, + setSearchIncludeDescendants, + toggleSearchIncludeDescendants, + }; +}; diff --git a/frontend/src/app/useDocumentsSearch.ts b/frontend/src/app/useDocumentsSearch.ts new file mode 100644 index 0000000..be3588f --- /dev/null +++ b/frontend/src/app/useDocumentsSearch.ts @@ -0,0 +1,298 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAppState } from '../lib/store/appState'; +import { TAG_FILTER_UNTAGGED } from './workspaceUtils'; +import { listDocuments } from '../lib/api/apiClient'; +import type { Identifier } from '../types/identifiers'; + +import type { Document } from '../types/documents'; + +type ApiClient = { + get: <T = unknown>(url: string, config?: { params?: Record<string, unknown> }) => Promise<{ data: T }>; +}; + +import useNotifyApiError from '../hooks/useNotifyApiError'; + +interface UseDocumentsSearchArgs { + api: ApiClient; + selectedFolder?: Identifier | 'root' | null; + locationPathname?: string; + isDocumentsRoute?: boolean; + searchIncludeDescendants?: boolean; + documentsSortField?: string; + documentsSortDirection?: string; + setSearchIncludeDescendants: (value: boolean) => void; + documentsManager: { + ingest: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; + }; +} + +interface UseDocumentsSearchResult { + searchQuery: string; + setSearchQuery: Dispatch<SetStateAction<string>>; + searchResultIds: Identifier[] | null; + setSearchResultIds: Dispatch<SetStateAction<Identifier[] | null>>; + searchLoading: boolean; + setSearchLoading: Dispatch<SetStateAction<boolean>>; + activeTagFilters: Identifier[]; + setActiveTagFilters: Dispatch<SetStateAction<Identifier[]>>; + activeCorrespondentFilters: Identifier[]; + setActiveCorrespondentFilters: Dispatch<SetStateAction<Identifier[]>>; + toggleTagFilter: (tagId: Identifier) => void; + toggleCorrespondentFilter: (correspondentId?: Identifier | null) => void; + isFilterActive: boolean; + clearFilters: () => void; + handleSearchChange: (value: string) => void; + handleSearchSubmit: () => void; + refetchSearchResults: () => void; + documentsFilterValue: { + query: string; + searchResultIds: Identifier[] | null; + searchLoading: boolean; + includeDescendants: boolean; + activeTagIds: Identifier[]; + activeCorrespondentIds: Identifier[]; + isActive: boolean; + setQuery: (value: string) => void; + submit: () => void; + clear: () => void; + toggleTag: (tagId: Identifier) => void; + toggleCorrespondent: (correspondentId?: Identifier | null) => void; + toggleIncludeDescendants: () => void; + }; +} + +const useDocumentsSearch = ({ + api, + selectedFolder, + locationPathname, + isDocumentsRoute, + searchIncludeDescendants, + documentsSortField, + documentsSortDirection, + setSearchIncludeDescendants, + documentsManager, +}: UseDocumentsSearchArgs): UseDocumentsSearchResult => { + const { token } = useAppState(); + const [searchQuery, setSearchQuery] = useState<string>(''); + const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]); + const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]); + const [searchResultIds, setSearchResultIds] = useState<Identifier[] | null>(null); + const [searchLoading, setSearchLoading] = useState<boolean>(false); + const [searchTrigger, setSearchTrigger] = useState<number>(0); + const notifyApiError = useNotifyApiError(); + const navigate = useNavigate(); + + const toggleTagFilter = useCallback((tagId: Identifier) => { + if (!tagId) return; + setActiveTagFilters((previous) => { + if (tagId === TAG_FILTER_UNTAGGED) { + return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED]; + } + const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED); + if (sanitized.includes(tagId)) { + return sanitized.filter((id) => id !== tagId); + } + return sanitized.concat([tagId]); + }); + }, []); + + const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => { + setActiveCorrespondentFilters((previous) => { + if (!correspondentId) { + return []; + } + return previous.includes(correspondentId) ? [] : [correspondentId]; + }); + }, []); + + const isFilterActive = useMemo( + () => + searchQuery.trim().length > 0 + || activeTagFilters.length > 0 + || activeCorrespondentFilters.length > 0, + [searchQuery, activeTagFilters, activeCorrespondentFilters], + ); + + const clearFilters = useCallback(() => { + setSearchQuery(''); + setActiveTagFilters([]); + setActiveCorrespondentFilters([]); + setSearchLoading(false); + setSearchIncludeDescendants(true); + setSearchResultIds(null); + }, [ + setSearchIncludeDescendants, + ]); + + const handleSearchChange = useCallback((value: string) => { + setSearchQuery(value); + }, []); + + const handleSearchSubmit = useCallback(() => { + if (!navigate) return; + const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root'; + const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`; + if (!isDocumentsRoute || locationPathname !== targetPath) { + navigate(targetPath, { replace: false }); + } + }, [navigate, selectedFolder, isDocumentsRoute, locationPathname]); + + const documentsFilterValue = useMemo( + () => ({ + query: searchQuery, + searchResultIds, + searchLoading, + includeDescendants: Boolean(searchIncludeDescendants), + activeTagIds: activeTagFilters, + activeCorrespondentIds: activeCorrespondentFilters, + isActive: isFilterActive, + setQuery: handleSearchChange, + submit: handleSearchSubmit, + clear: clearFilters, + toggleTag: toggleTagFilter, + toggleCorrespondent: toggleCorrespondentFilter, + toggleIncludeDescendants: () => setSearchIncludeDescendants(!searchIncludeDescendants), + }), + [ + searchQuery, + searchResultIds, + searchLoading, + searchIncludeDescendants, + activeTagFilters, + activeCorrespondentFilters, + isFilterActive, + handleSearchChange, + handleSearchSubmit, + clearFilters, + toggleTagFilter, + toggleCorrespondentFilter, + setSearchIncludeDescendants, + ], + ); + + const refetchSearchResults = useCallback(() => { + setSearchTrigger(Date.now()); + }, []); + + useEffect(() => { + if (!token) return undefined; + + if (!isFilterActive) { + setSearchResultIds(null); + setSearchLoading(false); + return undefined; + } + + let cancelled = false; + let started = false; + setSearchLoading(true); + + const debounce = setTimeout(async () => { + started = true; + try { + const params: Record<string, unknown> = {}; + const trimmedQuery = searchQuery.trim(); + if (trimmedQuery.length) { + params.query = trimmedQuery; + } + if (activeTagFilters.length) { + const onlyUntagged = activeTagFilters.length === 1 + && activeTagFilters[0] === TAG_FILTER_UNTAGGED; + if (onlyUntagged) { + params.tags = 'none'; + } else { + const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED); + if (tagIds.length) { + params.tags = tagIds.join(','); + } + } + } + if (activeCorrespondentFilters.length) { + params.correspondents = activeCorrespondentFilters.join(','); + } + const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder; + if (folderIdentifier) { + params.folder_id = folderIdentifier; + } + if (!searchIncludeDescendants) { + params.include_descendants = false; + } + if (documentsSortField) { + params.sort = documentsSortField; + } + if (documentsSortDirection) { + params.dir = documentsSortDirection; + } + const data = await listDocuments(params); + if (cancelled) return; + + const results = Array.isArray(data) ? data : []; + const { canonical } = documentsManager.ingest(results); + const ids = canonical + .map((doc) => (doc?.id ?? null) as Identifier | null) + .filter((id): id is Identifier => id != null); + setSearchResultIds(ids); + + if (!ids.length) { + setSearchLoading(false); + return; + } + } catch (error) { + if (cancelled) return; + notifyApiError(error, 'Search failed. Please try again.'); + setSearchResultIds(null); + } finally { + if (!cancelled && started) { + setSearchLoading(false); + } + } + }, 300); + + return () => { + cancelled = true; + clearTimeout(debounce); + if (started) { + setSearchLoading(false); + } + }; + }, [ + api, + token, + isFilterActive, + searchQuery, + activeTagFilters, + activeCorrespondentFilters, + searchIncludeDescendants, + documentsSortField, + documentsSortDirection, + selectedFolder, + notifyApiError, + documentsManager, + searchTrigger, + ]); + + return { + searchQuery, + setSearchQuery, + searchResultIds, + setSearchResultIds, + searchLoading, + setSearchLoading, + activeTagFilters, + setActiveTagFilters, + activeCorrespondentFilters, + setActiveCorrespondentFilters, + toggleTagFilter, + toggleCorrespondentFilter, + isFilterActive, + clearFilters, + handleSearchChange, + handleSearchSubmit, + refetchSearchResults, + documentsFilterValue, + }; +}; + +export default useDocumentsSearch; diff --git a/frontend/src/app/useDocumentsShell.ts b/frontend/src/app/useDocumentsShell.ts new file mode 100644 index 0000000..bec4546 --- /dev/null +++ b/frontend/src/app/useDocumentsShell.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import { useAppShell } from '../lib/context/AppShellContext'; +import type { DocumentsFilterValue } from '../documents/context/DocumentsFilterContext'; +import type { UseWorkspaceSurfaceArgs } from './useWorkspaceSurface'; +import type { Identifier } from '../types/identifiers'; + +type WorkspaceSurfaceConfig = Omit<UseWorkspaceSurfaceArgs, 'sidebarHidden' | 'onExpandSidebar'> & { + openDetailPanel?: (documentId: Identifier) => void; + closeDetailPanel?: () => void; + handleBreadcrumbNavigate?: (crumb: any) => void; +}; + +interface DocumentsShellView { + surfaceConfig: WorkspaceSurfaceConfig; + documentsFilter: DocumentsFilterValue; + documentsManager: any; + foldersManager: any; +} + +const useDocumentsShell = (): DocumentsShellView => { + const shell = useAppShell() as any; + + return useMemo(() => { + const surfaceConfig: WorkspaceSurfaceConfig = { + viewMode: shell.search?.documentsViewMode, + detailPanelProps: (shell.detailPanel?.detailPanelProps ?? null) as WorkspaceSurfaceConfig['detailPanelProps'], + detailPanelOpen: Boolean(shell.detailPanel?.detailPanelOpen), + openDetailPanel: shell.detailPanel?.openDetailPanel as WorkspaceSurfaceConfig['openDetailPanel'], + closeDetailPanel: shell.detailPanel?.closeDetailPanel as WorkspaceSurfaceConfig['closeDetailPanel'], + viewerWorkspaceDocument: shell.preview?.viewerWorkspaceDocument, + viewerDocumentId: (shell.preview?.viewerDocumentId as Identifier) ?? null, + closeDocumentViewer: shell.preview?.closeDocumentViewer as WorkspaceSurfaceConfig['closeDocumentViewer'], + ensureViewerData: shell.preview?.ensureViewerData as WorkspaceSurfaceConfig['ensureViewerData'], + ensureAssetUrl: shell.preview?.ensureAssetUrl as WorkspaceSurfaceConfig['ensureAssetUrl'], + getDocumentAsset: shell.preview?.getDocumentAsset as WorkspaceSurfaceConfig['getDocumentAsset'], + notifyApiError: shell.ui?.notifyApiError as WorkspaceSurfaceConfig['notifyApiError'], + handleBreadcrumbNavigate: shell.folderTree?.handleBreadcrumbNavigate as WorkspaceSurfaceConfig['handleBreadcrumbNavigate'], + }; + + return { + surfaceConfig, + documentsFilter: shell.search?.documentsFilter as DocumentsFilterValue, + documentsManager: shell.managers?.documentsManager, + foldersManager: shell.folderTree?.foldersManager, + }; + }, [shell]); +}; + +export default useDocumentsShell; diff --git a/frontend/src/app/useManagementModals.tsx b/frontend/src/app/useManagementModals.tsx new file mode 100644 index 0000000..54c4d0b --- /dev/null +++ b/frontend/src/app/useManagementModals.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useStatusToast } from '../lib/context/StatusToastContext'; +import TagsPanel from '../tags/TagsPanel'; +import CorrespondentsPanel, { CorrespondentsPanelProps } from '../correspondents/CorrespondentsPanel'; +import PanelHeader from '../components/PanelHeader'; +import { CloseIcon } from '../components/icons'; +import { CORRESPONDENTS_MODAL, TAGS_MODAL } from '../constants/app'; +import type { Tag, Correspondent } from '../types/documents'; +import type CorrespondentManager from '../lib/assets/CorrespondentManager'; +import type { Identifier } from '../types/identifiers'; + +interface UseManagementModalsArgs { + locationPathname?: string; + tags?: Tag[]; + refreshTags?: () => void | Promise<void>; + onTagCreate?: (...args: any[]) => void | Promise<void>; + onTagUpdate?: (...args: any[]) => void | Promise<void>; + onTagDelete?: (...args: any[]) => void | Promise<void>; + correspondents?: Correspondent[]; + correspondentLookupById?: Map<Identifier, Correspondent> | null; + correspondentLookupByName?: Map<string, Correspondent> | null; + refreshCorrespondents?: () => void | Promise<void>; + onCorrespondentCreate?: (...args: any[]) => void | Promise<void>; + onCorrespondentUpdate?: (...args: any[]) => void | Promise<void>; + onCorrespondentDelete?: (...args: any[]) => void | Promise<void>; + correspondentManager?: CorrespondentManager | null; +} + +interface UseManagementModalsResult { + managementModals: ReactNode; + openTagsModal: () => void; + openCorrespondentsModal: () => void; + closeActiveModal: () => void; + activeModal: string | null; +} + +export const useManagementModals = ({ + locationPathname, + tags = [], + refreshTags, + onTagCreate, + onTagUpdate, + onTagDelete, + correspondents = [], + refreshCorrespondents, + onCorrespondentCreate, + onCorrespondentUpdate, + onCorrespondentDelete, +}: UseManagementModalsArgs): UseManagementModalsResult => { + const { showToast } = useStatusToast(); + const [activeModal, setActiveModal] = useState<string | null>(null); + + const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []); + const openCorrespondentsModal = useCallback( + () => setActiveModal(CORRESPONDENTS_MODAL), + [], + ); + const closeActiveModal = useCallback(() => setActiveModal(null), []); + + useEffect(() => { + setActiveModal(null); + }, [locationPathname]); + + useEffect(() => { + if (!activeModal) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setActiveModal(null); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [activeModal]); + + const tagModal = useMemo(() => { + if (activeModal !== TAGS_MODAL) { + return null; + } + return ( + <div + className="modal-backdrop" + role="presentation" + onClick={closeActiveModal} + > + <div + className="modal modal--panel" + role="dialog" + aria-modal="true" + aria-labelledby="tags-modal-title" + onClick={(event) => event.stopPropagation()} + > + <PanelHeader + className="panel-modal__header" + title="Manage Tags" + titleTag="h3" + titleProps={{ id: 'tags-modal-title' }} + actions={( + <button type="button" className="icon-button" onClick={closeActiveModal} aria-label="Close"> + <CloseIcon size={16} /> + </button> + )} + /> + <div className="panel-modal__body"> + <TagsPanel + tags={tags} + onRefresh={refreshTags} + onCreateTag={onTagCreate} + onUpdateTag={onTagUpdate} + onDeleteTag={onTagDelete} + onNotify={showToast} + /> + </div> + </div> + </div> + ); + }, [ + activeModal, + closeActiveModal, + onTagCreate, + onTagDelete, + onTagUpdate, + refreshTags, + showToast, + tags, + ]); + + const handleCorrespondentCreateSafe = useCallback<CorrespondentsPanelProps['onCreate']>( + async (payload) => { + if (!onCorrespondentCreate) { + return undefined; + } + return onCorrespondentCreate(payload) ?? undefined; + }, + [onCorrespondentCreate], + ); + + const handleCorrespondentUpdateSafe = useCallback<CorrespondentsPanelProps['onUpdate']>( + async (id, payload) => { + if (!onCorrespondentUpdate) { + return; + } + await onCorrespondentUpdate(id, payload); + }, + [onCorrespondentUpdate], + ); + + const handleCorrespondentDeleteSafe = useCallback<CorrespondentsPanelProps['onDelete']>( + async (id) => { + if (!onCorrespondentDelete) { + return; + } + await onCorrespondentDelete(id); + }, + [onCorrespondentDelete], + ); + + const correspondentsModal = useMemo(() => { + if (activeModal !== CORRESPONDENTS_MODAL) { + return null; + } + return ( + <div + className="modal-backdrop" + role="presentation" + onClick={closeActiveModal} + > + <div + className="modal modal--panel" + role="dialog" + aria-modal="true" + aria-labelledby="correspondents-modal-title" + onClick={(event) => event.stopPropagation()} + > + <PanelHeader + className="panel-modal__header" + title="Manage Correspondents" + titleTag="h3" + titleProps={{ id: 'correspondents-modal-title' }} + actions={( + <button type="button" className="icon-button" onClick={closeActiveModal} aria-label="Close"> + <CloseIcon size={16} /> + </button> + )} + /> + <div className="panel-modal__body"> + <CorrespondentsPanel + correspondents={correspondents} + onRefresh={refreshCorrespondents} + onCreate={handleCorrespondentCreateSafe} + onUpdate={handleCorrespondentUpdateSafe} + onDelete={handleCorrespondentDeleteSafe} + onNotify={showToast} + /> + </div> + </div> + </div> + ); + }, [ + activeModal, + closeActiveModal, + correspondents, + handleCorrespondentCreateSafe, + handleCorrespondentDeleteSafe, + handleCorrespondentUpdateSafe, + refreshCorrespondents, + showToast, + ]); + + const managementModals = ( + <> + {tagModal} + {correspondentsModal} + </> + ); + + return { + managementModals, + openTagsModal, + openCorrespondentsModal, + closeActiveModal, + activeModal, + }; +}; diff --git a/frontend/src/app/useWorkspaceSelection.ts b/frontend/src/app/useWorkspaceSelection.ts new file mode 100644 index 0000000..0a31e07 --- /dev/null +++ b/frontend/src/app/useWorkspaceSelection.ts @@ -0,0 +1,117 @@ +import { useCallback, useMemo } from 'react'; +import { useDocumentSelection } from './useDocumentSelection'; +import { isDocumentEntry, isFolderEntry, getEntryId } from './entryKey'; + +interface SelectionEntry { + entryKey?: string; + // Legacy field for compatibility + // rowKey?: string; // Removed as part of refactor + [key: string]: unknown; +} + +interface WorkspaceSelectionOptions { + onDocumentActivate?: (id: string) => void; + onInspectFolder?: (id: string) => void; +} + +const identity = <T,>(value: T) => value; + +export const useWorkspaceSelection = ({ + onDocumentActivate = identity, + onInspectFolder = identity, +}: WorkspaceSelectionOptions = {}) => { + const selection = useDocumentSelection(); + + const { + selectedEntries, + setSelectedEntries, + selectionOrder, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + selectionInitializedRef, + focusedDocumentId, + setFocusedDocumentId, + focusedEntryKey, + setFocusedEntryKey, + applySelection, + clearSelection, + handleEntrySelection, + promoteSelectionOrder, + configureSelectionEnvironment, + } = selection; + + const selectedDocumentIds = useMemo( + () => + selectedEntries + .filter((entry) => isDocumentEntry(entry)) + .map((entry) => getEntryId(entry)) + .filter(Boolean), + [selectedEntries], + ); + + const selectedFolderIds = useMemo( + () => + selectedEntries + .filter((entry) => isFolderEntry(entry)) + .map((entry) => getEntryId(entry)) + .filter(Boolean), + [selectedEntries], + ); + + const selectEntry = useCallback( + (entryOrEntries: SelectionEntry | string | Array<SelectionEntry | string>, event?: unknown) => { + const entries = Array.isArray(entryOrEntries) ? entryOrEntries : [entryOrEntries]; + const entryKeys = entries + .map((entry) => { + return entry && Object(entry) === entry + ? (entry as SelectionEntry).entryKey ?? undefined + : (entry as string); + }) + .filter((key): key is string => Boolean(key)); + + if (entryKeys.length === 0) return; + handleEntrySelection(entryKeys, event); + }, + [handleEntrySelection], + ); + + const inspectDocument = useCallback( + (documentId?: string) => { + if (!documentId) return; + onDocumentActivate(documentId); + }, + [onDocumentActivate], + ); + + const inspectFolder = useCallback( + (folderId?: string) => { + if (!folderId) return; + onInspectFolder(folderId); + }, + [onInspectFolder], + ); + + return { + selectedEntries, + selectedDocumentIds, + selectedFolderIds, + selectionOrder, + selectionOrderRef, + selectionAnchorRef, + selectionInitializedRef, + focusedDocumentId, + setFocusedDocumentId, + focusedEntryKey, + setFocusedEntryKey, + applySelection, + clearSelection, + handleEntrySelection: selectEntry, + promoteSelectionOrder, + configureSelectionEnvironment, + setSelectedEntries, + setSelectionOrder, + inspectDocument, + inspectFolder, + }; +}; diff --git a/frontend/src/app/useWorkspaceSurface.tsx b/frontend/src/app/useWorkspaceSurface.tsx new file mode 100644 index 0000000..9030009 --- /dev/null +++ b/frontend/src/app/useWorkspaceSurface.tsx @@ -0,0 +1,238 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; +import { SidebarExpandIcon } from '../components/icons'; +import DocumentsPanel from '../documents/panel/DocumentsPanel'; +import DocumentViewerPanel from '../viewer/DocumentViewerPanel'; +import { usePanelManager } from './PanelManagerContext'; +import { FolderManagerProvider } from '../folders/FolderManagerContext'; +import type { Identifier } from '../types/identifiers'; + +type EnsureAssetUrl = ( + docId: Identifier, + asset: unknown, + options?: Record<string, unknown>, +) => Promise<unknown> | void; +type EnsureViewerData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>; +type GetDocumentAsset = (document: unknown, assetType: string) => unknown; +type NotifyApiError = (error: unknown, fallbackMessage?: string) => void; + +type DetailPanelProps = (ComponentProps<typeof DocumentViewerPanel> & { + onClose?: () => void; + onOpenViewer?: (args: { documentIds: Array<string> }) => void; + folderNodes?: Map<Identifier | 'root', unknown>; + ensureFolderData?: ( + folderId: Identifier | 'root', + options?: { force?: boolean; includeDocuments?: boolean }, + ) => Promise<void>; +}) | null; + +type WorkspaceSurface = { content: ReactNode; detail?: ReactNode | null; detailMode?: 'overlay' | 'inline' | null } | null; + +export interface UseWorkspaceSurfaceArgs { + sidebarHidden?: boolean; + onExpandSidebar?: () => void; + viewMode?: string; + detailPanelProps?: DetailPanelProps; + detailPanelOpen?: boolean; + viewerWorkspaceDocument?: unknown; + viewerDocumentId?: Identifier | null; + ensureAssetUrl?: EnsureAssetUrl; + ensureViewerData?: EnsureViewerData; + getDocumentAsset?: GetDocumentAsset; + notifyApiError?: NotifyApiError; + closeDocumentViewer?: () => void; +} + +interface UseWorkspaceSurfaceResult { + surface: WorkspaceSurface; +} + +export const useWorkspaceSurface = ({ + sidebarHidden = false, + onExpandSidebar, + viewMode, + detailPanelProps, + detailPanelOpen = false, + viewerWorkspaceDocument, + viewerDocumentId, + ensureAssetUrl, + ensureViewerData, + getDocumentAsset, + notifyApiError, + closeDocumentViewer, +}: UseWorkspaceSurfaceArgs): UseWorkspaceSurfaceResult => { + const { registerDetailCloseHandler, setDetailActive } = usePanelManager(); + + useEffect(() => { + const handler = detailPanelProps?.onClose || null; + registerDetailCloseHandler(handler); + return () => registerDetailCloseHandler(null); + }, [registerDetailCloseHandler, detailPanelProps?.onClose]); + + const setDetailActiveRef = useRef(setDetailActive); + useEffect(() => { + setDetailActiveRef.current = setDetailActive; + }, [setDetailActive]); + + useEffect(() => { + setDetailActive(Boolean(detailPanelOpen)); + }, [detailPanelOpen, setDetailActive]); + + useEffect(() => { + return () => { + if (setDetailActiveRef.current) { + setDetailActiveRef.current(false); + } + }; + }, []); + + const renderSidebarToggle = useCallback<() => ReactNode>(() => { + if (!sidebarHidden) { + return null; + } + return ( + <button + type="button" + className="icon-button" + onClick={onExpandSidebar} + aria-label="Expand sidebar" + title="Expand sidebar" + > + <SidebarExpandIcon /> + </button> + ); + }, [sidebarHidden, onExpandSidebar]); + + const documentsSurface = useMemo<WorkspaceSurface>(() => { + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const sidebarMode = viewMode === 'desk' ? 'overlay' : 'inline'; + + const detailMode: 'overlay' | 'inline' | null = detailPanelOpen && detailPanelProps ? sidebarMode : null; + const detail = detailPanelOpen && detailPanelProps + ? (() => { + const { + onClose, + onOpenViewer, + tags: tagOptions, + folderNodes, + ensureFolderData, + ...restDetailProps + } = detailPanelProps; + const viewer = ( + <DocumentViewerPanel + variant="sidebar" + sidebarMode={sidebarMode} + onClose={onClose} + onMaximize={onOpenViewer} + tagOptions={tagOptions} + {...restDetailProps} + /> + ); + if (folderNodes && ensureFolderData) { + return ( + <FolderManagerProvider folderNodes={folderNodes} ensureFolderData={ensureFolderData}> + {viewer} + </FolderManagerProvider> + ); + } + return ( + <>{viewer}</> + ); + })() + : null; + + return { + content: ( + <DocumentsPanel + headerLeading={sidebarToggle} + /> + ), + detail, + detailMode, + }; + }, [ + viewMode, + renderSidebarToggle, + detailPanelOpen, + detailPanelProps, + ]); + + const showViewerWorkspace = Boolean(viewerDocumentId); + + const viewerSurface = useMemo<WorkspaceSurface>(() => { + if (!showViewerWorkspace) { + return null; + } + + const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detailExtras = detailPanelProps || {}; + const { + tagLookupById, + tags: tagOptions, + onTagAdd, + onTagRemove, + correspondents, + correspondentLookupById, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + resolveFolderPath, + folderNodes, + ensureFolderData, + } = detailExtras; + + const viewer = ( + <DocumentViewerPanel + document={viewerWorkspaceDocument || null} + hydrateDocument={ensureViewerData} + tagLookupById={tagLookupById} + tagOptions={tagOptions} + onTagAdd={onTagAdd} + onTagRemove={onTagRemove} + correspondents={correspondents} + correspondentLookupById={correspondentLookupById} + onCorrespondentAdd={onCorrespondentAdd} + onCorrespondentRemove={onCorrespondentRemove} + onUpdateTitle={onUpdateTitle} + onUpdateIssued={onUpdateIssued} + ensureAssetUrl={ensureAssetUrl} + getDocumentAsset={getDocumentAsset} + ensurePreviewData={ensureViewerData} + notifyApiError={notifyApiError} + sidebarToggle={sidebarToggle} + onClose={closeDocumentViewer} + resolveFolderPath={resolveFolderPath} + /> + ); + + const content = folderNodes && ensureFolderData + ? ( + <FolderManagerProvider folderNodes={folderNodes} ensureFolderData={ensureFolderData}> + {viewer} + </FolderManagerProvider> + ) + : viewer; + + return { content, detail: null, detailMode: null }; + }, [ + showViewerWorkspace, + viewerWorkspaceDocument, + ensureViewerData, + ensureAssetUrl, + getDocumentAsset, + notifyApiError, + renderSidebarToggle, + closeDocumentViewer, + detailPanelProps, + ]); + + const surface = useMemo<WorkspaceSurface>(() => { + if (showViewerWorkspace) { + return viewerSurface; + } + return documentsSurface; + }, [showViewerWorkspace, viewerSurface, documentsSurface]); + + return { surface }; +}; diff --git a/frontend/src/app/workspaceUtils.ts b/frontend/src/app/workspaceUtils.ts new file mode 100644 index 0000000..f13494e --- /dev/null +++ b/frontend/src/app/workspaceUtils.ts @@ -0,0 +1,64 @@ +import type { FolderTreeNode } from '../lib/api/apiTypes'; +import { + DEFAULT_FOLDER_NAME, + DEFAULT_SORT_DIRECTION, + DEFAULT_SORT_FIELD, + SORT_FIELD_VALUES, + TAG_FILTER_UNTAGGED, +} from '../constants/workspace'; + +export { + DEFAULT_FOLDER_NAME, + DEFAULT_SORT_DIRECTION, + DEFAULT_SORT_FIELD, + SORT_FIELD_VALUES, + TAG_FILTER_UNTAGGED, +}; + +export const hasFiles = (event) => + Array.from(event.dataTransfer?.types || []).includes('Files'); + +const mergeAssetIntoGroup = (group, assetData) => { + if (!assetData || !assetData.asset_type) { + return group || []; + } + + const list = Array.isArray(group) ? group : []; + const index = list.findIndex((item) => item?.asset_type === assetData.asset_type); + if (index >= 0) { + const next = list.slice(); + next[index] = assetData; + return next; + } + return list.concat(assetData); +}; + +export const mergeAssetIntoDocument = (doc, assetData) => { + if (!doc) return doc; + const nextGroup = mergeAssetIntoGroup(doc.current_version?.assets, assetData); + return { + ...doc, + current_version: { ...(doc.current_version || {}), assets: nextGroup }, + }; +}; + +export const createRootNode = () => ({ + id: 'root', + name: DEFAULT_FOLDER_NAME, + parentId: null, + children: [], + expanded: true, + loaded: false, + hasChildren: false, +}); + +export const flattenFolderTree = (data: FolderTreeNode[]): FolderTreeNode[] => { + const result: FolderTreeNode[] = []; + data.forEach((item) => { + result.push(item); + if (item.children) { + result.push(...flattenFolderTree(item.children)); + } + }); + return result; +}; diff --git a/frontend/src/assets/folder.svg b/frontend/src/assets/folder.svg new file mode 100644 index 0000000..feed8f6 --- /dev/null +++ b/frontend/src/assets/folder.svg @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <linearGradient id="folderGradient" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979"> + <stop offset="0" stop-color="var(--folder-icon-back, #62a0ea)"/> + <stop offset="0.5" stop-color="var(--folder-icon-mid, #afd4ff)"/> + <stop offset="1" stop-color="var(--folder-icon-front, #62a0ea)"/> + </linearGradient> + <path d="m 21.976562 12 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 86.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -72.085938 c 0 -6.628906 -5.359375 -12 -11.972656 -12 h -46.027344 c -2.453125 0 -4.695312 -1.386718 -5.796875 -3.582031 l -1.503906 -2.992187 c -1.65625 -3.292969 -5.019531 -5.371094 -8.699219 -5.371094 z m 0 0" fill="var(--folder-icon-back, #438de6)"/> + <path d="m 65.976562 36 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 54.03125 c 0 5.542969 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.457031 9.980469 -10 v -62.03125 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="url(#folderGradient)"/> + <path d="m 65.976562 32 c -2.746093 0 -5.226562 1.101562 -7.027343 2.890625 c -2.273438 2.253906 -5.382813 5.109375 -8.632813 5.109375 h -28.339844 c -5.527343 0 -9.976562 4.460938 -9.976562 10 v 55.976562 c 0 5.539063 4.449219 10 9.976562 10 h 84.042969 c 5.53125 0 9.980469 -4.460937 9.980469 -10 v -63.976562 c 0 -5.539062 -4.449219 -10 -9.980469 -10 z m 0 0" fill="var(--folder-icon-front, #a4caee)"/> +</svg> diff --git a/frontend/src/assets/logo.webp b/frontend/src/assets/logo.webp new file mode 100644 index 0000000..27b3ae7 Binary files /dev/null and b/frontend/src/assets/logo.webp differ diff --git a/frontend/src/assets/logo_small.webp b/frontend/src/assets/logo_small.webp new file mode 100644 index 0000000..cfeccc6 Binary files /dev/null and b/frontend/src/assets/logo_small.webp differ diff --git a/frontend/src/assets/papercorner.svg b/frontend/src/assets/papercorner.svg new file mode 100644 index 0000000..3fb513b --- /dev/null +++ b/frontend/src/assets/papercorner.svg @@ -0,0 +1,19 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 128 128" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <defs> + <filter id="cornerShadow" x="-20%" y="-20%" width="200%" height="200%"> + <feDropShadow dx="-4" dy="4" stdDeviation="8" flood-opacity="0.24"/> + </filter> + <linearGradient id="cornerGradient" gradientUnits="userSpaceOnUse" x1="32" y1="0" x2="0" y2="64"> + <stop offset="0%" stop-color="#ffffff"/> + <stop offset="100%" stop-color="#f3f3f3"/> + </linearGradient> + </defs> + <g filter="url(#cornerShadow)"> + <g transform="matrix(-1.81626,-1.81626,0.460023,-0.460023,180.663,203.387)"> + <path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="url(#cornerGradient)"/> + </g> + <g transform="matrix(1.81626,1.81626,-0.460023,0.460023,4.6259,-132.676)"> + <path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="#fdfdfd"/> + </g> + </g> +</svg> diff --git a/frontend/src/components/BreadcrumbTrail.tsx b/frontend/src/components/BreadcrumbTrail.tsx new file mode 100644 index 0000000..89f8379 --- /dev/null +++ b/frontend/src/components/BreadcrumbTrail.tsx @@ -0,0 +1,421 @@ +import React, { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import PropTypes from 'prop-types'; +import useFloatingMenu from './useFloatingMenu'; +import { + ELLIPSIS, + WIDTH_BUFFER_RATIO, + WIDTH_CHANGE_TOLERANCE, + WIDTH_TOLERANCE, +} from '../constants/ui'; + +const normalizeEntries = (entries) => + (Array.isArray(entries) ? entries : []) + .map((entry, index) => { + if (!entry) { + return null; + } + const id = entry.id ?? entry.value ?? index; + const label = entry.label ?? entry.name ?? entry.title ?? ''; + const onClick = entry.onClick ? entry.onClick : null; + return label ? { id, label, onClick, raw: entry } : null; + }) + .filter(Boolean); + +const BreadcrumbTrail = ({ + entries = [], + className = '', + separator = '/', + truncateFromStart = true, +}) => { + const normalized = useMemo(() => normalizeEntries(entries), [entries]); + const shouldTruncateFromStart = truncateFromStart !== false; + const measurementEntries = useMemo( + () => (shouldTruncateFromStart ? normalized : normalized.slice().reverse()), + [normalized, shouldTruncateFromStart], + ); + const containerRef = useRef<HTMLDivElement | null>(null); + const measurementRef = useRef<HTMLDivElement | null>(null); + const ellipsisButtonRef = useRef<HTMLButtonElement | null>(null); + const [availableWidth, setAvailableWidth] = useState(null); + const [startIndex, setStartIndex] = useState(0); + const measureRafRef = useRef(null); + + const { + isOpen: ellipsisMenuOpen, + toggle: toggleEllipsisMenu, + close: closeEllipsisMenu, + menuRef: ellipsisMenuRef, + menuStyle: ellipsisMenuStyle, + updatePosition: refreshEllipsisMenuPosition, + } = useFloatingMenu({ + anchorRef: ellipsisButtonRef, + minWidth: 192, + offset: 6, + }); + + useEffect(() => { + closeEllipsisMenu(); + }, [normalized, shouldTruncateFromStart, closeEllipsisMenu]); + + useEffect(() => { + const resolveHost = () => containerRef.current?.parentElement || containerRef.current; + const measure = () => { + const host = resolveHost(); + if (!host) { + return; + } + const nextWidth = host.getBoundingClientRect().width; + if (!nextWidth) { + return; + } + setAvailableWidth((prev) => ( + prev && Math.abs(prev - nextWidth) < WIDTH_CHANGE_TOLERANCE ? prev : nextWidth + )); + }; + + const scheduleMeasure = () => { + const raf = window.requestAnimationFrame; + if (!raf) { + measure(); + return; + } + if (measureRafRef.current) { + cancelAnimationFrame(measureRafRef.current); + } + measureRafRef.current = raf(() => { + measureRafRef.current = null; + measure(); + }); + }; + + scheduleMeasure(); + + if (!('ResizeObserver' in window)) { + return () => { + if (measureRafRef.current) { + cancelAnimationFrame(measureRafRef.current); + measureRafRef.current = null; + } + }; + } + + const host = resolveHost(); + if (!host) { + return () => { + if (measureRafRef.current) { + cancelAnimationFrame(measureRafRef.current); + measureRafRef.current = null; + } + }; + } + + const observer = new ResizeObserver(scheduleMeasure); + observer.observe(host); + + return () => { + observer.disconnect(); + if (measureRafRef.current) { + cancelAnimationFrame(measureRafRef.current); + measureRafRef.current = null; + } + }; + }, []); + + useLayoutEffect(() => { + if (!measurementEntries.length) { + return; + } + + const container = containerRef.current; + const measurement = measurementRef.current; + const host = container?.parentElement || container; + if (!container || !measurement || !host) { + return; + } + + const entryNodes = Array.from( + measurement.querySelectorAll('[data-item-type="entry"]'), + ) as HTMLElement[]; + if (!entryNodes.length) { + return; + } + + const separatorNodes = Array.from( + measurement.querySelectorAll('[data-item-type="separator"]'), + ) as HTMLElement[]; + const ellipsisNode = measurement.querySelector('[data-item-type="ellipsis"]') as HTMLElement | null; + + const originalEntryDisplay = entryNodes.map((node) => node.style.display); + const originalSeparatorDisplay = separatorNodes.map((node) => node.style.display); + const originalEllipsisDisplay = ellipsisNode ? ellipsisNode.style.display : null; + + const widths = []; + + for (let start = 0; start < entryNodes.length; start += 1) { + entryNodes.forEach((node, index) => { + // Hide entries that fall before the visible window. + node.style.display = index < start ? 'none' : ''; + }); + + separatorNodes.forEach((node) => { + const targetIndex = Number(node.getAttribute('data-target-index')); + node.style.display = targetIndex < Math.max(start, 1) ? 'none' : ''; + }); + + if (ellipsisNode) { + ellipsisNode.style.display = start > 0 ? '' : 'none'; + } + + widths[start] = measurement.getBoundingClientRect().width; + } + + entryNodes.forEach((node, index) => { + node.style.display = originalEntryDisplay[index] ?? ''; + }); + + separatorNodes.forEach((node, index) => { + node.style.display = originalSeparatorDisplay[index] ?? ''; + }); + + if (ellipsisNode) { + ellipsisNode.style.display = originalEllipsisDisplay ?? 'none'; + } + + const available = availableWidth ?? host.getBoundingClientRect().width; + if (!available || !widths.length) { + return; + } + + // Reserve a tiny buffer so the live trail doesn't oscillate when the + // container width barely fits; shrink the measured allowance a bit. + const adjustedAvailable = available * WIDTH_BUFFER_RATIO; + + let nextStart = widths.length - 1; + for (let start = 0; start < widths.length; start += 1) { + if (widths[start] <= adjustedAvailable + WIDTH_TOLERANCE) { + nextStart = start; + break; + } + } + + if (nextStart !== startIndex) { + setStartIndex(nextStart); + } + }, [measurementEntries, separator, availableWidth, startIndex]); + + const trimmedCount = Math.min(startIndex, Math.max(0, normalized.length - 1)); + const visibleEntries = shouldTruncateFromStart + ? normalized.slice(trimmedCount) + : normalized.slice(0, Math.max(normalized.length - trimmedCount, 1)); + const hiddenEntries = trimmedCount === 0 + ? [] + : shouldTruncateFromStart + ? normalized.slice(0, trimmedCount) + : normalized.slice(-trimmedCount); + const hasHiddenEntries = hiddenEntries.length > 0; + const ellipsisPlacement = shouldTruncateFromStart ? 'start' : 'end'; + const displayEntries = hasHiddenEntries + ? ellipsisPlacement === 'start' + ? [ELLIPSIS, ...visibleEntries] + : [...visibleEntries, ELLIPSIS] + : visibleEntries; + + useEffect(() => { + if (!hasHiddenEntries) { + closeEllipsisMenu(); + return; + } + if (ellipsisMenuOpen) { + refreshEllipsisMenuPosition(); + } + }, [ + hasHiddenEntries, + ellipsisMenuOpen, + closeEllipsisMenu, + refreshEllipsisMenuPosition, + hiddenEntries.length, + ]); + + if (!normalized.length) { + return null; + } + + const wrapperClassName = className + ? `breadcrumb-trail ${className}`.trim() + : 'breadcrumb-trail'; + + return ( + <> + <span ref={containerRef} className={wrapperClassName}> + {displayEntries.map((entry, index) => { + const isEllipsis = entry.id === ELLIPSIS.id; + const isLast = index === displayEntries.length - 1; + + if (isEllipsis) { + return ( + <React.Fragment key="breadcrumb-ellipsis"> + {index > 0 ? ( + <span className="breadcrumb-trail__separator" aria-hidden="true"> + {separator} + </span> + ) : null} + <span className="breadcrumb-trail__ellipsis"> + <button + ref={ellipsisButtonRef} + type="button" + className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button" + aria-haspopup="menu" + aria-expanded={ellipsisMenuOpen} + onClick={() => { + if (!hasHiddenEntries) { + closeEllipsisMenu(); + return; + } + toggleEllipsisMenu(); + }} + title="Show parent folders" + > + {entry.label} + </button> + </span> + </React.Fragment> + ); + } + + const commonProps = { + className: `breadcrumb-trail__link${isLast ? ' is-current' : ''}`, + title: entry.label, + 'aria-current': isLast ? 'page' : undefined, + }; + + const content = !entry.onClick || isLast + ? ( + <span key={`${entry.id}-label`} {...commonProps}> + {entry.label} + </span> + ) + : ( + <button + key={`${entry.id}-button`} + type="button" + {...commonProps} + onClick={() => entry.onClick?.(entry.raw ?? entry)} + > + {entry.label} + </button> + ); + + return ( + <React.Fragment key={entry.id || index}> + {index > 0 ? ( + <span className="breadcrumb-trail__separator" aria-hidden="true"> + {separator} + </span> + ) : null} + {content} + </React.Fragment> + ); + })} + </span> + <span + ref={measurementRef} + className="breadcrumb-trail breadcrumb-trail--measure" + aria-hidden="true" + > + <button + type="button" + className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button" + data-item-type="ellipsis" + style={{ display: 'none' }} + tabIndex={-1} + > + {ELLIPSIS.label} + </button> + {measurementEntries.map((entry, index) => { + const isLast = index === measurementEntries.length - 1; + const isInteractive = Boolean(entry.onClick) && !isLast; + const MeasurementTag = isInteractive ? 'button' : 'span'; + + return ( + <React.Fragment key={`measure-${entry.id || index}`}> + {index > 0 ? ( + <span + className="breadcrumb-trail__separator" + data-item-type="separator" + data-target-index={index} + > + {separator} + </span> + ) : null} + <MeasurementTag + type={isInteractive ? 'button' : undefined} + className="breadcrumb-trail__link" + data-item-type="entry" + data-entry-index={index} + tabIndex={-1} + > + {entry.label} + </MeasurementTag> + </React.Fragment> + ); + })} + </span> + {ellipsisMenuOpen + && hasHiddenEntries + && ellipsisMenuStyle + ? createPortal( + <div + className="menu menu--floating" + role="menu" + ref={ellipsisMenuRef} + style={ellipsisMenuStyle} + data-floating-position + > + <div className="menu__list"> + {hiddenEntries.map((hiddenEntry) => ( + <button + key={hiddenEntry.id} + type="button" + className="menu__item" + role="menuitem" + onClick={() => { + closeEllipsisMenu(); + hiddenEntry.onClick?.(hiddenEntry.raw ?? hiddenEntry); + }} + disabled={!hiddenEntry.onClick} + > + <span className="menu__label">{hiddenEntry.label}</span> + </button> + ))} + </div> + </div>, + document.body, + ) + : null} + </> + ); +}; + +const breadcrumbEntryShape = PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + name: PropTypes.string, + label: PropTypes.string, + title: PropTypes.string, + onClick: PropTypes.func, +}); + +BreadcrumbTrail.propTypes = { + entries: PropTypes.arrayOf(breadcrumbEntryShape), + className: PropTypes.string, + separator: PropTypes.string, + truncateFromStart: PropTypes.bool, +}; + +export default BreadcrumbTrail; diff --git a/frontend/src/components/PanelHeader.tsx b/frontend/src/components/PanelHeader.tsx new file mode 100644 index 0000000..ebda6a8 --- /dev/null +++ b/frontend/src/components/PanelHeader.tsx @@ -0,0 +1,63 @@ +import React, { ElementType, ReactNode } from 'react'; +import { composeClassName } from './classNames'; + +type TitleProps = Record<string, unknown>; + +interface PanelHeaderProps { + className?: string; + leading?: ReactNode; + title?: ReactNode; + titleTag?: ElementType; + titleProps?: TitleProps; + actions?: ReactNode; +} + +const PanelHeader: React.FC<PanelHeaderProps> = ({ + className = '', + leading = null, + title = null, + titleTag: HeadingTag = 'h2', + titleProps = {}, + actions = null, +}) => { + const headerClassName = composeClassName('panel-header', className); + + const renderTitle = (): ReactNode => { + if (title == null) { + return null; + } + + if (React.isValidElement(title)) { + if (title.type === React.Fragment) { + return ( + <span className="panel-header__title" {...titleProps}> + {title} + </span> + ); + } + + const existingClassName = (title.props as { className?: string })?.className ?? ''; + const mergedClassName = composeClassName('panel-header__title', existingClassName); + return React.cloneElement(title, { + ...titleProps, + className: mergedClassName, + }); + } + + return ( + <HeadingTag className="panel-header__title" {...titleProps}> + {title} + </HeadingTag> + ); + }; + + return ( + <div className={headerClassName}> + {leading ? <div className="panel-header__leading">{leading}</div> : null} + {renderTitle()} + {actions ? <div className="panel-header__actions">{actions}</div> : null} + </div> + ); +}; + +export default PanelHeader; diff --git a/frontend/src/components/QuickAddMenu.tsx b/frontend/src/components/QuickAddMenu.tsx new file mode 100644 index 0000000..a3707fe --- /dev/null +++ b/frontend/src/components/QuickAddMenu.tsx @@ -0,0 +1,277 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, ReactNode, FormEvent, MutableRefObject } from 'react'; +import { PlusIcon } from './icons'; + +import useFloatingMenu from './useFloatingMenu'; + +type QuickAddOption = string | { id?: string; label?: string; name?: string;[key: string]: unknown }; + +interface NormalizedOption { + id?: string; + label: string; + original: QuickAddOption; + index: number; +} + +const normalizeOption = (option: QuickAddOption | null, index: number): NormalizedOption | null => { + if (option == null) { + return null; + } + if (typeof option === 'object') { + const label = option.label ?? option.name; + if (label == null) { + return null; + } + return { + id: option.id ?? label, + label: String(label), + original: option, + index, + }; + } + const label = String(option); + return { + id: label, + label, + original: option, + index, + }; +}; + +interface FloatingMenuState { + isOpen: boolean; + toggle: () => void; + close: () => void; + menuRef: MutableRefObject<HTMLDivElement | null>; + menuStyle: CSSProperties | null; + updatePosition: () => void; +} + +type CSSVarStyle = CSSProperties & Record<string, string>; + +interface QuickAddMenuProps { + onSelectOption?: (value: QuickAddOption, normalized: NormalizedOption) => Promise<void> | void; + onCreate?: (value: string) => Promise<void> | void; + options?: QuickAddOption[]; + placeholder?: string; + createLabel?: string; + emptyMessage?: string; + className?: string; + triggerAriaLabel?: string; + triggerTitle?: string; + renderOption?: (value: QuickAddOption, normalized: NormalizedOption) => ReactNode; + menuMinWidth?: number; + triggerClassName?: string; + triggerContent?: ReactNode; + disabled?: boolean; + align?: 'start' | 'center' | 'end' | (string & {}); + positionStrategy?: 'fixed' | 'absolute' | (string & {}); +} + +const QuickAddMenu = ({ + onSelectOption, + onCreate, + options = [], + placeholder = 'Search or create…', + createLabel = 'Add', + emptyMessage = 'No matches', + className, + triggerAriaLabel = 'Add item', + triggerTitle = 'Add', + renderOption, + menuMinWidth = 220, + triggerClassName = 'icon-button quick-add__trigger', + triggerContent = null, + disabled = false, + align = 'start', + positionStrategy = 'fixed', +}: QuickAddMenuProps) => { + const anchorRef = useRef(null); + const inputRef = useRef(null); + const [query, setQuery] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const { + isOpen, + toggle, + close, + menuRef, + menuStyle, + updatePosition, + } = useFloatingMenu({ + anchorRef, + minWidth: menuMinWidth, + matchAnchorWidth: false, + align, + positionStrategy, + }) as FloatingMenuState; + + useEffect(() => { + if (disabled && isOpen) { + close(); + } + }, [disabled, isOpen, close]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + setQuery(''); + setSubmitting(false); + + const frame = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select?.(); + updatePosition(); + }); + + return () => cancelAnimationFrame(frame); + }, [isOpen, updatePosition]); + + const normalizedOptions = useMemo<NormalizedOption[]>( + () => + options + .map((option, index) => normalizeOption(option, index)) + .filter((option): option is NormalizedOption => Boolean(option)), + [options], + ); + + const filteredOptions = useMemo(() => { + if (!query.trim()) { + return normalizedOptions; + } + const search = query.trim().toLowerCase(); + return normalizedOptions.filter((option) => option.label.toLowerCase().includes(search)); + }, [normalizedOptions, query]); + + const handleSelect = useCallback( + async (option: NormalizedOption) => { + if (!option || !onSelectOption) { + return; + } + setSubmitting(true); + try { + await onSelectOption(option.original ?? option.label, option); + setSubmitting(false); + close(); + } catch (error) { + setSubmitting(false); + console.error('[quick-add] option selection failed', error); + } + }, + [close, onSelectOption], + ); + + const handleCreate = useCallback( + async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + if (!onCreate) { + return; + } + const value = query.trim(); + if (!value) { + return; + } + setSubmitting(true); + try { + await onCreate(value); + setSubmitting(false); + close(); + } catch (error) { + setSubmitting(false); + console.error('[quick-add] creation failed', error); + } + }, + [close, onCreate, query], + ); + + const canCreate = Boolean(onCreate); + const isAnchoredMenu = positionStrategy === 'absolute' && align === 'start'; + const menuClassName = 'menu menu--floating'; + const anchoredMenuStyle = isAnchoredMenu && menuStyle + ? { + top: menuStyle.top, + left: menuStyle.left, + ...(menuStyle.width ? { width: menuStyle.width } : null), + } + : undefined; + const menuInlineStyle = (isAnchoredMenu ? anchoredMenuStyle : menuStyle || undefined) as CSSVarStyle | undefined; + const hasFloatingWidthVar = Boolean(menuInlineStyle && Object.prototype.hasOwnProperty.call(menuInlineStyle, '--floating-min-width')); + const menuStyleWithVar: CSSVarStyle | undefined = hasFloatingWidthVar + ? menuInlineStyle + : { + ...(menuInlineStyle || {}), + '--floating-min-width': `${Math.max(menuMinWidth, 0)}px`, + }; + + return ( + <div className={className ? `quick-add ${className}` : 'quick-add'}> + <button + type="button" + ref={anchorRef} + className={triggerClassName} + aria-haspopup="menu" + aria-expanded={isOpen} + onClick={toggle} + aria-label={triggerAriaLabel} + title={triggerTitle} + disabled={disabled} + > + {triggerContent ?? <PlusIcon />} + </button> + {isOpen ? ( + <div + className={menuClassName} + ref={menuRef} + style={menuStyleWithVar} + role="menu" + data-floating-position + > + {canCreate ? ( + <form className="quick-add__form" onSubmit={handleCreate}> + <input + ref={inputRef} + type="text" + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder={placeholder} + disabled={submitting} + aria-label={placeholder} + /> + <button type="submit" disabled={submitting || !query.trim()}> + {createLabel} + </button> + </form> + ) : null} + <div className="menu__list" role="presentation"> + {filteredOptions.length ? ( + filteredOptions.map((option) => { + const key = option.id ?? option.index; + return ( + <button + key={key} + type="button" + className="menu__item" + role="menuitem" + onClick={() => handleSelect(option)} + disabled={submitting} + > + {renderOption ? ( + renderOption(option.original ?? option.label, option) + ) : ( + <span className="menu__label">{option.label}</span> + )} + </button> + ); + }) + ) : ( + <div className="menu__empty">{emptyMessage}</div> + )} + </div> + </div> + ) : null} + </div> + ); +}; + +export default QuickAddMenu; diff --git a/frontend/src/components/StatusToastOverlay.tsx b/frontend/src/components/StatusToastOverlay.tsx new file mode 100644 index 0000000..cdc9770 --- /dev/null +++ b/frontend/src/components/StatusToastOverlay.tsx @@ -0,0 +1,83 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { useStatusToast } from '../lib/context/StatusToastContext'; +import '../styles/status-toast.css'; + +const FADE_OUT_DURATION = 300; // Match CSS animation duration + +const StatusToastOverlay: React.FC = () => { + const { toasts, removeToast } = useStatusToast(); + const [exitingToasts, setExitingToasts] = useState<Set<string>>(new Set()); + const [displayToasts, setDisplayToasts] = useState(toasts); + const prevToastIdsRef = useRef<Set<string>>(new Set()); + + // Detect when toasts are removed from context and trigger fade + useEffect(() => { + const currentIds = new Set(toasts.map(t => t.id)); + const prevIds = prevToastIdsRef.current; + + // Find toasts that were removed + const removedIds = Array.from(prevIds).filter((id) => typeof id === 'string' && !currentIds.has(id)); + + // Trigger fade for removed toasts + if (removedIds.length > 0) { + setExitingToasts(prev => { + const next = new Set(prev); + removedIds.forEach(id => next.add(id)); + return next; + }); + + // Remove from display after fade + setTimeout(() => { + setDisplayToasts(current => current.filter(t => !removedIds.includes(t.id))); + setExitingToasts(prev => { + const next = new Set(prev); + removedIds.forEach(id => next.delete(id)); + return next; + }); + }, FADE_OUT_DURATION); + } + + // Add new toasts to display + const newToasts = toasts.filter(t => !prevIds.has(t.id)); + if (newToasts.length > 0) { + setDisplayToasts(toasts); + } + + prevToastIdsRef.current = currentIds; + }, [toasts]); + + const handleRemove = useCallback((id: string) => { + removeToast(id); + }, [removeToast]); + + if (displayToasts.length === 0) { + return null; + } + + return ( + <div className="status-toast-container"> + {[...displayToasts].reverse().map((toast) => { + const isExiting = exitingToasts.has(toast.id); + const classNames = [ + 'status-toast-pill', + `status-toast-pill--${toast.variant}`, + isExiting ? 'status-toast-pill--exiting' : '', + ].filter(Boolean).join(' '); + + return ( + <div + key={toast.id} + className={classNames} + onClick={() => handleRemove(toast.id)} + role="status" + aria-live="polite" + > + {toast.message} + </div> + ); + })} + </div> + ); +}; + +export default StatusToastOverlay; diff --git a/frontend/src/components/classNames.ts b/frontend/src/components/classNames.ts new file mode 100644 index 0000000..a95a0cd --- /dev/null +++ b/frontend/src/components/classNames.ts @@ -0,0 +1,2 @@ +export const composeClassName = (base: string, extra?: string | null): string => + extra ? `${base} ${extra}` : base; diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 0000000..2fc6ebc --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import type { JSX } from 'react'; +import type { IconProps as TablerIconProps } from '@tabler/icons-react'; +import { + IconChevronRight as TablerChevronRight, + IconDownload as TablerDownload, + IconZoomInArea as TablerZoomInArea, + IconPencil, + IconTagFilled, + IconUserFilled, + IconTrash, + IconLayoutList, + IconLayoutGrid, + IconArrowLeft, + IconAnalyze, + IconUpload, + IconWindowMaximize, + IconFolderPlus, + IconFolder, + IconFolders, + IconFoldersOff, + IconRefresh, + IconRestore, + IconMinusVertical, + IconLogout, + IconChevronDown, + IconX as TablerIconX, + IconSettings, + IconCheck, + IconPlus, + IconSun, + IconMoon, + IconDeviceLaptop, + IconLayoutSidebarLeftCollapse, + IconLayoutSidebarLeftExpand, + IconLayoutBottombarCollapse, + IconLayoutBottombarExpand, + IconInfoCircle, + IconCircleDashedCheck, + IconFile, + IconLoader, + IconSortAscendingLetters, + IconSortDescendingLetters, + IconFileInfo, + IconAlertTriangle, + IconBrandGithub, + IconBrandMatrix, + IconWorld, +} from '@tabler/icons-react'; +import FolderSvg from '../assets/folder.svg'; +const logoWebp = new URL('../assets/logo.webp', import.meta.url).toString(); +const logoSmallWebp = new URL('../assets/logo_small.webp', import.meta.url).toString(); +import { composeClassName } from './classNames'; + +type TablerIconComponent = (props: TablerIconProps) => JSX.Element; + +// Factory for creating standard icon wrappers with consistent defaults +const createIcon = ( + Icon: TablerIconComponent, + { baseClass = 'icon', defaultStroke = 1.6 }: { baseClass?: string; defaultStroke?: number } = {}, +): TablerIconComponent => { + const WrappedIcon: TablerIconComponent = ({ className, size = '1em', stroke = defaultStroke, ...rest }) => ( + <Icon + className={composeClassName(baseClass, className)} + size={size} + stroke={stroke} + {...rest} + /> + ); + return WrappedIcon; +}; + +// Standard stroke icons (stroke = 1.6) +export const ChevronIcon = createIcon(TablerChevronRight); +export const TrashIcon = createIcon(IconTrash); +export const EditIcon = createIcon(IconPencil); +export const DownloadIcon = createIcon(TablerDownload); +export const IconZoomInArea = createIcon(TablerZoomInArea); +export const GithubIcon = createIcon(IconBrandGithub); +export const MatrixIcon = createIcon(IconBrandMatrix); +export const WorldIcon = createIcon(IconWorld); +export const ViewListIcon = createIcon(IconLayoutList); +export const ViewGridIcon = createIcon(IconLayoutGrid); +export const UploadIcon = createIcon(IconUpload); +export const ArrowLeftIcon = createIcon(IconArrowLeft); +export const SidebarCollapseIcon = createIcon(IconLayoutSidebarLeftCollapse); +export const SidebarExpandIcon = createIcon(IconLayoutSidebarLeftExpand); +export const InfoIcon = createIcon(IconInfoCircle); +export const FileInfoIcon = createIcon(IconFileInfo); +export const BottombarCollapseIcon = createIcon(IconLayoutBottombarCollapse); +export const BottombarExpandIcon = createIcon(IconLayoutBottombarExpand); +export const FolderPlusIcon = createIcon(IconFolderPlus); +export const FoldersIcon = createIcon(IconFolders); +export const FoldersOffIcon = createIcon(IconFoldersOff); +export const RefreshIcon = createIcon(IconRefresh); +export const RestoreIcon = createIcon(IconRestore); +export const MinusVerticalIcon = createIcon(IconMinusVertical); +export const SortAscendingLettersIcon = createIcon(IconSortAscendingLetters); +export const SortDescendingLettersIcon = createIcon(IconSortDescendingLetters); +export const IconX = createIcon(TablerIconX); +export const CloseIcon = createIcon(TablerIconX); +export const SettingsIcon = createIcon(IconSettings); +export const PlusIcon = createIcon(IconPlus); +export const SunIcon = createIcon(IconSun); +export const MoonIcon = createIcon(IconMoon); +export const DesktopIcon = createIcon(IconDeviceLaptop); +export const CheckIcon = createIcon(IconCheck); +export const CircleDashedCheckIcon = createIcon(IconCircleDashedCheck); +export const FileIcon = createIcon(IconFile); +export const FolderOutlineIcon = createIcon(IconFolder); +export const AnalyzeIcon = createIcon(IconAnalyze); +export const WindowMaximizeIcon = createIcon(IconWindowMaximize); +export const LogoutIcon = createIcon(IconLogout); +export const ChevronDownIcon = createIcon(IconChevronDown); + +// Icons with different default stroke +export const LoaderIcon = createIcon(IconLoader, { defaultStroke: 1.8 }); +export const WarningIcon = createIcon(IconAlertTriangle, { defaultStroke: 1.8 }); + +// Filled icons (stroke = 0) +export const TagIcon = createIcon(IconTagFilled, { baseClass: 'icon icon--fill', defaultStroke: 0 }); +export const CorrespondentIcon = createIcon(IconUserFilled, { baseClass: 'icon icon--fill', defaultStroke: 0 }); + +// Custom icons that need special handling +export const FolderIcon: TablerIconComponent = ({ className, size = 16, title, ...rest }) => { + return ( + <FolderSvg + className={composeClassName('folder-icon', className)} + width={size} + height={size} + role={title ? 'img' : 'presentation'} + aria-hidden={title ? undefined : true} + focusable="false" + title={title} + {...rest} + /> + ); +}; + +interface LogoIconProps { + className?: string; + width?: number; + height?: number; + variant?: 'default' | 'small'; +} + +export const LogoIcon: React.FC<LogoIconProps> = ({ className, width = 24, height = 24, variant = 'default' }) => { + const src = variant === 'small' ? logoSmallWebp : logoWebp; + return ( + <img + src={src} + className={composeClassName('logo-icon', className)} + width={width} + height={height} + alt="Papercrate logo" + loading="lazy" + decoding="async" + /> + ); +}; + +export const IconFileStack: TablerIconComponent = ({ className, size = 24, stroke = 160, ...rest }) => ( + <svg + className={composeClassName('icon', className)} + width={size} + height={size} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={stroke} + strokeLinecap="round" + strokeLinejoin="round" + xmlns="http://www.w3.org/2000/svg" + xmlnsXlink="http://www.w3.org/1999/xlink" + {...rest} + > + <path d="M 17.9392 19.2642 c 0.6299 -0.3377 1.0608 -1.0031 1.0608 -1.7642 l 0 -11.4909 l 2.502 0.5318 c 1.0329 0.2195 1.6984 1.2443 1.4788 2.2772 l -2.6346 12.3951 c -0.2196 1.0329 -1.2443 1.6984 -2.2772 1.4789 l -5.1403 -1.0926 l 3.4203 -0.727 c 0.8245 -0.1753 1.4308 -0.8288 1.5902 -1.6083 Z" /> + <path d="M 5 5.173 l 0 -1.673 c 0 -1.1 0.9 -2 2 -2 l 10 0 c 1.1 0 2 0.9 2 2 l 0 14 c 0 0.7611 -0.4309 1.4265 -1.0608 1.7642 c 0.0548 -0.2682 0.0567 -0.5513 -0.0036 -0.835 l -2.8267 -13.2989 c -0.2356 -1.1082 -1.3351 -1.8223 -2.4433 -1.5867 l -7.6656 1.6294 Z" /> + <path d="M 16.349 20.8725 l -10.075 2.1415 c -1.1082 0.2355 -2.2077 -0.4785 -2.4432 -1.5867 l -2.8268 -13.2989 c -0.2356 -1.1083 0.4784 -2.2077 1.5867 -2.4433 l 10.0749 -2.1415 c 1.1082 -0.2356 2.2077 0.4785 2.4433 1.5867 l 2.8267 13.2989 c 0.2356 1.1082 -0.4784 2.2077 -1.5866 2.4433 Z" /> + </svg> +); + +export const FolderMoveIcon: TablerIconComponent = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={size} + height={size} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={stroke} + strokeLinecap="round" + strokeLinejoin="round" + className={composeClassName('icon', className)} + {...rest} + > + <g transform="translate(2, 0)"> + <path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-2m0 -6v-3a2 2 0 0 1 2 -2" /> + </g> + <g transform="translate(-4, 0)"> + <path d="M5 12l11 0"></path> + <path d="M13 16l4 -4"></path> + <path d="M13 8l4 4"></path> + </g> + </svg> +); diff --git a/frontend/src/components/useFloatingMenu.ts b/frontend/src/components/useFloatingMenu.ts new file mode 100644 index 0000000..8322ba3 --- /dev/null +++ b/frontend/src/components/useFloatingMenu.ts @@ -0,0 +1,309 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { MutableRefObject, CSSProperties } from 'react'; +import { clamp } from '../utils/math'; +import { DEFAULT_VIEWPORT_MARGIN } from '../constants/ui'; + +type PositionStrategy = 'fixed' | 'absolute' | (string & {}); + +interface FloatingMenuMetrics { + strategy: PositionStrategy; + top: number; + left: number; + minWidth?: number; + width?: number; +} + +type FloatingMenuStyle = (CSSProperties & { '--floating-min-width'?: string }) | null; + +const resolveViewportWidth = () => window.innerWidth || document.documentElement.clientWidth || 0; + +const computeWidth = (anchorWidth: number, minWidth: number, matchAnchorWidth: boolean) => { + if (matchAnchorWidth) { + return Math.max(anchorWidth, minWidth); + } + return Math.max(minWidth || 0, anchorWidth || 0); +}; + +const formatStyle = (metrics: FloatingMenuMetrics | null): FloatingMenuStyle => { + if (!metrics) { + return null; + } + const style: FloatingMenuStyle = { + position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed', + top: metrics.top, + left: metrics.left, + }; + if (metrics.minWidth != null) { + style['--floating-min-width'] = `${Math.max(metrics.minWidth, 0)}px`; + } + if (metrics.width) { + style.width = metrics.width; + } + return style; +}; + +interface UseFloatingMenuOptions { + anchorRef?: MutableRefObject<HTMLElement | null>; + offset?: number; + minWidth?: number; + matchAnchorWidth?: boolean; + align?: 'start' | 'center' | 'end' | (string & {}); + viewportMargin?: number; + onOpenChange?: (isOpen: boolean) => void; + positionStrategy?: PositionStrategy; +} + +const useFloatingMenu = ({ + anchorRef, + offset = 6, + minWidth = 0, + matchAnchorWidth = false, + align = 'start', + viewportMargin = DEFAULT_VIEWPORT_MARGIN, + onOpenChange, + positionStrategy = 'fixed', +}: UseFloatingMenuOptions = {}) => { + const menuRef = useRef<HTMLDivElement | null>(null); + const [menuMetrics, setMenuMetrics] = useState<FloatingMenuMetrics | null>(null); + const [isOpen, setIsOpen] = useState(false); + const onOpenChangeRef = useRef(onOpenChange); + + useEffect(() => { + onOpenChangeRef.current = onOpenChange; + }, [onOpenChange]); + + const updatePosition = useCallback(() => { + const anchor = anchorRef?.current; + if (!anchor) { + return false; + } + + const rect = anchor.getBoundingClientRect(); + const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth); + const menu = menuRef.current; + const measuredWidth = menu?.offsetWidth ?? desiredWidth; + const widthForAlignment = matchAnchorWidth ? desiredWidth : Math.max(desiredWidth, measuredWidth); + + if (positionStrategy === 'absolute') { + const anchor = anchorRef?.current; + if (!anchor) { + return false; + } + const offsetParent = (menu && menu.offsetParent) || anchor.offsetParent || anchor.parentElement; + if (!offsetParent) { + // Fall back to fixed positioning if we cannot resolve a relative parent. + setMenuMetrics({ + strategy: 'fixed', + top: rect.bottom + offset, + left: rect.left, + minWidth: desiredWidth, + width: matchAnchorWidth ? desiredWidth : undefined, + }); + return true; + } + + let left; + if (align === 'end') { + left = anchor.offsetLeft + anchor.offsetWidth - widthForAlignment; + } else if (align === 'center') { + left = anchor.offsetLeft + anchor.offsetWidth / 2 - widthForAlignment / 2; + } else { + left = anchor.offsetLeft; + } + + const top = anchor.offsetTop + anchor.offsetHeight + offset; + + setMenuMetrics({ + strategy: 'absolute', + top, + left, + minWidth: desiredWidth, + width: matchAnchorWidth ? desiredWidth : undefined, + }); + return true; + } + + const viewportWidth = resolveViewportWidth(); + const viewportHeight = window.innerHeight || 0; + const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN; + const menuHeight = menu?.offsetHeight ?? 0; + + let left; + if (align === 'end') { + left = rect.right - widthForAlignment; + } else if (align === 'center') { + left = rect.left + rect.width / 2 - widthForAlignment / 2; + } else { + left = rect.left; + } + + const maxLeft = viewportWidth > 0 ? viewportWidth - widthForAlignment - safeMargin : left; + const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left; + + let top = rect.bottom + offset; + if (viewportHeight > 0 && menuHeight > 0) { + const projectedBottom = top + menuHeight + safeMargin; + if (projectedBottom > viewportHeight) { + const upwardTop = rect.top - offset - menuHeight; + top = Math.max(upwardTop, safeMargin); + } + } + + setMenuMetrics({ + strategy: 'fixed', + top, + left: clampedLeft, + minWidth: desiredWidth, + width: matchAnchorWidth ? desiredWidth : undefined, + }); + + return true; + }, [ + anchorRef, + align, + matchAnchorWidth, + minWidth, + offset, + positionStrategy, + viewportMargin, + ]); + + const close = useCallback(() => { + setIsOpen((prev) => { + if (!prev) { + return prev; + } + onOpenChangeRef.current?.(false); + return false; + }); + }, []); + + const open = useCallback(() => { + setIsOpen((prev) => { + if (prev) { + return prev; + } + const positioned = updatePosition(); + if (!positioned) { + onOpenChangeRef.current?.(false); + return prev; + } + onOpenChangeRef.current?.(true); + return true; + }); + }, [updatePosition]); + + const toggle = useCallback(() => { + setIsOpen((prev) => { + if (prev) { + onOpenChangeRef.current?.(false); + return false; + } + const positioned = updatePosition(); + if (!positioned) { + onOpenChangeRef.current?.(false); + return prev; + } + onOpenChangeRef.current?.(true); + return true; + }); + }, [updatePosition]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + let ignoreFocusEvents = true; + const raf = window.requestAnimationFrame; + const rafId = raf + ? raf(() => { + ignoreFocusEvents = false; + }) + : null; + + if (!anchorRef?.current) { + close(); + return undefined; + } + + const handlePointer = (event) => { + if (event.type === 'focusin' && ignoreFocusEvents) { + return; + } + const target = event.target; + // If the target is no longer in the document, it means it was unmounted + // (e.g. due to a re-render caused by the open action). + // In this case, we should ignore the event. + if (target instanceof Node && !document.contains(target)) { + return; + } + + const menu = menuRef.current; + const anchor = anchorRef?.current; + + if ((anchor && anchor.contains(target)) || (menu && menu.contains(target))) { + return; + } + close(); + }; + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + close(); + } + }; + + // Delay adding listeners to avoid capturing the event that opened the menu + const timer = setTimeout(() => { + document.addEventListener('mousedown', handlePointer); + document.addEventListener('touchstart', handlePointer, { passive: true }); + document.addEventListener('focusin', handlePointer); + document.addEventListener('keydown', handleKeyDown); + }, 0); + + return () => { + clearTimeout(timer); + if (rafId != null) { + window.cancelAnimationFrame(rafId); + } + document.removeEventListener('mousedown', handlePointer); + document.removeEventListener('touchstart', handlePointer); + document.removeEventListener('focusin', handlePointer); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [anchorRef, close, isOpen]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + const handleRelayout = () => { + const positioned = updatePosition(); + if (!positioned) { + close(); + } + }; + + handleRelayout(); + window.addEventListener('resize', handleRelayout); + window.addEventListener('scroll', handleRelayout, true); + return () => { + window.removeEventListener('resize', handleRelayout); + window.removeEventListener('scroll', handleRelayout, true); + }; + }, [close, isOpen, updatePosition]); + + return { + isOpen, + open, + close, + toggle, + menuRef, + menuStyle: formatStyle(menuMetrics), + updatePosition, + }; +}; + +export default useFloatingMenu; diff --git a/frontend/src/constants/app.ts b/frontend/src/constants/app.ts new file mode 100644 index 0000000..0de6b27 --- /dev/null +++ b/frontend/src/constants/app.ts @@ -0,0 +1,4 @@ +export const ENTRY_KEY_SEPARATOR = ':'; +export const TAGS_MODAL = 'tags'; +export const CORRESPONDENTS_MODAL = 'correspondents'; +export const STORED_TOKEN_KEY = 'papercrate_token'; diff --git a/frontend/src/constants/colors.ts b/frontend/src/constants/colors.ts new file mode 100644 index 0000000..b769eb1 --- /dev/null +++ b/frontend/src/constants/colors.ts @@ -0,0 +1 @@ +export const HEX_COLOR_PATTERN = /^#?([0-9a-fA-F]{6})$/; diff --git a/frontend/src/constants/desktop.ts b/frontend/src/constants/desktop.ts new file mode 100644 index 0000000..3acd06b --- /dev/null +++ b/frontend/src/constants/desktop.ts @@ -0,0 +1,3 @@ +export const DB_NAME = 'papercrate_desk'; +export const DB_VERSION = 1; +export const LAYOUT_STORE = 'layouts'; diff --git a/frontend/src/constants/documents.ts b/frontend/src/constants/documents.ts new file mode 100644 index 0000000..e3616b6 --- /dev/null +++ b/frontend/src/constants/documents.ts @@ -0,0 +1,20 @@ +export const DEFAULT_THUMBNAIL_SIZE = 48; + +export const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'] as const; +export const TAG_TEXT_MIME_TYPE = 'text/plain'; + +export const DEFAULT_GRID_ICON_SIZE = 144; +export const DEFAULT_LIST_ICON_SIZE = 48; +export const DEFAULT_DESKTOP_CARD_SIZE = 300; + +export const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'issued_at', label: 'Issued date' }, + { value: 'created_at', label: 'Added' }, + { value: 'updated_at', label: 'Updated date' }, +] as const; + +export const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce<Record<string, string>>((acc, option) => { + acc[option.value] = option.label; + return acc; +}, {}); diff --git a/frontend/src/constants/layout.ts b/frontend/src/constants/layout.ts new file mode 100644 index 0000000..74a6ace --- /dev/null +++ b/frontend/src/constants/layout.ts @@ -0,0 +1,23 @@ +export const DEFAULT_SIDEBAR_WIDTH = 320; +export const DEFAULT_DETAIL_WIDTH = 420; +export const MINIMAL_FREE_RATIO = 1 / 3; +export const SIDEBAR_SOLO_THRESHOLD = 1 / 2; +export const MINIMUM_MAIN_CONTENT_WIDTH = 160; + +export type PanelKey = 'sidebar' | 'detail'; + +export const PANEL_LIMITS: Record<PanelKey, { maxRatio: number; minPx: number }> = { + sidebar: { + maxRatio: 1 / 3, + minPx: 280, + }, + detail: { + maxRatio: 2 / 3, + minPx: 320, + }, +}; + +export const PANEL_STORAGE_KEYS: Record<PanelKey, string> = { + sidebar: 'papercrate_sidebar_width', + detail: 'papercrate_detail_width', +}; diff --git a/frontend/src/constants/preview.ts b/frontend/src/constants/preview.ts new file mode 100644 index 0000000..d568611 --- /dev/null +++ b/frontend/src/constants/preview.ts @@ -0,0 +1,32 @@ +export const RERENDER_DELTA = 48; +export const MAX_PIXEL_RATIO = 2; +export const FAST_SCROLL_VELOCITY_THRESHOLD = 0.8; +export const FAST_SCROLL_DWELL_THRESHOLD_MS = 120; +export const SCROLL_VELOCITY_MIN_DELTA = 0.05; + +export const AUDIO_EXTENSIONS = new Set([ + 'aac', + 'aiff', + 'flac', + 'm4a', + 'mp3', + 'ogg', + 'oga', + 'opus', + 'wav', + 'weba', +]); + +export const VIDEO_EXTENSIONS = new Set([ + 'avi', + 'mkv', + 'mov', + 'mp4', + 'm4v', + 'webm', + 'wmv', +]); + +export const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4; +export const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style'; +export const MIN_STACKED_BREAKPOINT = 480; diff --git a/frontend/src/constants/settings.ts b/frontend/src/constants/settings.ts new file mode 100644 index 0000000..68d6145 --- /dev/null +++ b/frontend/src/constants/settings.ts @@ -0,0 +1,28 @@ +import type { SettingsSectionConfig } from '../settings/SettingsModal'; +import ApiTokensSection from '../settings/sections/ApiTokensSection'; +import CapabilitySetsSection from '../settings/sections/CapabilitySetsSection'; +import PasskeysSection from '../settings/sections/PasskeysSection'; + +const PASSKEYS_SECTION: SettingsSectionConfig = { + id: 'passkeys', + label: 'Passkeys', + component: PasskeysSection, +}; + +const API_TOKENS_SECTION: SettingsSectionConfig = { + id: 'apiTokens', + label: 'API tokens', + component: ApiTokensSection, +}; + +const CAPABILITY_SETS_SECTION: SettingsSectionConfig = { + id: 'capabilitySets', + label: 'Capability sets', + component: CapabilitySetsSection, +}; + +export const DEFAULT_SETTINGS_SECTIONS = [ + PASSKEYS_SECTION, + API_TOKENS_SECTION, + CAPABILITY_SETS_SECTION, +]; diff --git a/frontend/src/constants/sidebar.ts b/frontend/src/constants/sidebar.ts new file mode 100644 index 0000000..21d2364 --- /dev/null +++ b/frontend/src/constants/sidebar.ts @@ -0,0 +1,12 @@ +export const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed'; +export const THEME_STORAGE_KEY = 'papercrate_theme_settings'; +export const DEFAULT_NEUTRAL_HUE = 29; +export const DEFAULT_NEUTRAL_CHROMA = 0.44; +export const DEFAULT_THEME_MODE = 'system'; +export const THEME_MODES = ['system', 'light', 'dark']; +export const THEME_MODE_LABELS = { + system: 'System default', + light: 'Light', + dark: 'Dark', +}; +export const DARK_MODE_MEDIA_QUERY = '(prefers-color-scheme: dark)'; diff --git a/frontend/src/constants/ui.ts b/frontend/src/constants/ui.ts new file mode 100644 index 0000000..08ffad5 --- /dev/null +++ b/frontend/src/constants/ui.ts @@ -0,0 +1,7 @@ +export const NBSP = String.fromCharCode(160); +export const DEFAULT_VIEWPORT_MARGIN = 8; + +export const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null } as const; +export const WIDTH_TOLERANCE = 1; +export const WIDTH_BUFFER_RATIO = 0.99; +export const WIDTH_CHANGE_TOLERANCE = 0.02; diff --git a/frontend/src/constants/workspace.ts b/frontend/src/constants/workspace.ts new file mode 100644 index 0000000..86f339d --- /dev/null +++ b/frontend/src/constants/workspace.ts @@ -0,0 +1,10 @@ +export const DEFAULT_FOLDER_NAME = 'Documents'; +export const DEFAULT_SORT_FIELD = 'title'; +export const DEFAULT_SORT_DIRECTION = 'asc'; +export const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at']; +export const TAG_FILTER_UNTAGGED = '__UNTAGGED__'; + +export const VIEW_MODE_STORAGE_KEY = 'papercrate_view_mode'; +export const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field'; +export const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction'; +export const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants'; diff --git a/frontend/src/correspondents/CorrespondentsPanel.tsx b/frontend/src/correspondents/CorrespondentsPanel.tsx new file mode 100644 index 0000000..80e7ce6 --- /dev/null +++ b/frontend/src/correspondents/CorrespondentsPanel.tsx @@ -0,0 +1,237 @@ +import { useCallback, useState } from 'react'; +import type { FormEvent, KeyboardEvent } from 'react'; +import type { Correspondent } from '../types/documents'; + +export interface CorrespondentsPanelProps { + correspondents?: Correspondent[]; + onRefresh?: () => void | Promise<void>; + onCreate: (payload: { name: string }) => Promise<Correspondent | void>; + onUpdate: (id: string, payload: { name: string }) => Promise<void>; + onDelete: (id: string) => Promise<void>; + onNotify?: (message: string, variant?: string) => void; +} + +function CorrespondentsPanel({ + correspondents = [], + onRefresh, + onCreate, + onUpdate, + onDelete, + onNotify, +}: CorrespondentsPanelProps) { + const [editingId, setEditingId] = useState<string | null>(null); + const [draftName, setDraftName] = useState(''); + const [createName, setCreateName] = useState(''); + const [saving, setSaving] = useState(false); + const [creating, setCreating] = useState(false); + const [deletingId, setDeletingId] = useState<string | null>(null); + + const startEdit = useCallback((correspondent: Correspondent) => { + setEditingId(correspondent.id); + setDraftName(correspondent.name); + }, []); + + const cancelEdit = useCallback(() => { + setEditingId(null); + setDraftName(''); + setSaving(false); + }, []); + + const handleSave = useCallback(async () => { + if (!editingId) return; + const trimmed = draftName.trim(); + if (!trimmed) { + onNotify?.('Correspondent name cannot be empty.', 'error'); + return; + } + + setSaving(true); + try { + await onUpdate(editingId, { name: trimmed }); + cancelEdit(); + } catch (error) { + onNotify?.('Failed to update correspondent.', 'error'); + console.error('[correspondents] update failed', error); + setSaving(false); + } + }, [editingId, draftName, onUpdate, cancelEdit, onNotify]); + + const handleDelete = useCallback( + async (correspondent: Correspondent) => { + if (!correspondent?.id) return; + setDeletingId(correspondent.id); + try { + await onDelete(correspondent.id); + if (editingId === correspondent.id) { + cancelEdit(); + } + } catch (error) { + onNotify?.('Failed to delete correspondent.', 'error'); + console.error('[correspondents] delete failed', error); + } finally { + setDeletingId(null); + } + }, + [onDelete, editingId, cancelEdit, onNotify], + ); + + const handleCreate = useCallback( + async (event: FormEvent<HTMLFormElement>) => { + event.preventDefault(); + const trimmed = createName.trim(); + if (!trimmed) { + onNotify?.('Correspondent name cannot be empty.', 'error'); + return; + } + setCreating(true); + try { + await onCreate({ name: trimmed }); + setCreateName(''); + } catch (error) { + onNotify?.('Failed to create correspondent.', 'error'); + console.error('[correspondents] create failed', error); + } finally { + setCreating(false); + } + }, + [createName, onCreate, onNotify], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent<HTMLInputElement>) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSave(); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelEdit(); + } + }, + [handleSave, cancelEdit], + ); + + const renderUsage = useCallback((correspondent: Correspondent) => { + return correspondent.usage_count; + }, []); + + return ( + <section className="correspondents-panel"> + <div className="panel-section__header"> + <div className="panel-section__titles"> + <h2>Correspondents</h2> + <div className="panel-section__subtitle">{correspondents.length} total</div> + </div> + <div className="header-actions correspondents-actions"> + <form className="correspondents-actions__form" onSubmit={handleCreate}> + <input + type="text" + placeholder="New correspondent name" + value={createName} + onChange={(event) => setCreateName(event.target.value)} + disabled={creating} + /> + <button type="submit" disabled={creating || !createName.trim()}> + {creating ? 'Creating…' : 'Create'} + </button> + </form> + <button + className="secondary" + type="button" + onClick={onRefresh} + disabled={saving || creating || Boolean(deletingId)} + > + Refresh + </button> + </div> + </div> + <div className="panel-section__body tags-panel__body"> + {correspondents.length === 0 ? ( + <div className="empty-state">No correspondents created yet.</div> + ) : ( + <div className="tags-table"> + <table> + <thead> + <tr> + <th scope="col">Name</th> + <th scope="col" className="numeric"> + Usage + </th> + <th scope="col" className="actions"> + Actions + </th> + </tr> + </thead> + <tbody> + {correspondents.map((correspondent) => { + const isEditing = editingId === correspondent.id; + return ( + <tr key={correspondent.id} className={isEditing ? 'editing' : ''}> + <td className="tags-table__label"> + {isEditing ? ( + <input + className="tags-table__label-input" + value={draftName} + onChange={(event) => setDraftName(event.target.value)} + onKeyDown={handleKeyDown} + disabled={saving} + autoFocus + /> + ) : ( + <span>{correspondent.name}</span> + )} + </td> + <td className="numeric">{renderUsage(correspondent)}</td> + <td className="actions"> + {isEditing ? ( + <div className="tags-table__edit-controls"> + <button + type="button" + className="secondary" + onClick={handleSave} + disabled={saving} + > + Save + </button> + <button + type="button" + className="secondary" + onClick={cancelEdit} + disabled={saving} + > + Cancel + </button> + </div> + ) : ( + <div className="tags-table__row-actions"> + <button + type="button" + className="secondary" + onClick={() => startEdit(correspondent)} + disabled={deletingId === correspondent.id} + > + Edit + </button> + <button + type="button" + className="danger" + onClick={() => handleDelete(correspondent)} + disabled={deletingId === correspondent.id} + > + Delete + </button> + </div> + )} + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + )} + </div> + </section> + ); +} + +export default CorrespondentsPanel; diff --git a/frontend/src/desktop/components/DesktopDocumentCard.tsx b/frontend/src/desktop/components/DesktopDocumentCard.tsx new file mode 100644 index 0000000..ff8d391 --- /dev/null +++ b/frontend/src/desktop/components/DesktopDocumentCard.tsx @@ -0,0 +1,135 @@ +import React, { useMemo } from 'react'; +import DesktopPreviewCard from './DesktopPreviewCard'; +import { resolveCorrespondents } from '../../documents/correspondents'; +import type { Document } from '../../types/documents'; +import { LayoutCard } from '../logic/LayoutSystem'; +import { useCardPointer } from '../interactions/useCardPointer'; +import DocumentTags from '../../documents/components/DocumentTags'; +import { TagInteractionHandlers } from '../../documents/interactions/useTagInteractions'; +import { useDocumentsAssetContext } from '../../documents/context/DocumentsAssetContext'; +import { useDocumentsViewStateContext } from '../../documents/context/DocumentsViewStateContext'; + +const preventAll = (event?: React.SyntheticEvent | Event | null) => { + if (!event) return; + if (typeof event.preventDefault === 'function') event.preventDefault(); + if (typeof event.stopPropagation === 'function') event.stopPropagation(); +}; + +interface DesktopDocumentCardProps { + doc: Document; + style?: React.CSSProperties; + shouldLoad?: boolean; + matchesFilter?: boolean; + selected?: boolean; + docTagTokens?: string; + ensureAssetUrl?: (...args: any[]) => Promise<unknown>; + getDocumentAsset?: (...args: any[]) => unknown; + onDocumentActivate?: (id: string, event?: any) => void; + onSelect: (ids: string[], extend?: boolean) => void; + onDeselect: (ids: string[]) => void; + selection: string[]; + requestCanvasFocus?: () => void; + tagHandlers?: TagInteractionHandlers; + layoutCard: LayoutCard; +} + +const DesktopDocumentCard: React.FC<DesktopDocumentCardProps> = ({ + doc, + style, + shouldLoad = false, + matchesFilter = true, + selected = false, + docTagTokens, + onDocumentActivate, + onSelect, + onDeselect, + selection, + requestCanvasFocus, + tagHandlers, + layoutCard, +}) => { + const { + ensureAssetUrl, + getDocumentAsset + } = useDocumentsAssetContext(); + + const { tagLookupById, correspondentLookupById } = useDocumentsViewStateContext(); + const cardPointerHandlers = useCardPointer( + layoutCard, + !!selected, + selection, + onSelect, + onDeselect, + onDocumentActivate, + requestCanvasFocus + ); + + const correspondents = useMemo(() => resolveCorrespondents(doc, correspondentLookupById), [doc, correspondentLookupById]); + const tags = Array.isArray(doc?.tags) ? doc.tags : []; + + const itemClasses = ['desk-item']; + if (!matchesFilter) itemClasses.push('is-filtered-out'); + if (selected) itemClasses.push('is-selected'); + + const ariaHidden = matchesFilter ? undefined : 'true'; + const dataTagIds = docTagTokens || undefined; + + return ( + <div + key={doc.id} + className={itemClasses.join(' ')} + style={style} + role="button" + data-doc-id={doc.id} + data-tag-ids={dataTagIds} + aria-hidden={ariaHidden} + ref={(node) => layoutCard?.setRef(node)} + {...cardPointerHandlers} + onDragEnter={(event) => tagHandlers?.onTagDragEnter(event, doc.id!)} + onDragOver={(event) => tagHandlers?.onTagDragOver(event, doc)} + onDragLeave={(event) => tagHandlers?.onTagDragLeave(event, doc.id!)} + onDrop={(event) => tagHandlers?.onTagDrop(event, doc)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + preventAll(event); + onDocumentActivate?.(doc.id, event); + } + }} + > + <div className="desk-item__body"> + <DesktopPreviewCard + doc={doc} + title={doc.title} + ensureAssetUrl={ensureAssetUrl} + getDocumentAsset={getDocumentAsset} + shouldLoad={shouldLoad} + /> + {correspondents.length > 0 && ( + <div className="desk-item__correspondents" aria-hidden="true"> + {correspondents.map((correspondent) => ( + <span + key={correspondent.key} + className="badge desk-correspondent-chip" + title={correspondent.name} + > + <span className="desk-correspondent-chip__label">{correspondent.name}</span> + </span> + ))} + </div> + )} + {tags.length > 0 && ( + <div className="desk-item__tags" aria-hidden="true"> + <DocumentTags + doc={doc} + tags={tags} + tagLookupById={tagLookupById} + tagHandlers={tagHandlers} + /> + </div> + )} + </div> + </div> + ); +}; + +export default React.memo(DesktopDocumentCard); diff --git a/frontend/src/desktop/components/DesktopPreviewCard.tsx b/frontend/src/desktop/components/DesktopPreviewCard.tsx new file mode 100644 index 0000000..53b29c8 --- /dev/null +++ b/frontend/src/desktop/components/DesktopPreviewCard.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import type { JSX } from 'react'; +import { resolveDocumentAssetUrl } from '../../lib/assets/AssetManager'; +import type { Identifier } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; + +import type { Asset } from '../../types/assets'; + +type EnsureAssetUrl = ( + documentId: Identifier, + asset: Asset, + options?: { force?: boolean;[key: string]: unknown }, +) => Promise<unknown>; + +type GetDocumentAsset = (document: Document | null, assetType: string) => Asset | null; + +interface DesktopPreviewCardProps { + doc: Document | null; + title?: string; + ensureAssetUrl?: EnsureAssetUrl | null; + getDocumentAsset?: GetDocumentAsset; + shouldLoad?: boolean; +} + +const DesktopPreviewCard = ({ + doc, + title, + ensureAssetUrl, + getDocumentAsset, + shouldLoad = true, +}: DesktopPreviewCardProps): JSX.Element => { + const currentUrl = useMemo(() => { + if (!doc) return null; + return resolveDocumentAssetUrl(doc, 'thumbnail', { + ensureAssetUrl: shouldLoad && ensureAssetUrl ? ensureAssetUrl : undefined, + getAsset: getDocumentAsset, + }); + }, [doc, ensureAssetUrl, getDocumentAsset, shouldLoad]); + + const hasPreview = Boolean(currentUrl); + const cardClasses = ['desk-item__card']; + if (!hasPreview) cardClasses.push('desk-item__card--empty'); + return ( + <div + className={cardClasses.join(' ')} + onDragStart={(event) => { + if (event instanceof DragEvent) { + event.preventDefault(); + } + }} + > + {hasPreview ? ( + <img + src={currentUrl} + alt={title} + draggable={false} + onDragStart={(event) => event.preventDefault()} + /> + ) : ( + <div className="desk-item__empty"> + <div className="desk-item__placeholder">DOC</div> + <div className="desk-item__title" title={title}> + {title} + </div> + </div> + )} + </div> + ); +}; + +export default DesktopPreviewCard; diff --git a/frontend/src/desktop/components/DesktopWorkspace.tsx b/frontend/src/desktop/components/DesktopWorkspace.tsx new file mode 100644 index 0000000..df12538 --- /dev/null +++ b/frontend/src/desktop/components/DesktopWorkspace.tsx @@ -0,0 +1,456 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { LayoutStore, LayoutCard } from '../logic/LayoutSystem'; +import DesktopDocumentCard from './DesktopDocumentCard'; +import usePreviewMetadata from '../hooks/usePreviewMetadata'; +import { + TagInteractionHandlers, +} from '../../documents/interactions/useTagInteractions'; +import './workspace-layout.css'; +import './workspace-items.css'; +import './workspace-cards.css'; +import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; +import { createDocumentEntryKey } from '../../app/entryKey'; +import { PointerTrackingProvider, usePointerTracking } from '../interactions/PointerTrackingContext'; +import type { Identifier } from '../../types/identifiers'; +import type { DocumentsListEntry, Document } from '../../types/documents'; +import { useAppState } from '../../lib/store/appState'; +import { useDocumentOpen } from '../../lib/context/DocumentOpenContext'; +import { useDocumentsAssetContext } from '../../documents/context/DocumentsAssetContext'; +import { useDocumentsViewStateContext } from '../../documents/context/DocumentsViewStateContext'; + +interface DocumentSizeInfo { + width: number; + height: number; + source?: 'snapshot' | 'metadata' | 'fallback'; +} + +// Fallback size computation +const computeFallbackCardSize = (_doc: Document, defaultSize: number = 200): DocumentSizeInfo => { + const size = Math.round(defaultSize * (1 / Math.SQRT2)); + return { width: size, height: size, source: 'fallback' }; +}; + +interface DesktopWorkspaceProps { + entries: DocumentsListEntry[]; + onSelectionChange?: (selectedIds: Identifier[]) => void; + viewId?: string | null; + defaultCardSize?: number; + tagHandlers?: TagInteractionHandlers; +} + +const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({ + entries, + onSelectionChange, + viewId, + defaultCardSize = 200, + tagHandlers, +}) => { + const { openDocument } = useDocumentOpen(); + const { + ensureAssetUrl, + getDocumentAsset + } = useDocumentsAssetContext(); + + const { tenant } = useAppState(); + const tenantId = tenant?.id as Identifier; + const { addPointer, removePointer } = usePointerTracking(); + const containerRef = useRef<HTMLDivElement>(null); + const [isLayoutReady, setIsLayoutReady] = useState(false); + const [isLayoutLoaded, setIsLayoutLoaded] = useState(false); + const [hasContainerSize, setHasContainerSize] = useState(false); + + const items = useMemo(() => { + return entries + .filter((entry): entry is { type: 'document'; document: Document } & DocumentsListEntry => + entry.type === 'document' && !!entry.document + ) + .map(entry => entry.document); + }, [entries]); + + // Layout System Initialization + const layoutStore = useMemo(() => new LayoutStore(), []); + const layoutRef = useRef<Map<string, LayoutCard>>(new Map()); + + // Update container size in store + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + layoutStore.setContainerSize(width, height); + if (width > 0 && height > 0) { + setHasContainerSize(true); + } + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [layoutStore]); + + useEffect(() => { + if (tenantId && viewId) { + // Clear store when switching views to prevent stale items + layoutStore.clear(); + setIsLayoutLoaded(false); + setIsLayoutReady(false); // Immediately hide cards during transition + layoutStore.loadLayout(String(tenantId), viewId).then(() => { + setIsLayoutLoaded(true); + }); + } else { + // No tenant/view ID means no saved layout to load - skip directly to loaded + setIsLayoutLoaded(true); + } + }, [layoutStore, tenantId, viewId]); + + const metadataMap = usePreviewMetadata(items, getDocumentAsset, ensureAssetUrl); + + const ensureDocumentSize = useCallback((doc: Document): DocumentSizeInfo => { + if (doc.id) { + const meta = metadataMap.get(String(doc.id)); + if (meta && meta.width && meta.height) + return { width: meta.width, height: meta.height, source: 'metadata' }; + } + + return computeFallbackCardSize(doc, defaultCardSize); + }, [metadataMap, defaultCardSize]); + + // Synchronize LayoutStore with current items (Initialization & Cleanup) + useEffect(() => { + if (!hasContainerSize || !isLayoutLoaded) return; + + // Cleanup Stale Items + const currentIds = new Set(items.map((doc, index) => doc.id ? String(doc.id) : `temp-${index}`)); + for (const id of layoutStore.items.keys()) { + if (!currentIds.has(id)) { + layoutStore.unregister(id); + } + } + + // Initialize / Update Items (Saved first, then others) + const itemsWithSavedLayout: Document[] = []; + const itemsWithoutSavedLayout: Document[] = []; + + items.forEach(doc => { + // If already initialized in store, we don't strictly need to prioritize it for collision, + // but keeping the order ensures consistent behavior on re-runs. + // However, usually we only care about *new* items for collision logic. + if (doc.id && layoutStore.hasSavedLayout(String(doc.id))) { + itemsWithSavedLayout.push(doc); + } else { + itemsWithoutSavedLayout.push(doc); + } + }); + + const initializeDoc = (doc: Document) => { + const size = ensureDocumentSize(doc); + const metadata = doc.current_version?.metadata as { page_count?: number } | undefined; + const pageCount = metadata?.page_count ?? 1; + + layoutStore.initialize(doc.id, null, { + width: size.width, + height: size.height, + pageCount, + maxSize: defaultCardSize + }); + }; + + itemsWithSavedLayout.forEach(initializeDoc); + itemsWithoutSavedLayout.forEach(initializeDoc); + + // Save newly placed items + if (itemsWithoutSavedLayout.length > 0) { + void layoutStore.saveLayout(); + } + + // Enforce constraints + layoutStore.relayout(); + + setIsLayoutReady(true); + }, [hasContainerSize, isLayoutLoaded, items, layoutStore, ensureDocumentSize, defaultCardSize]); + + // Selection Context + const { + selectedDocumentIds, + setSelectedEntries, + clearSelection, + } = useWorkspaceSelectionContext(); + + const handleSelectionChange = useCallback((ids: Identifier[]) => { + // Sort IDs by Z-index (ascending) so the last item is the top-most + const sortedIds = [...ids].sort((a, b) => { + const cardA = layoutStore.items.get(String(a)); + const cardB = layoutStore.items.get(String(b)); + const zA = cardA ? cardA.z : -Infinity; + const zB = cardB ? cardB.z : -Infinity; + return zA - zB; + }); + + if (setSelectedEntries) { + const keys = sortedIds.map(id => createDocumentEntryKey(id)); + setSelectedEntries(keys); + } + onSelectionChange?.(sortedIds); + }, [setSelectedEntries, onSelectionChange, layoutStore]); + + const onClearSelection = useCallback(() => { + clearSelection ? clearSelection() : handleSelectionChange([]); + }, [clearSelection, handleSelectionChange]); + + // Sync LayoutStore to layoutRef + useEffect(() => { + const sync = () => { + layoutRef.current = layoutStore.items; + }; + sync(); + }, [layoutStore.items]); + + const handleShellKeyDown = useCallback(() => { }, []); + const focusShell = useCallback(() => { + if (containerRef.current) { + containerRef.current.focus(); + } + }, []); + + const { scrollRef } = useDocumentsViewStateContext(); + + useEffect(() => { + const handleWindowKeyDown = (e: KeyboardEvent) => { + // Handle events if the container or the shared scrollRef is focused + // This allows unified handlers (which focus scrollRef) to work seamlessly with Desktop shortcuts + const isTargetContainer = e.target === containerRef.current; + const isTargetScrollRef = scrollRef && e.target === scrollRef.current; + + if (!isTargetContainer && !isTargetScrollRef) { + return; + } + + // Space preview logic + if ((e.code === 'Space' || e.code === 'Enter') && selectedDocumentIds.length > 0) { + const lastId = selectedDocumentIds[selectedDocumentIds.length - 1]; + const doc = items.find(i => String(i.id) === lastId); + if (doc) { + e.preventDefault(); + const target = e.code === 'Enter' ? 'inspect' : 'preview'; + openDocument(doc, target); + return; + } + } + + // Navigation logic + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + e.preventDefault(); + + const layoutItems = Array.from(layoutStore.items.values()) as LayoutCard[]; + if (layoutItems.length === 0) return; + + let activeCard = null; + if (selectedDocumentIds.length > 0) { + // Use the last selected item as the anchor + const lastId = selectedDocumentIds[selectedDocumentIds.length - 1]; + activeCard = layoutStore.items.get(lastId); + } + + // If no selection or active card not found, select the top-most item + if (!activeCard) { + const topMost = layoutItems.reduce((prev, current) => (prev.z > current.z ? prev : current)); + handleSelectionChange([topMost.id]); + return; + } + + const cx = activeCard.centerX; + const cy = activeCard.centerY; + + let bestCandidate = null; + let minScore = Infinity; + + for (const candidate of layoutItems) { + if (candidate.id === activeCard.id) continue; + + const dx = candidate.centerX - cx; + const dy = candidate.centerY - cy; + + let valid = false; + let primaryDist = 0; + let offAxisDist = 0; + + switch (e.key) { + case 'ArrowRight': + if (dx > 0 && dx > Math.abs(dy)) { + valid = true; + primaryDist = dx; + offAxisDist = Math.abs(dy); + } + break; + case 'ArrowLeft': + if (dx < 0 && -dx > Math.abs(dy)) { + valid = true; + primaryDist = -dx; + offAxisDist = Math.abs(dy); + } + break; + case 'ArrowDown': + if (dy > 0 && dy > Math.abs(dx)) { + valid = true; + primaryDist = dy; + offAxisDist = Math.abs(dx); + } + break; + case 'ArrowUp': + if (dy < 0 && -dy > Math.abs(dx)) { + valid = true; + primaryDist = -dy; + offAxisDist = Math.abs(dx); + } + break; + } + + if (valid) { + // Weighted score: favor items closer in the primary direction, penalize off-axis + // We use a multiplier for off-axis distance to prefer "straighter" lines + // Reduced off-axis weight to favor directional distance (grid-like behavior) + let score = primaryDist + (offAxisDist * 0.2); + + // Z-Order Bonus: Subtract a small value based on Z-index to favor higher items + // Assuming max Z is around 10000, 0.1 gives a max bonus of 1000, which is significant but less than primary distance usually + score -= (candidate.z * 0.05); + + // Obstruction Penalty: Check if the candidate is obstructed + // If less than 5% is visible, treat as obstructed + if (candidate.getVisibleFraction() < 0.05) { + score += 5000; // Huge penalty for obstructed items + } + + if (score < minScore) { + minScore = score; + bestCandidate = candidate; + } + } + } + + if (bestCandidate) { + if (e.shiftKey) { + // Additive selection + const newSelection = new Set(selectedDocumentIds); + newSelection.add(bestCandidate.id); + handleSelectionChange(Array.from(newSelection)); + } else { + // Replace selection + handleSelectionChange([bestCandidate.id]); + } + } + } + }; + + window.addEventListener('keydown', handleWindowKeyDown); + return () => window.removeEventListener('keydown', handleWindowKeyDown); + }, [selectedDocumentIds, items, openDocument, layoutStore, handleSelectionChange, scrollRef]); + + return ( + <> + <div + className="desk-shell" + > + <div + className="desk-canvas" + ref={containerRef} + tabIndex={0} + onKeyDown={handleShellKeyDown} + onPointerDown={(e) => { + if (e.target === e.currentTarget) { + // Register background pointer + addPointer(e.pointerId); + (e.target as Element).setPointerCapture(e.pointerId); + + onClearSelection(); + focusShell(); + } + }} + onPointerUp={(e) => { + if (e.target === e.currentTarget) { + removePointer(e.pointerId); + (e.target as Element).releasePointerCapture(e.pointerId); + } + }} + onPointerCancel={(e) => { + if (e.target === e.currentTarget) { + removePointer(e.pointerId); + (e.target as Element).releasePointerCapture(e.pointerId); + } + }} + > + {isLayoutReady && items.map((doc, index) => { + const docId = doc.id ? String(doc.id) : `temp-${index}`; + const isSelected = selectedDocumentIds.includes(docId); + + const layoutCard = layoutStore.items.get(docId); + + if (!layoutCard) { + return null; + } + + return ( + <DesktopDocumentCard + key={docId} + doc={doc} + style={{ + position: 'absolute', + top: 0, + left: 0, + touchAction: 'none', + willChange: 'transform' + }} + shouldLoad={true} + matchesFilter={true} + + selected={isSelected} + docTagTokens="" + ensureAssetUrl={ensureAssetUrl} + getDocumentAsset={getDocumentAsset} + onDocumentActivate={(_id, event) => { + const isPreview = event && ((event as any).altKey || (event as any).button === 1); + openDocument(doc, isPreview ? 'preview' : 'inspect'); + }} + layoutCard={layoutCard} + tagHandlers={tagHandlers} + onSelect={(ids, extend = false) => { + if (!extend) { + handleSelectionChange(ids); + } else { + const newSelection = new Set(selectedDocumentIds); + ids.forEach(id => newSelection.add(id)); + handleSelectionChange(Array.from(newSelection)); + } + }} + onDeselect={(ids) => { + const newSelection = new Set(selectedDocumentIds); + ids.forEach(id => newSelection.delete(id)); + handleSelectionChange(Array.from(newSelection)); + }} + selection={selectedDocumentIds} + requestCanvasFocus={focusShell} + /> + ); + })} + </div> + </div> + </> + ); +}; + +const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = (props) => { + return ( + <PointerTrackingProvider> + <DesktopWorkspaceContent {...props} /> + </PointerTrackingProvider> + ); +}; + +export default DesktopWorkspace; diff --git a/frontend/src/desktop/components/workspace-cards.css b/frontend/src/desktop/components/workspace-cards.css new file mode 100644 index 0000000..d1575c5 --- /dev/null +++ b/frontend/src/desktop/components/workspace-cards.css @@ -0,0 +1,74 @@ +/* Workspace cards, thumbnails, and hover controls */ +.desk-item__shadow { + display: none; +} + +.desk-item__card { + position: relative; + border-radius: 0; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-shadow: 0 12px 32px var(--shadow-medium); + overflow: hidden; +} + +.desk-item--swoop { + transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.desk-item__card img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.desk-item__card--empty { + box-shadow: 0 12px 32px var(--shadow-medium); + background: + radial-gradient(circle at 42% 38%, color-mix(in oklch, var(--surface-subtle) 75%, var(--selection) 25%), color-mix(in oklch, var(--surface-subtle) 85%, var(--selection) 15%) 70%), + linear-gradient(135deg, color-mix(in oklch, var(--surface-subtle) 88%, var(--selection-soft) 12%) 0%, color-mix(in oklch, var(--surface-subtle) 65%, var(--shadow-faint) 35%) 100%); + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.desk-item__placeholder { + font-size: 0.95rem; + font-weight: 500; + letter-spacing: normal; + color: var(--muted); +} + +.desk-item__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + gap: 0.5rem; + padding: 1rem; + text-align: center; +} + +.desk-item__title { + font-size: 0.95rem; + font-weight: 500; + color: var(--fg); + max-width: 90%; + overflow: hidden; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; +} \ No newline at end of file diff --git a/frontend/src/desktop/components/workspace-items.css b/frontend/src/desktop/components/workspace-items.css new file mode 100644 index 0000000..70337de --- /dev/null +++ b/frontend/src/desktop/components/workspace-items.css @@ -0,0 +1,130 @@ +/* Workspace items, states, and inline badges */ +.desk-item { + position: absolute; + display: block; + width: auto; + cursor: grab; + touch-action: none; + transform-origin: center center; + transition: + opacity 0.55s cubic-bezier(0.4, 0, 0.2, 1), + filter 0.55s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.16s ease; + outline: none; + will-change: transform; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + filter: blur(0px) grayscale(0%); + opacity: 1; +} + +.desk-item__body { + flex-grow: 1; + width: 100%; + height: 100%; +} + +.desk-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; +} + +.desk-item.is-tag-target .desk-item__card { + outline: 0.35rem dashed var(--accent); + outline-offset: 0.35rem; + cursor: copy; +} + +.desk-item.is-tag-pending .desk-item__card { + outline: 0.25rem solid var(--accent-outline); + outline-offset: 0.25rem; +} + +.desk-item.is-filtered-out { + opacity: 0; + pointer-events: none; + filter: blur(18px) grayscale(100%); + z-index: 0; +} + +.desk-item.is-selected { + z-index: 5; +} + +.desk-item.is-selected .desk-item__card { + box-shadow: + 0 0 0 0.18rem color-mix(in oklch, var(--accent) 45%, transparent), + 0 0 0.35rem 0 color-mix(in oklch, var(--accent) 28%, transparent), + 0 12px 28px -14px color-mix(in oklch, var(--accent) 20%, transparent), + 0 10px 24px var(--shadow-medium); +} + +.desk-item.is-selected .desk-item__title { + color: var(--accent); +} + +.desk-item__tags { + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: flex-end; + transform-origin: top right; + transform: translate(-0.5em, 0.5em); + transition: transform 0.28s ease; +} + +.desk-item__correspondents { + position: absolute; + bottom: 0; + left: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: flex-start; + transform-origin: bottom left; + transform: translate(0.5em, -0.5em); + pointer-events: none; +} + +.desk-correspondent-chip { + pointer-events: none; + font-size: 0.82rem; + padding: 0.18rem 0.55rem; + max-width: min(16rem, 80%); + display: inline-flex; + align-items: center; + overflow: hidden; + background: color-mix(in oklch, var(--surface-subtle) 90%, transparent); + color: var(--muted); +} + +.desk-correspondent-chip__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tag-chip--draggable { + user-select: none; + pointer-events: auto; + transition: transform 0.16s ease, opacity 0.2s ease, box-shadow 0.2s ease; +} + +.tag-chip--draggable.is-drag-hidden { + opacity: 0.4; +} + +.desk-item__tags .tag-chip { + font-size: 0.85rem; + padding: 0.18rem 0.55rem; + gap: 0.3rem; +} + +.desk-item__tags .tag-chip--tear-pending { + opacity: 0.35; +} \ No newline at end of file diff --git a/frontend/src/desktop/components/workspace-layout.css b/frontend/src/desktop/components/workspace-layout.css new file mode 100644 index 0000000..4fcfd23 --- /dev/null +++ b/frontend/src/desktop/components/workspace-layout.css @@ -0,0 +1,34 @@ +/* Workspace layout & canvas scaffolding */ +.desk-main { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.desk-shell { + flex: 1; + display: flex; + flex-direction: column; + grid-column: 2 / -1; + min-height: 0; + position: relative; +} + +.desk-canvas { + flex: 1; + position: relative; + overflow: hidden; + margin: 0; + outline: none; +} + +.desk-canvas:focus, +.desk-canvas:focus-visible { + outline: none; +} + +body.desk-cursor-remove, +body.desk-cursor-remove * { + cursor: not-allowed !important; +} \ No newline at end of file diff --git a/frontend/src/desktop/hooks/usePreviewMetadata.ts b/frontend/src/desktop/hooks/usePreviewMetadata.ts new file mode 100644 index 0000000..4dd8ff2 --- /dev/null +++ b/frontend/src/desktop/hooks/usePreviewMetadata.ts @@ -0,0 +1,108 @@ +import { useEffect, useState, useRef } from 'react'; +import type { DocumentId } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; +import type { Asset, ThumbnailMetadata } from '../../types/assets'; + +interface PreviewMetadataEntry { + docId: DocumentId; + width: number; + height: number; +} + +type GetDocumentAsset = (doc: Document, type: string) => Asset | null; +type EnsureAssetUrl = (docId: DocumentId, asset: Asset, options?: { force?: boolean }) => Promise<Asset | null>; + +const usePreviewMetadata = ( + documents: Document[] | null, + getDocumentAsset?: GetDocumentAsset, + ensureAssetUrl?: EnsureAssetUrl, +) => { + const [metadataMap, setMetadataMap] = useState<Map<string, PreviewMetadataEntry>>(() => new Map()); + const failedIds = useRef(new Set<string>()); // Track failed fetches to prevent loops + + useEffect(() => { + let cancelled = false; + const docs = Array.isArray(documents) ? documents : []; + if (!docs.length) { + setMetadataMap(new Map()); + return () => { + cancelled = true; + }; + } + + const fetchMetadataForDoc = async (doc: Document) => { + if (!doc?.id) { + return null; + } + + const docId = String(doc.id); + const resolveAsset = (type: string) => getDocumentAsset?.(doc, type) ?? null; + + let asset = resolveAsset('thumbnail'); + let metadata: Partial<ThumbnailMetadata> | null = (asset?.metadata as Partial<ThumbnailMetadata> | null) || null; + + const hasDimensions = (meta: Partial<ThumbnailMetadata> | null): meta is ThumbnailMetadata => + typeof meta?.width === 'number' && + typeof meta?.height === 'number' && + meta.width > 0 && + meta.height > 0; + + if (!hasDimensions(metadata) && ensureAssetUrl && docId && asset?.id) { + // Skip if we already failed for this doc to avoid infinite loops + if (!failedIds.current.has(docId)) { + try { + const ensured = await ensureAssetUrl(doc.id, asset); + if (ensured) { + asset = ensured; + metadata = (asset?.metadata as Partial<ThumbnailMetadata> | null) || null; + } + + // If still no dimensions, mark as failed so we don't try again + if (!hasDimensions(metadata)) { + failedIds.current.add(docId); + } + } catch (error) { + console.warn('[desk] ensureDocumentSize metadata fetch failed', error); + failedIds.current.add(docId); + } + } + } + + if (!hasDimensions(metadata)) { + return null; + } + + return { + docId, + width: Number(metadata.width), + height: Number(metadata.height), + }; + }; + + let mounted = true; + (async () => { + const entries = await Promise.all(docs.map(fetchMetadataForDoc)); + if (!mounted || cancelled) { + return; + } + const next = new Map(); + entries.forEach((entry) => { + if (entry && entry.docId) { + next.set(entry.docId, entry); + } + }); + if (!cancelled) { + setMetadataMap(next); + } + })(); + + return () => { + cancelled = true; + mounted = false; + }; + }, [documents, getDocumentAsset, ensureAssetUrl]); + + return metadataMap; +}; + +export default usePreviewMetadata; diff --git a/frontend/src/desktop/interactions/PointerTrackingContext.tsx b/frontend/src/desktop/interactions/PointerTrackingContext.tsx new file mode 100644 index 0000000..31f9a87 --- /dev/null +++ b/frontend/src/desktop/interactions/PointerTrackingContext.tsx @@ -0,0 +1,30 @@ +import React, { useRef, useCallback } from 'react'; +import { createSafeContext } from '../../utils/createSafeContext'; + +interface PointerTrackingContextType { + activePointersRef: React.MutableRefObject<Map<number, string | undefined>>; + addPointer: (id: number, cardId?: string) => void; + removePointer: (id: number) => void; +} + +const [PointerTrackingContext, usePointerTracking] = createSafeContext<PointerTrackingContextType>('PointerTracking'); + +export const PointerTrackingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const activePointersRef = useRef(new Map<number, string | undefined>()); + + const addPointer = useCallback((id: number, cardId?: string) => { + activePointersRef.current.set(id, cardId); + }, []); + + const removePointer = useCallback((id: number) => { + activePointersRef.current.delete(id); + }, []); + + return React.createElement( + PointerTrackingContext.Provider, + { value: { activePointersRef, addPointer, removePointer } }, + children + ); +}; + +export { usePointerTracking }; diff --git a/frontend/src/desktop/interactions/useCardPointer.ts b/frontend/src/desktop/interactions/useCardPointer.ts new file mode 100644 index 0000000..c281580 --- /dev/null +++ b/frontend/src/desktop/interactions/useCardPointer.ts @@ -0,0 +1,277 @@ +import React, { useCallback, useRef } from 'react'; +import { usePointerTracking } from './PointerTrackingContext'; + +import { LayoutCard } from '../logic/LayoutSystem'; +import { handleDragMove, handleDragEnd, handleDragStart, attachToDragGroup } from '../logic/CardDragLogic'; + +const DRAG_THRESHOLD = 3; + +type PointerState = 'idle' | 'click' | 'drag'; + +export const useCardPointer = ( + card: LayoutCard, + isSelected: boolean, + selection: string[], + onSelect: (ids: string[], extend?: boolean) => void, + onDeselect: (ids: string[]) => void, + onDocumentActivate?: (id: string, event?: React.PointerEvent) => void, + requestCanvasFocus?: () => void +) => { + const [state, setState] = React.useState<PointerState>('idle'); + const initialPosition = useRef<{ x: number, y: number } | null>(null); + const lastPosition = useRef<{ x: number, y: number } | null>(null); + const lastClickTime = useRef<number>(0); + const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + + const { activePointersRef, addPointer, removePointer } = usePointerTracking(); + + const updateState = useCallback((e: React.PointerEvent) => { + if (state === 'click' && initialPosition.current) { + const dx = e.clientX - initialPosition.current.x; + const dy = e.clientY - initialPosition.current.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > DRAG_THRESHOLD) { + setState('drag-start'); + } + } + }, [state]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + // Allow left (0) and middle (1) click + if (e.button !== 0 && e.button !== 1) return; + + // Ignore interactions on interactive child elements (tags, inputs, buttons, etc.) + // We want these elements to handle their own pointer/drag events. + const target = e.target as Element; + const interactive = target.closest('button, a, input, textarea, select, [draggable="true"]'); + if (interactive && interactive !== e.currentTarget) { + return; + } + + e.preventDefault(); + + (e.target as Element).setPointerCapture(e.pointerId); + + // Register pointer with card ID + addPointer(e.pointerId, card.id); + + requestCanvasFocus?.(); + + setState('click'); + initialPosition.current = { x: e.clientX, y: e.clientY }; + lastPosition.current = { x: e.clientX, y: e.clientY }; + + // Long press detection for touch devices + if (e.pointerType === 'touch') { + longPressTimer.current = setTimeout(() => { + // Select stack + const stackIds = card.store.getStackBelow(card); + const idsToAdd = new Set<string>(); + + if (!isSelected) idsToAdd.add(card.id); + stackIds.forEach(id => idsToAdd.add(id)); + + // Only select what isn't already selected + const unselectedIdsToAdd = Array.from(idsToAdd).filter(id => !selection.includes(id)); + + if (unselectedIdsToAdd.length > 0) { + onSelect(unselectedIdsToAdd, true); + + const isMultiTouch = activePointersRef.current.size > 1; + if (isMultiTouch) { + // Find an existing drag group to attach to + let targetLeaderId: string | null = null; + let targetPointerId: number | null = null; + + for (const [ptrId, cId] of activePointersRef.current.entries()) { + if (ptrId === e.pointerId) continue; // Skip self + if (cId) { + const c = card.store.items.get(cId); + if (c && c.isDragging) { + targetLeaderId = cId; + targetPointerId = c.physics.dragPointerId; // Use the pointer driving that card + break; // Attach to the first found drag group + } + } + } + + if (targetLeaderId && targetPointerId !== null) { + attachToDragGroup(card.store, unselectedIdsToAdd, targetLeaderId, targetPointerId); + } + } + + // Haptic feedback if available + if (navigator.vibrate) { + navigator.vibrate(50); + } + } + }, 500); // 500ms long press + } + }, [card, isSelected, selection, onSelect, addPointer, activePointersRef, requestCanvasFocus]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + // Ignore interactions on interactive child elements + const target = e.target as Element; + const interactive = target.closest('button, a, input, textarea, select, [draggable="true"]'); + if (interactive && interactive !== e.currentTarget) { + return; + } + + e.preventDefault(); + + // Check for multi-touch (more than 1 active pointer implies we should add to selection) + // We check > 1 because the current pointer is already added + const isMultiTouch = activePointersRef.current.size > 1; + const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || isMultiTouch; + + updateState(e); + + // Cancel long press if moved + if (state === 'click' && initialPosition.current) { + const dx = e.clientX - initialPosition.current.x; + const dy = e.clientY - initialPosition.current.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance > DRAG_THRESHOLD && longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + } + + if (state === 'drag-start') { + let effectiveSelection = selection; + + if (!hasModifier) { + if (!isSelected) { + effectiveSelection = [card.id]; + onSelect([card.id], false); + } + } else { + // Modifier pressed: Add card and stack to selection + const stackIds = card.store.getStackBelow(card); + const idsToAdd = new Set<string>(); + + if (!isSelected) idsToAdd.add(card.id); + stackIds.forEach(id => idsToAdd.add(id)); + + // Only select what isn't already selected to avoid toggling off + const unselectedIdsToAdd = Array.from(idsToAdd).filter(id => !selection.includes(id)); + + if (unselectedIdsToAdd.length > 0) { + onSelect(unselectedIdsToAdd, true); + effectiveSelection = [...selection, ...unselectedIdsToAdd]; + } + } + + card.store.bringToFront(effectiveSelection); + + setState('drag'); + + // Start the drag for THIS pointer + if (initialPosition.current) { + const rect = card.ref.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const leadingCardId = card.id; + + const offset = { + x: initialPosition.current.x - centerX, + y: initialPosition.current.y - centerY + }; + + handleDragStart(card.store, effectiveSelection, leadingCardId, offset, e.pointerId); + // Reset lastPosition to current pointer to avoid jump on first move + lastPosition.current = { x: e.clientX, y: e.clientY }; + } + } + + if (state === 'drag' && lastPosition.current) { + const delta = { + x: e.clientX - lastPosition.current.x, + y: e.clientY - lastPosition.current.y + }; + + if (delta.x !== 0 || delta.y !== 0) { + handleDragMove(card.store, selection, delta, e.pointerId); + lastPosition.current = { x: e.clientX, y: e.clientY }; + } + } + }, [card, state, updateState, isSelected, selection, onSelect, activePointersRef]); + + const onPointerUp = useCallback((e: React.PointerEvent) => { + // Ignore interactions on interactive child elements + const target = e.target as Element; + const interactive = target.closest('button, a, input, textarea, select, [draggable="true"]'); + if (interactive && interactive !== e.currentTarget) { + return; + } + + e.preventDefault(); + + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + + // Check for multi-touch before removing the pointer + const isMultiTouch = activePointersRef.current.size > 1; + const hasModifier = e.metaKey || e.ctrlKey || e.shiftKey || isMultiTouch; + + // Unregister pointer + removePointer(e.pointerId); + + updateState(e); + + if (state === 'drag' || state === 'drag-start') { + handleDragEnd(card.store, selection, e.pointerId); + } else if (state === 'click') { + const now = Date.now(); + if (now - lastClickTime.current < 300) { + onDocumentActivate?.(card.id, e); + } + lastClickTime.current = now; + + if (isSelected) { + if (hasModifier) { + onDeselect([card.id]); + } + } else { + onSelect([card.id], hasModifier); + + if (!hasModifier) { + card.bringToFront(); + } + } + } + + setState('idle'); + initialPosition.current = null; + lastPosition.current = null; + (e.target as Element).releasePointerCapture(e.pointerId); + }, [card, state, isSelected, onSelect, onDeselect, onDocumentActivate, updateState, selection, activePointersRef, removePointer]); + + const onPointerCancel = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + + // Ensure we clean of any drags associated with this pointer + handleDragEnd(card.store, selection, e.pointerId); + + removePointer(e.pointerId); + setState('idle'); + initialPosition.current = null; + lastPosition.current = null; + (e.target as Element).releasePointerCapture(e.pointerId); + }, [card, selection, removePointer]); + + return { + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel + }; +}; diff --git a/frontend/src/desktop/logic/CardDragLogic.ts b/frontend/src/desktop/logic/CardDragLogic.ts new file mode 100644 index 0000000..deafcd1 --- /dev/null +++ b/frontend/src/desktop/logic/CardDragLogic.ts @@ -0,0 +1,78 @@ +import { LayoutStore } from './LayoutSystem'; + +export const handleDragStart = (store: LayoutStore, selection: string[], leadingId: string, offset: { x: number, y: number }, pointerId: number) => { + const leadingCard = store.items.get(leadingId); + if (!leadingCard) return; + + // 1. Begin drag on the leader (clears old followers) + leadingCard.physics.beginDrag(offset, pointerId); + + // 2. Snap all followers to leader's center + // 3. Attach them as followers to the leader's physics + selection.forEach(id => { + if (id === leadingId) return; + const card = store.items.get(id); + if (card) { + // If card is already dragging by another pointer, skip it + if (card.physics.isDragging && card.physics.dragPointerId !== pointerId) return; + + const leadingCenterX = leadingCard.x + leadingCard.width / 2; + const leadingCenterY = leadingCard.y + leadingCard.height / 2; + + const targetX = leadingCenterX - card.width / 2; + const targetY = leadingCenterY - card.height / 2; + + card.snapTo(targetX, targetY); + + // Stop any existing physics on the follower + card.physics.stop(); + + // Attach as follower + leadingCard.physics.addFollower(card); + } + }); +}; + +export const attachToDragGroup = (store: LayoutStore, selection: string[], leadingId: string, _pointerId: number) => { + const leadingCard = store.items.get(leadingId); + if (!leadingCard || !leadingCard.physics.isDragging) return; + + selection.forEach(id => { + if (id === leadingId) return; + const card = store.items.get(id); + + // If card exists and is not already dragging, attach it + if (card && !card.physics.isDragging) { + const leadingCenterX = leadingCard.x + leadingCard.width / 2; + const leadingCenterY = leadingCard.y + leadingCard.height / 2; + + const targetX = leadingCenterX - card.width / 2; + const targetY = leadingCenterY - card.height / 2; + + card.snapTo(targetX, targetY); + + // Stop any existing physics + card.physics.stop(); + + // Attach as follower + leadingCard.physics.addFollower(card); + } + }); +}; + +export const handleDragMove = (store: LayoutStore, _selection: string[], delta: { x: number, y: number }, pointerId: number) => { + for (const card of store.items.values()) { + if (card.physics.isDragging && card.physics.dragPointerId === pointerId) { + card.physics.continueDrag(delta); + } + } +}; + +export const handleDragEnd = (store: LayoutStore, _selection: string[], pointerId: number) => { + for (const card of store.items.values()) { + if (card.physics.dragPointerId === pointerId) { + card.physics.finishDrag(); + } + } + store.saveLayout(); +}; diff --git a/frontend/src/desktop/logic/CardPhysics.ts b/frontend/src/desktop/logic/CardPhysics.ts new file mode 100644 index 0000000..edc9ad1 --- /dev/null +++ b/frontend/src/desktop/logic/CardPhysics.ts @@ -0,0 +1,359 @@ +import { LayoutCard } from './LayoutSystem'; + +export class CardPhysics { + private velocity: { x: number, y: number, rotation: number } = { x: 0, y: 0, rotation: 0 }; + private _isDragging: boolean = false; + public dragPointerId: number | null = null; + private pendingDelta: { x: number, y: number } = { x: 0, y: 0 }; + + public mass: number = 30; + private baseMass: number = 30; + private massScale: number = 1; + private angularVelocity: number = 0; + private lastTimestamp: number = 0; + + private dragOffset: { x: number, y: number } | null = null; + + get isDragging(): boolean { + return this._isDragging; + } + + private physicsRafId: number | null = null; + private lastTickTime: number = 0; + private card: LayoutCard; + + constructor(card: LayoutCard) { + this.card = card; + this.updateMass(card.pageCount); + } + + updateMass(pageCount: number) { + const pages = Math.max(1, pageCount); + this.baseMass = 30 + 5 * pages; + this.mass = this.baseMass; + this.updateMassScale(); + } + + private updateMassScale() { + this.massScale = Math.max(this.mass / 30, 1); + } + + private normalizeAngle(angle: number): number { + let a = angle % 360; + if (a > 180) a -= 360; + if (a <= -180) a += 360; + return a; + } + + beginDrag(offset: { x: number, y: number }, pointerId: number) { + // If I am a follower of someone else, detach first! + if (this.leader) { + this.leader.removeFollower(this.card); + } + + // Ensure I don't have stale followers from a previous session + this.stopPhysicsLoop(); + + this._isDragging = true; + this.dragPointerId = pointerId; + + // Store the offset from center where we grabbed the card + // Convert world offset to local offset (rotate by -rotation) + const rad = -this.card.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + this.dragOffset = { + x: offset.x * cos - offset.y * sin, + y: offset.x * sin + offset.y * cos + }; + + this.angularVelocity = 0; + this.startPhysicsLoop(); + } + + continueDrag(delta: { x: number, y: number }) { + this.pendingDelta.x += delta.x; + this.pendingDelta.y += delta.y; + } + + finishDrag() { + this._isDragging = false; + this.dragPointerId = null; + // Keep dragOffset for inertia pivot correction + this.lastTimestamp = performance.now(); + this.angularVelocity = 0; // Kill momentum on release + } + + private startPhysicsLoop() { + if (this.physicsRafId) return; + this.lastTickTime = performance.now(); + this.physicsRafId = requestAnimationFrame(this.physicsTick); + } + + public stop() { + this.stopPhysicsLoop(); + } + + private stopPhysicsLoop() { + if (this.physicsRafId) { + cancelAnimationFrame(this.physicsRafId); + this.physicsRafId = null; + } + this.dragOffset = null; + this.clearFollowers(); + } + + private physicsTick = (time: number) => { + const rawDt = (time - this.lastTickTime) / 1000; + const dt = Math.max(1 / 120, Math.min(rawDt, 1 / 20)); + this.lastTickTime = time; + + this.updatePhysics(dt); + + if (this.physicsRafId) { + this.physicsRafId = requestAnimationFrame(this.physicsTick); + } + }; + + private readonly ROTATION_LIMIT = 5; + private minLimit: number = -5; + private maxLimit: number = 5; + + private followers: { card: LayoutCard, offsetRotation: number }[] = []; + public leader: CardPhysics | null = null; + + addFollower(card: LayoutCard) { + // Calculate relative rotation + // follower = leader + offset => offset = follower - leader + const offset = this.normalizeAngle(card.rotation - this.card.rotation); + this.followers.push({ card, offsetRotation: offset }); + + // Set back-reference + card.physics.leader = this; + + // Add follower mass to leader + this.mass += card.physics.mass; + this.updateMassScale(); + + // Constrain leader limits to ensure follower stays within [-5, 5] + // But clamp the result to never exceed the global limits [-5, 5] + // This prevents the leader from being forced into extreme angles by far-away followers + const calculatedMin = -this.ROTATION_LIMIT - offset; + const calculatedMax = this.ROTATION_LIMIT - offset; + + this.minLimit = Math.max(this.minLimit, Math.min(this.ROTATION_LIMIT, Math.max(-this.ROTATION_LIMIT, calculatedMin))); + this.maxLimit = Math.min(this.maxLimit, Math.max(-this.ROTATION_LIMIT, Math.min(this.ROTATION_LIMIT, calculatedMax))); + + // Safety: If limits invert (min > max), prioritize keeping leader near 0 + if (this.minLimit > this.maxLimit) { + this.minLimit = -this.ROTATION_LIMIT; + this.maxLimit = this.ROTATION_LIMIT; + } + } + + removeFollower(card: LayoutCard) { + const index = this.followers.findIndex(f => f.card === card); + if (index !== -1) { + const follower = this.followers[index]; + follower.card.physics.leader = null; + this.followers.splice(index, 1); + + // Recalculate mass and limits + this.recalculateStackProperties(); + } + } + + clearFollowers() { + // Clear back-references + this.followers.forEach(f => { + f.card.physics.leader = null; + }); + this.followers = []; + this.recalculateStackProperties(); + } + + private recalculateStackProperties() { + this.mass = this.baseMass; + this.minLimit = -this.ROTATION_LIMIT; + this.maxLimit = this.ROTATION_LIMIT; + + for (const f of this.followers) { + this.mass += f.card.physics.mass; + + // Re-apply limits + const offset = f.offsetRotation; + const calculatedMin = -this.ROTATION_LIMIT - offset; + const calculatedMax = this.ROTATION_LIMIT - offset; + + this.minLimit = Math.max(this.minLimit, Math.min(this.ROTATION_LIMIT, Math.max(-this.ROTATION_LIMIT, calculatedMin))); + this.maxLimit = Math.min(this.maxLimit, Math.max(-this.ROTATION_LIMIT, Math.min(this.ROTATION_LIMIT, calculatedMax))); + } + + // Safety check + if (this.minLimit > this.maxLimit) { + this.minLimit = -this.ROTATION_LIMIT; + this.maxLimit = this.ROTATION_LIMIT; + } + + this.updateMassScale(); + } + + private updatePhysics(dt: number) { + const dx = this.pendingDelta.x; + const dy = this.pendingDelta.y; + + this.pendingDelta = { x: 0, y: 0 }; + + const vx = dx / dt; + const vy = dy / dt; + + // 1. Update Position (Direct 1:1 movement) + const newX = this.card.x + dx; + const newY = this.card.y + dy; + const constrained = this.card.getConstrainedPosition(newX, newY); + + // 2. Calculate Torque & Forces + let torque = 0; + let recoveryTorque = 0; + let isRecovering = false; + + // Drag Torque + if (this.dragOffset && this._isDragging) { + const rad = this.card.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const worldLeverX = this.dragOffset.x * cos - this.dragOffset.y * sin; + const worldLeverY = this.dragOffset.x * sin + this.dragOffset.y * cos; + + torque = worldLeverX * vy - worldLeverY * vx; + } + + // Recovery Torque + const normRot = this.normalizeAngle(this.card.rotation); + if (normRot > this.maxLimit) { + recoveryTorque = (this.maxLimit - normRot) * 2500; + isRecovering = true; + } else if (normRot < this.minLimit) { + recoveryTorque = (this.minLimit - normRot) * 2500; + isRecovering = true; + } + + const totalTorque = torque + recoveryTorque; + const alpha = (totalTorque * 0.05) / this.massScale; + this.angularVelocity += alpha * dt; + + // Friction + this.angularVelocity *= 0.85; + if (isRecovering) { + this.angularVelocity *= 0.6; + } + + // Deadzone + if (Math.abs(this.angularVelocity) < 1) { + this.angularVelocity = 0; + } + + // 3. Update Rotation + let newRot = this.card.rotation + this.angularVelocity * dt; + + // Ratchet clamping + const newNormRot = this.normalizeAngle(newRot); + + if (newNormRot > this.maxLimit) { + // If moving further past max, clamp + if (newNormRot > normRot) { + newRot = this.card.rotation + (this.maxLimit - normRot); + this.angularVelocity = 0; + } + } else if (newNormRot < this.minLimit) { + // If moving further past min, clamp + if (newNormRot < normRot) { + newRot = this.card.rotation + (this.minLimit - normRot); + this.angularVelocity = 0; + } + } + + // 4. Pivot Correction + let correctionX = 0; + let correctionY = 0; + + if (this.dragOffset) { + const oldRad = this.card.rotation * Math.PI / 180; + const oldCos = Math.cos(oldRad); + const oldSin = Math.sin(oldRad); + + const newRad = newRot * Math.PI / 180; + const newCos = Math.cos(newRad); + const newSin = Math.sin(newRad); + + const oldLeverX = this.dragOffset.x * oldCos - this.dragOffset.y * oldSin; + const oldLeverY = this.dragOffset.x * oldSin + this.dragOffset.y * oldCos; + + const newLeverX = this.dragOffset.x * newCos - this.dragOffset.y * newSin; + const newLeverY = this.dragOffset.x * newSin + this.dragOffset.y * newCos; + + correctionX = oldLeverX - newLeverX; + correctionY = oldLeverY - newLeverY; + } + + // Stop Condition + if (!this._isDragging) { + const currentNormRot = this.normalizeAngle(this.card.rotation); + const isOutside = currentNormRot > this.maxLimit || currentNormRot < this.minLimit; + + if (isOutside) { + const targetAngle = currentNormRot > this.maxLimit ? this.maxLimit : this.minLimit; + const dist = Math.abs(this.normalizeAngle(currentNormRot - targetAngle)); + + if (Math.abs(this.angularVelocity) < 0.5 && dist < 0.1) { + this.card.update({ rotation: targetAngle }, { markDirty: true }); + this.angularVelocity = 0; + this.stopPhysicsLoop(); + return; + } + } else { + // Inside range - just stop if slow + if (Math.abs(this.angularVelocity) < 0.5) { + this.angularVelocity = 0; + this.stopPhysicsLoop(); + return; + } + } + } + + // 5. Apply to Leader + const finalX = constrained.x + correctionX; + const finalY = constrained.y + correctionY; + const finalConstrained = this.card.getConstrainedPosition(finalX, finalY); + + this.card.update({ + x: finalConstrained.x, + y: finalConstrained.y, + rotation: newRot + }, { markDirty: true }); + + // 6. Apply to Followers + for (const follower of this.followers) { + // Followers match leader's position exactly (center aligned) + // But we need to account for their own dimensions if we want center-to-center alignment + // The LayoutCard.x/y is top-left. + // Leader Center: finalConstrained.x + leader.width/2, finalConstrained.y + leader.height/2 + + const leaderCenterX = finalConstrained.x + this.card.width / 2; + const leaderCenterY = finalConstrained.y + this.card.height / 2; + + const followerX = leaderCenterX - follower.card.width / 2; + const followerY = leaderCenterY - follower.card.height / 2; + + const followerRot = newRot + follower.offsetRotation; + + follower.card.update({ + x: followerX, + y: followerY, + rotation: followerRot + }, { markDirty: true }); + } + } +} diff --git a/frontend/src/desktop/logic/LayoutSystem.ts b/frontend/src/desktop/logic/LayoutSystem.ts new file mode 100644 index 0000000..58eb399 --- /dev/null +++ b/frontend/src/desktop/logic/LayoutSystem.ts @@ -0,0 +1,571 @@ +import { constrainDimensions, getInitialPosition, CONTAINER_PADDING } from '../utils/layoutUtils'; + +import { fetchLayoutRecords, upsertLayoutRecords } from './db'; +import { CardPhysics } from './CardPhysics'; + +interface LayoutCardState { + id: string; + x: number; + y: number; + z: number; + rotation: number; + width: number; + height: number; + pageCount: number; +} + +export class LayoutCard implements LayoutCardState { + id: string; + x: number = 0; + y: number = 0; + z: number = 0; + rotation: number = 0; + width: number = 0; + height: number = 0; + pageCount: number = 1; + ref: HTMLElement | null = null; + + private _innerRadius: number = 0; + private _outerRadius: number = 0; + private _centerX: number = 0; + private _centerY: number = 0; + public store: LayoutStore; + public physics: CardPhysics; + public intendedX: number = 0; + public intendedY: number = 0; + public isDirty: boolean = false; + + constructor(id: string, store: LayoutStore, initialData: Partial<LayoutCardState> = {}, ref: HTMLElement | null = null) { + this.id = id; + this.store = store; + Object.assign(this, initialData); + this.physics = new CardPhysics(this); + this.intendedX = this.x; + this.intendedY = this.y; + this.ref = ref; + this.recalculateRadii(); + this.recalculateCenters(); + } + + setRef(ref: HTMLElement | null) { + this.ref = ref; + this.applyTransform(); + } + + getConstrainedPosition(x: number, y: number): { x: number, y: number } { + const rad = (this.rotation * Math.PI) / 180; + const sin = Math.abs(Math.sin(rad)); + const cos = Math.abs(Math.cos(rad)); + + const rotatedWidth = this.width * cos + this.height * sin; + const rotatedHeight = this.width * sin + this.height * cos; + + const minX = CONTAINER_PADDING + (rotatedWidth - this.width) / 2; + const maxX = this.store.containerWidth - CONTAINER_PADDING - this.width - (rotatedWidth - this.width) / 2; + + const minY = CONTAINER_PADDING + (rotatedHeight - this.height) / 2; + const maxY = this.store.containerHeight - CONTAINER_PADDING - this.height - (rotatedHeight - this.height) / 2; + + const newX = Math.max(minX, Math.min(x, maxX)); + const newY = Math.max(minY, Math.min(y, maxY)); + + return { x: newX, y: newY }; + } + + + + update(changes: Partial<LayoutCardState>, options: { markDirty?: boolean, isConstraintUpdate?: boolean } = {}) { + Object.assign(this, changes); + + if (!options.isConstraintUpdate) { + if (changes.x !== undefined) this.intendedX = changes.x; + if (changes.y !== undefined) this.intendedY = changes.y; + } + + if (changes.width !== undefined || changes.height !== undefined) { + this.recalculateRadii(); + } + + if (changes.x !== undefined || changes.y !== undefined || changes.width !== undefined || changes.height !== undefined) { + this.recalculateCenters(); + } + + if (changes.pageCount !== undefined) { + this.physics.updateMass(changes.pageCount); + } + + if (options.markDirty) { + this.isDirty = true; + } + + this.applyTransform(); + } + + isUnobstructed(): boolean { + for (const other of this.store.items.values()) { + if (other.id === this.id) continue; + if (other.z <= this.z) continue; + + const dx = other.x - this.x; + const dy = other.y - this.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Broad phase: check outer radii + if (distance < this.outerRadius + other.outerRadius) { + // Narrow phase: SAT intersection test + if (this.intersects(other)) { + return false; + } + } + } + + return true; + } + + bringToFront() { + this.z = this.store.zCounter++; + } + + private getVertices(): { x: number; y: number }[] { + const rad = (this.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const hw = this.width / 2; + const hh = this.height / 2; + + // Corners relative to center, then rotated, then translated + // (-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh) + const corners = [ + { x: -hw, y: -hh }, + { x: hw, y: -hh }, + { x: hw, y: hh }, + { x: -hw, y: hh } + ]; + + return corners.map(p => ({ + x: (p.x * cos - p.y * sin) + this._centerX, + y: (p.x * sin + p.y * cos) + this._centerY + })); + } + + containsPoint(x: number, y: number): boolean { + // Translate point to local space relative to center + const dx = x - this._centerX; + const dy = y - this._centerY; + + // Rotate point by -rotation to align with AABB + const rad = (-this.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const localX = dx * cos - dy * sin; + const localY = dx * sin + dy * cos; + + const hw = this.width / 2; + const hh = this.height / 2; + + return localX >= -hw && localX <= hw && localY >= -hh && localY <= hh; + } + + getVisibleFraction(): number { + const samplesX = 4; + const samplesY = 4; + const totalSamples = samplesX * samplesY; + let visibleSamples = 0; + + // Get potential occluders (higher Z-index) + const occluders = Array.from(this.store.items.values()).filter(other => + other.id !== this.id && other.z > this.z + ); + + if (occluders.length === 0) return 1.0; + + const rad = (this.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const hw = this.width / 2; + const hh = this.height / 2; + + // Sample points across the card surface + for (let i = 0; i < samplesX; i++) { + for (let j = 0; j < samplesY; j++) { + // Normalized coordinates [-1, 1] + const nx = (i / (samplesX - 1)) * 2 - 1; + const ny = (j / (samplesY - 1)) * 2 - 1; + + // Local coordinates + const lx = nx * hw * 0.9; // 0.9 to avoid edge cases + const ly = ny * hh * 0.9; + + // World coordinates + const wx = (lx * cos - ly * sin) + this._centerX; + const wy = (lx * sin + ly * cos) + this._centerY; + + // Check occlusion + let isOccluded = false; + for (const occluder of occluders) { + if (occluder.containsPoint(wx, wy)) { + isOccluded = true; + break; + } + } + + if (!isOccluded) { + visibleSamples++; + } + } + } + + return visibleSamples / totalSamples; + } + + private getAxes(): { x: number; y: number }[] { + const rad = (this.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + // Normals of the edges (local x and y axes) + return [ + { x: cos, y: sin }, + { x: -sin, y: cos } + ]; + } + + private intersects(other: LayoutCard): boolean { + const verticesA = this.getVertices(); + const verticesB = other.getVertices(); + const axes = [...this.getAxes(), ...other.getAxes()]; + + for (const axis of axes) { + const pA = this.project(verticesA, axis); + const pB = this.project(verticesB, axis); + + if (pA.max < pB.min || pB.max < pA.min) { + return false; // Gap found, no intersection + } + } + return true; + } + + private project(vertices: { x: number; y: number }[], axis: { x: number; y: number }) { + let min = Infinity; + let max = -Infinity; + for (const v of vertices) { + const dot = v.x * axis.x + v.y * axis.y; + if (dot < min) min = dot; + if (dot > max) max = dot; + } + return { min, max }; + } + + private recalculateRadii() { + this._innerRadius = Math.min(this.width, this.height) / 2; + this._outerRadius = Math.sqrt(this.width * this.width + this.height * this.height) / 2; + } + + private recalculateCenters() { + this._centerX = this.x + this.width / 2; + this._centerY = this.y + this.height / 2; + } + + private rafId: number | null = null; + + private applyTransform() { + if (this.rafId) { + cancelAnimationFrame(this.rafId); + } + + this.rafId = requestAnimationFrame(() => { + if (this.ref) { + this.ref.style.transform = + `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`; + this.ref.style.zIndex = String(this.z); + this.ref.style.width = `${this.width}px`; + this.ref.style.height = `${this.height}px`; + } + this.rafId = null; + }); + } + + snapTo(x: number, y: number) { + this.update({ x, y }, { markDirty: true }); + + if (!this.ref) + return; + + this.ref.classList.add('desk-item--swoop'); + this.ref.style.transform = `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`; + + const cleanup = () => { + if (this.ref) { + this.ref.classList.remove('desk-item--swoop'); + } + }; + + this.ref.addEventListener('transitionend', cleanup, { once: true }); + + // Safety timeout in case transitionend doesn't fire (e.g. element removed) + setTimeout(cleanup, 350); + } + + toSnapshot(): LayoutCardState { + return { + id: this.id, + x: this.x, + y: this.y, + z: this.z, + rotation: this.rotation, + width: this.width, + height: this.height, + pageCount: this.pageCount + }; + } + + get innerRadius(): number { + return this._innerRadius; + } + + get outerRadius(): number { + return this._outerRadius; + } + + get centerX(): number { + return this._centerX; + } + + get centerY(): number { + return this._centerY; + } + + get isDragging(): boolean { + return this.physics.isDragging; + } +} + +export class LayoutStore { + items = new Map<string, LayoutCard>(); + zCounter = 100; + containerWidth: number = 0; + containerHeight: number = 0; + + private savedLayouts = new Map<string, { x: number, y: number, rotation: number, z: number }>(); + private tenantId: string | null = null; + private viewId: string | null = null; + + initialize(id: string, ref: HTMLElement | null, config: { + width: number; + height: number; + pageCount: number; + maxSize?: number; + }) { + let card = this.items.get(id); + + const { width, height } = constrainDimensions( + config.width, + config.height, + config.maxSize || 320 + ); + + if (!card) { + // Check for saved layout + const saved = this.savedLayouts.get(id); + + let x, y, rotation, z; + + if (saved) { + x = saved.x; + y = saved.y; + rotation = saved.rotation; + z = saved.z; + // Ensure zCounter is higher than any loaded z + if (z >= this.zCounter) { + this.zCounter = z + 1; + } + + card = new LayoutCard(id, this, { + x, + y, + rotation, + width, + height, + z, + pageCount: config.pageCount + }, ref); + } else { + // Create card with temporary position + card = new LayoutCard(id, this, { + width, + height, + z: this.zCounter++, + pageCount: config.pageCount + }, ref); + + // Calculate initial position using the card instance + const { x: initX, y: initY, rotation: initRotation } = getInitialPosition( + this.containerWidth, + this.containerHeight, + card, + Array.from(this.items.values()) + ); + + // Update card with calculated position + card.update({ x: initX, y: initY, rotation: initRotation }, { markDirty: true }); + } + + this.items.set(id, card); + } else { + card.update({ width, height, pageCount: config.pageCount }, { markDirty: false }); + } + + // Always update ref and ensure transform is applied + if (card.ref !== ref) { + card.setRef(ref); + } + + return card; + } + + unregister(id: string) { + this.items.delete(id); + } + + clear() { + this.items.clear(); + this.zCounter = 100; + this.savedLayouts.clear(); + } + + setContainerSize(width: number, height: number) { + this.containerWidth = width; + this.containerHeight = height; + this.relayout(); + } + + relayout() { + for (const card of this.items.values()) { + const { x, y } = card.getConstrainedPosition(card.intendedX, card.intendedY); + if (x !== card.x || y !== card.y) { + card.update({ x, y }, { markDirty: false, isConstraintUpdate: true }); + } + } + } + + getCardsInCircle(x: number, y: number, radius: number): LayoutCard[] { + const result: LayoutCard[] = []; + for (const card of this.items.values()) { + // Calculate center of candidate card + const cx = card.x + card.width / 2; + const cy = card.y + card.height / 2; + + const dx = cx - x; + const dy = cy - y; + + const distSq = dx * dx + dy * dy; + const limit = radius; + + if (distSq < limit * limit) { + result.push(card); + } + } + return result; + } + + getStackBelow(topCard: LayoutCard): string[] { + const centerX = topCard.x + topCard.width / 2; + const centerY = topCard.y + topCard.height / 2; + const candidates = this.getCardsInCircle(centerX, centerY, topCard.innerRadius); + + return candidates + .filter(other => { + if (other.id === topCard.id) return false; + if (other.z >= topCard.z) return false; + return true; + }) + .map(c => c.id); + } + + bringToFront(ids: string[]) { + const cards = ids + .map(id => this.items.get(id)) + .filter((c): c is LayoutCard => !!c); + + // Sort by current Z-index to preserve relative order + cards.sort((a, b) => a.z - b.z); + + // Assign new Z-indices + for (const card of cards) { + card.update({ z: this.zCounter++ }, { markDirty: true }); + } + } + + getSnapshot() { + return Array.from(this.items.values()).map(card => card.toSnapshot()); + } + + async loadLayout(tenantId: string, viewId: string) { + this.tenantId = tenantId; + this.viewId = viewId; + + const records = await fetchLayoutRecords({ tenantId, viewId }); + + this.savedLayouts.clear(); + let maxZ = this.zCounter; + + for (const record of records) { + if (record.documentId && record.centerX !== undefined && record.centerY !== undefined) { + this.savedLayouts.set(record.documentId, { + x: record.centerX, + y: record.centerY, + rotation: record.rotation || 0, + z: record.zIndex || 0 + }); + if (record.zIndex && record.zIndex >= maxZ) { + maxZ = record.zIndex + 1; + } + } + } + + this.zCounter = maxZ; + + // Apply to existing items if any (though usually this runs before items are created) + for (const [id, card] of this.items) { + const saved = this.savedLayouts.get(id); + if (saved) { + card.update(saved, { markDirty: false }); + } + } + + // Ensure everything is within bounds + this.relayout(); + } + + hasSavedLayout(id: string): boolean { + return this.savedLayouts.has(id); + } + + async saveLayout() { + if (!this.tenantId || !this.viewId) return; + + const dirtyCards = Array.from(this.items.values()).filter(card => card.isDirty); + if (dirtyCards.length === 0) return; + + const entries = dirtyCards.map(card => ({ + documentId: card.id, + centerX: card.intendedX, + centerY: card.intendedY, + rotation: card.rotation, + zIndex: card.z, + updatedAt: Date.now() + })); + + await upsertLayoutRecords({ + tenantId: this.tenantId, + viewId: this.viewId, + entries + }); + + // Reset dirty flag for saved cards + for (const card of dirtyCards) { + card.isDirty = false; + } + } +} diff --git a/frontend/src/desktop/logic/db.ts b/frontend/src/desktop/logic/db.ts new file mode 100644 index 0000000..f258a42 --- /dev/null +++ b/frontend/src/desktop/logic/db.ts @@ -0,0 +1,164 @@ +import type { DocumentId } from '../../types/identifiers'; +import { DB_NAME, DB_VERSION, LAYOUT_STORE } from '../../constants/desktop'; +type TenantId = import('../../types/identifiers').TenantId; + +const currentDbPromise: { value: Promise<IDBDatabase | null> | null } = { value: null }; + +const openDatabase = (): Promise<IDBDatabase> => { + if (currentDbPromise.value) { + return currentDbPromise.value as Promise<IDBDatabase>; + } + + currentDbPromise.value = new Promise((resolve, reject) => { + const request = window.indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(LAYOUT_STORE)) { + const store = db.createObjectStore(LAYOUT_STORE, { + keyPath: ['tenantId', 'viewId', 'documentId'], + }); + store.createIndex('tenantViewIdx', ['tenantId', 'viewId'], { unique: false }); + store.createIndex('tenantIdx', 'tenantId', { unique: false }); + store.createIndex('updatedIdx', 'updatedAt', { unique: false }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error('Failed to open IndexedDB')); + }; + }); + + return currentDbPromise.value as Promise<IDBDatabase>; +}; + +const requestToPromise = <T>(request: IDBRequest<T>, defaultValue: T): Promise<T> => + new Promise((resolve, reject) => { + request.onsuccess = () => { + const { result } = request; + resolve(result ?? defaultValue); + }; + request.onerror = () => { + reject(request.error || new Error('IndexedDB request failed')); + }; + }); + +const transactionComplete = (transaction: IDBTransaction) => + new Promise<void>((resolve, reject) => { + transaction.oncomplete = () => { + resolve(); + }; + transaction.onerror = () => { + reject(transaction.error || new Error('IndexedDB transaction failed')); + }; + transaction.onabort = () => { + reject(transaction.error || new Error('IndexedDB transaction aborted')); + }; + }); + +type TransactionMode = 'readonly' | 'readwrite' | 'versionchange'; + +const withStore = async <T>(mode: TransactionMode, handler: (store: IDBObjectStore, tx: IDBTransaction) => Promise<T> | T): Promise<T> => { + const db = await openDatabase(); + const transaction = db.transaction(LAYOUT_STORE, mode); + const store = transaction.objectStore(LAYOUT_STORE); + const done = transactionComplete(transaction); + try { + const result = await handler(store, transaction); + await done; + return result; + } catch (error) { + try { + transaction.abort(); + } catch (abortError) { + console.warn('[desk] Failed to abort transaction', abortError); + } + try { + await done; + } catch { + // ignore + } + throw error; + } +}; + +interface LayoutRecord { + tenantId: TenantId; + viewId: string; + documentId: DocumentId; + centerX?: number; + centerY?: number; + rotation?: number; + zIndex?: number; + updatedAt?: number; +} + +export const fetchLayoutRecords = async ({ + tenantId, + viewId, +}: { + tenantId?: TenantId; + viewId?: string; +}): Promise<LayoutRecord[]> => { + if (!tenantId || !viewId) { + return []; + } + + try { + return await withStore('readonly', (store) => { + const index = store.index('tenantViewIdx'); + return requestToPromise(index.getAll([tenantId, viewId]), []); + }); + } catch (error) { + console.warn('[desk] Failed to read layout records', error); + return []; + } +}; + +export const upsertLayoutRecords = async ({ + tenantId, + viewId, + entries, +}: { + tenantId?: TenantId; + viewId?: string; + entries?: Array<{ + documentId?: DocumentId; + centerX?: number; + centerY?: number; + rotation?: number; + zIndex?: number; + updatedAt?: number; + }>; +}) => { + if (!tenantId || !viewId || !Array.isArray(entries) || !entries.length) { + return; + } + + try { + await withStore('readwrite', (store) => { + const timestamp = Date.now(); + entries.forEach((entry) => { + if (!entry || !entry.documentId) { + return; + } + store.put({ + tenantId, + viewId, + documentId: entry.documentId, + centerX: Number(entry.centerX) || 0, + centerY: Number(entry.centerY) || 0, + rotation: Number(entry.rotation) || 0, + zIndex: Number(entry.zIndex) || 0, + updatedAt: entry.updatedAt || timestamp, + } satisfies LayoutRecord); + }); + }); + } catch (error) { + console.warn('[desk] Failed to upsert layout records', error); + } +}; diff --git a/frontend/src/desktop/utils/layoutUtils.ts b/frontend/src/desktop/utils/layoutUtils.ts new file mode 100644 index 0000000..f86c602 --- /dev/null +++ b/frontend/src/desktop/utils/layoutUtils.ts @@ -0,0 +1,87 @@ +import type { LayoutCard } from '../logic/LayoutSystem'; + +export const CONTAINER_PADDING = 18; + +export const constrainDimensions = (width: number, height: number, maxDimension: number) => { + if (width <= maxDimension && height <= maxDimension) { + return { width, height }; + } + + const aspect = width / height; + if (width > height) { + return { + width: maxDimension, + height: maxDimension / aspect + }; + } else { + return { + width: maxDimension * aspect, + height: maxDimension + }; + } +}; + +export const getInitialPosition = ( + containerWidth: number, + containerHeight: number, + card: LayoutCard, + _existingCards: LayoutCard[] = [] +): { x: number, y: number, rotation: number } => { + // Mitchell's Best-Candidate Algorithm (Monte Carlo) + const K = 20; // Number of candidates to test + let bestCandidate = { x: 0, y: 0, rotation: 0 }; + let bestScore = -Infinity; + + // Padding to keep cards inside + const padding = CONTAINER_PADDING; + + // Calculate safe bounds for top-left corner + const minX = padding; + const maxX = Math.max(padding, containerWidth - card.width - padding); + const minY = padding; + const maxY = Math.max(padding, containerHeight - card.height - padding); + + const newCardRadius = card.outerRadius; + const halfWidth = card.width / 2; + const halfHeight = card.height / 2; + + for (let i = 0; i < K; i++) { + const x = minX + Math.random() * (maxX - minX); + const y = minY + Math.random() * (maxY - minY); + + const cx = x + halfWidth; + const cy = y + halfHeight; + + // Distance to nearest edge + const distEdge = Math.min( + x, // Left + containerWidth - (x + card.width), // Right + y, // Top + containerHeight - (y + card.height) // Bottom + ); + + // Distance to nearest neighbor + let minNeighborDist = Infinity; + for (const other of _existingCards) { + const dx = cx - other.centerX; + const dy = cy - other.centerY; + const distSq = dx * dx + dy * dy; + + const radiiSum = newCardRadius + other.outerRadius; + const distToEdge = distSq - radiiSum * radiiSum; + + if (distToEdge < minNeighborDist) { + minNeighborDist = distToEdge; + } + } + + const score = Math.min(distEdge, minNeighborDist); + + if (score > bestScore) { + bestScore = score; + bestCandidate = { x, y, rotation: Math.random() * 10 - 5 }; + } + } + + return bestCandidate; +}; diff --git a/frontend/src/documents/CorrespondentLinks.tsx b/frontend/src/documents/CorrespondentLinks.tsx new file mode 100644 index 0000000..28368d7 --- /dev/null +++ b/frontend/src/documents/CorrespondentLinks.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { NBSP } from '../constants/ui'; + +interface CorrespondentLinkEntry { + id?: string | null; + name?: string | null; + key?: string; +} + +interface CorrespondentLinksProps { + correspondents?: CorrespondentLinkEntry[]; + activeCorrespondentIdSet?: Set<string>; + onCorrespondentClick?: (id: string) => void; +} + +const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({ + correspondents, + activeCorrespondentIdSet, + onCorrespondentClick, +}) => { + if (!Array.isArray(correspondents) || correspondents.length === 0) { + return null; + } + + const activeSet = activeCorrespondentIdSet || new Set<string>(); + const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => { + if (!onCorrespondentClick || correspondent.id == null) { + return; + } + event.stopPropagation(); + onCorrespondentClick(correspondent.id); + }; + + return correspondents.map((correspondent, index) => { + const isActive = correspondent.id != null && activeSet.has(correspondent.id); + const hasHandler = Boolean(onCorrespondentClick) && correspondent.id != null; + const classNames = ['doc-correspondent-link']; + if (isActive) classNames.push('is-active'); + if (!hasHandler) classNames.push('is-static'); + const isLast = index === correspondents.length - 1; + const fallbackLabel = correspondent.name ?? '—'; + const label = isLast ? `${fallbackLabel}:${NBSP}` : fallbackLabel; + + return ( + <React.Fragment + key={correspondent.key ?? correspondent.id ?? `${fallbackLabel}-${index}`} + > + <button + type="button" + className={classNames.join(' ')} + aria-disabled={hasHandler ? undefined : true} + onClick={(event) => handleClick(event, correspondent)} + onKeyDown={(event) => { + if (!hasHandler) { + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + handleClick(event, correspondent); + } + }} + > + {label} + </button> + {isLast ? null : <span className="doc-correspondent-link__separator">, </span>} + </React.Fragment> + ); + }); +}; + +export default CorrespondentLinks; diff --git a/frontend/src/documents/DocumentThumbnailImage.tsx b/frontend/src/documents/DocumentThumbnailImage.tsx new file mode 100644 index 0000000..7b28a16 --- /dev/null +++ b/frontend/src/documents/DocumentThumbnailImage.tsx @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, JSX, MutableRefObject } from 'react'; +import type { Document } from '../types/documents'; +import { + getAssetFromVersion, + resolveDocumentAssetUrl, + resolveAssetUrl, +} from '../lib/assets/AssetManager'; +import { DEFAULT_THUMBNAIL_SIZE } from '../constants/documents'; +import type { + Asset as AssetManagerAsset, + EnsureAssetUrl as AssetManagerEnsureAssetUrl, + GetAsset as AssetManagerGetAsset, +} from '../lib/assets/AssetManager'; + +// Detect when an element becomes visible within a scroll container so we can delay loading. +const useLazyVisibility = ( + rootRef: MutableRefObject<Element | null> | null, + resetKey?: string | null, +) => { + const targetRef = useRef<HTMLDivElement | null>(null); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + setIsVisible(false); + }, [resetKey]); + + const rootNode = rootRef?.current || null; + + useEffect(() => { + if (isVisible) { + return undefined; + } + + const element = targetRef.current; + if (!element) { + return undefined; + } + + if (!window.IntersectionObserver) { + setIsVisible(true); + return undefined; + } + + const observer = new window.IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }); + }, + { + root: rootNode, + rootMargin: '200px 0px', + threshold: 0.01, + }, + ); + + observer.observe(element); + return () => observer.disconnect(); + }, [isVisible, rootNode, resetKey]); + + return { ref: targetRef, isVisible }; +}; + + + +const getPageCount = (doc?: Document | null): number | null => { + return doc?.current_version?.metadata?.page_count; +}; + +type Asset = AssetManagerAsset; +type EnsureAssetUrl = AssetManagerEnsureAssetUrl; +type GetAsset = AssetManagerGetAsset; + +interface DocumentThumbnailImageProps { + document?: Document | null; + ensureAssetUrl?: EnsureAssetUrl; + getAsset?: GetAsset; + alt?: string; + maxSize?: number; + scrollRootRef?: MutableRefObject<Element | null> | null; +} + +const DocumentThumbnailImage = ({ + document, + ensureAssetUrl, + getAsset, + alt = '', + maxSize = DEFAULT_THUMBNAIL_SIZE, + scrollRootRef = null, +}: DocumentThumbnailImageProps): JSX.Element => { + const documentId = document?.id; + const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId); + const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); + + const thumbnailAsset = useMemo<Asset | null>( + () => getAssetFromVersion(document?.current_version, 'thumbnail'), + [document?.current_version], + ); + const thumbnailMetadata = (thumbnailAsset?.metadata as { width?: number; height?: number } | null) || null; + const assetWidth = thumbnailMetadata?.width; + const assetHeight = thumbnailMetadata?.height; + + const dimensions = useMemo(() => { + const hasDimensions = typeof assetWidth === 'number' && assetWidth > 0 && typeof assetHeight === 'number' && assetHeight > 0; + if (!hasDimensions) { + return { width: resolvedMaxSize, height: resolvedMaxSize }; + } + const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight); + return { + width: Math.max(1, Math.round(assetWidth * scale)), + height: Math.max(1, Math.round(assetHeight * scale)), + }; + }, [assetWidth, assetHeight, resolvedMaxSize]); + + const innerStyle = useMemo<CSSProperties>( + () => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }), + [dimensions.height, dimensions.width], + ); + + const url = useMemo(() => { + if (!isVisible) { + return null; + } + const options: { + ensureAssetUrl?: EnsureAssetUrl; + getAsset?: GetAsset; + } = {}; + if (ensureAssetUrl) { + options.ensureAssetUrl = ensureAssetUrl; + } + if (getAsset) { + options.getAsset = getAsset; + } + return resolveDocumentAssetUrl(document, 'thumbnail', options) || resolveAssetUrl(thumbnailAsset); + }, [document, ensureAssetUrl, getAsset, isVisible, thumbnailAsset]); + + const pageCount = getPageCount(document); + const showMultiPageBadge = pageCount !== null && pageCount > 1; + const innerClasses = ['document-thumbnail-inner']; + if (showMultiPageBadge) { + innerClasses.push('document-thumbnail-inner--multipage'); + } + + const aspectRatio = useMemo(() => { + if (dimensions.width > 0 && dimensions.height > 0) { + return dimensions.width / dimensions.height; + } + return null; + }, [dimensions.height, dimensions.width]); + + useEffect(() => { + const node = visibilityRef.current; + if (!node) { + return; + } + if (aspectRatio) { + node.dataset.thumbnailAspect = String(aspectRatio); + } else { + delete node.dataset.thumbnailAspect; + } + }, [aspectRatio, visibilityRef]); + + return ( + <div className="document-thumbnail-wrapper" ref={visibilityRef}> + <div className={innerClasses.join(' ')} style={innerStyle}> + {url ? ( + <img + src={url} + alt={alt} + className="document-thumbnail" + loading="lazy" + decoding="async" + draggable={false} + onDragStart={(event) => event.preventDefault()} + /> + ) : ( + <div className="thumb-placeholder">DOC</div> + )} + </div> + </div> + ); +}; + +export default DocumentThumbnailImage; diff --git a/frontend/src/documents/DocumentsManager.ts b/frontend/src/documents/DocumentsManager.ts new file mode 100644 index 0000000..aaef178 --- /dev/null +++ b/frontend/src/documents/DocumentsManager.ts @@ -0,0 +1,239 @@ +import { shallowEqual } from 'react-redux'; +import type { DocumentId, Identifier, TagId } from '../types/identifiers'; +import type { Tag, Correspondent } from '../types/documents'; +import type TagManager from '../lib/assets/TagManager'; +import type CorrespondentManager from '../lib/assets/CorrespondentManager'; + +type ManagedDocument = { id?: DocumentId | null; tags?: Identifier[] | null; correspondents?: Identifier[] | null } & Record<string, unknown>; + +type FetchDocument = (id: DocumentId) => Promise<unknown>; + +class DocumentsManager<T extends ManagedDocument = ManagedDocument> { + private byId: Map<DocumentId, T>; + private fetcher?: FetchDocument; + private inflight: Map<DocumentId, Promise<T | null>>; + private listeners: Set<() => void>; + private emitScheduled: boolean; + private tagManager?: TagManager; + private correspondentManager?: CorrespondentManager; + + constructor( + fetchDocument?: FetchDocument, + ) { + this.byId = new Map(); + this.fetcher = fetchDocument; + this.inflight = new Map(); + this.listeners = new Set(); + this.emitScheduled = false; + } + + setTagManager(tagManager: TagManager) { + this.tagManager = tagManager; + } + + setCorrespondentManager(correspondentManager: CorrespondentManager) { + this.correspondentManager = correspondentManager; + } + + private emit() { + if (this.emitScheduled) { + return; + } + this.emitScheduled = true; + setTimeout(() => { + this.emitScheduled = false; + this.listeners.forEach((fn) => fn()); + }, 0); + } + + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + setFetcher(fetchDocument?: FetchDocument) { + this.fetcher = fetchDocument; + } + + ingest(rawDocs: unknown[] = []): { canonical: T[]; changed: boolean } { + const docs = rawDocs.map((doc) => doc as T).filter(Boolean); + let changed = false; + let nextById = this.byId; + const canonical: T[] = []; + + docs.forEach((doc) => { + const id = doc?.id; + if (id == null) { + canonical.push(doc); + return; + } + + if (this.tagManager && Array.isArray((doc as any).tags)) { + const rawTags = (doc as any).tags as any[]; + const validTags: Tag[] = []; + const tagIds: TagId[] = []; + + rawTags.forEach(tag => { + if (tag.id) { + tagIds.push(tag.id); + validTags.push(tag as Tag); + } + }); + + if (validTags.length > 0) { + this.tagManager.ingest(validTags); + } + + (doc as any).tags = tagIds; + } + + if (this.correspondentManager && Array.isArray((doc as any).correspondents)) { + const rawCorrespondents = (doc as any).correspondents as any[]; + const validCorrespondents: Correspondent[] = []; + const correspondentIds: Identifier[] = []; + + rawCorrespondents.forEach(corr => { + if (corr.id) { + correspondentIds.push(corr.id); + validCorrespondents.push(corr as Correspondent); + } + }); + + if (validCorrespondents.length > 0) { + this.correspondentManager.ingest(validCorrespondents); + } + (doc as any).correspondents = correspondentIds; + } + + const existing = nextById.get(id as DocumentId); + const merged = existing ? ({ ...existing, ...doc } as T) : ({ ...(doc as T) } as T); + const useExisting = existing && shallowEqual(existing, merged); + const nextDoc = useExisting ? (existing as T) : merged; + + if (!useExisting) { + if (!changed) { + nextById = new Map(this.byId); + } + nextById.set(id as DocumentId, nextDoc); + changed = true; + } + canonical.push(nextDoc); + }); + + if (changed) { + this.byId = nextById; + this.emit(); + } + + return { canonical, changed }; + } + + async ensure(id: DocumentId, fetcherOverride?: FetchDocument): Promise<T | null> { + if (id == null) { + return null; + } + + const cached = this.byId.get(id); + if (cached) { + return cached; + } + + const fetcher = fetcherOverride || this.fetcher; + if (!fetcher) { + return null; + } + + const inflight = this.inflight.get(id); + if (inflight) { + return inflight; + } + + const request = (async () => { + try { + const fetched = await fetcher(id); + const { canonical } = this.ingest([fetched as unknown]); + return canonical[0] ?? null; + } finally { + this.inflight.delete(id); + } + })(); + + this.inflight.set(id, request); + return request; + } + + update(id: DocumentId, updater: (doc: T) => Partial<T> | T | undefined): boolean { + const doc = this.byId.get(id); + if (!doc) { + return false; + } + const changes = updater(doc); + if (!changes) { + return false; + } + const { changed } = this.ingest([{ ...doc, ...changes }]); + return changed; + } + + map(mapper: (doc: T) => T | undefined): boolean { + if (!this.byId.size) { + return false; + } + + let changed = false; + const next = new Map<DocumentId, T>(); + this.byId.forEach((doc, key) => { + const updated = mapper(doc); + const nextDoc = updated === undefined ? doc : updated; + if (nextDoc !== doc) { + changed = true; + } + next.set(key, nextDoc ?? doc); + }); + + if (changed) { + this.byId = next; + this.emit(); + } + + return changed; + } + + remove(ids: Array<DocumentId>): boolean { + if (!Array.isArray(ids) || ids.length === 0) { + return false; + } + let changed = false; + let next = this.byId; + ids.forEach((id) => { + if (next.has(id)) { + if (!changed) { + next = new Map(this.byId); + } + next.delete(id); + changed = true; + } + }); + if (changed) { + this.byId = next; + this.emit(); + } + return changed; + } + + getById(id: DocumentId): T | null { + return this.byId.get(id) ?? null; + } + + getMany(ids: Array<DocumentId> = []): T[] { + return ids + .map((id) => this.byId.get(id) || null) + .filter((doc): doc is T => Boolean(doc)); + } + + getSnapshot(): Map<DocumentId, T> { + return this.byId; + } +} + +export default DocumentsManager; diff --git a/frontend/src/documents/DocumentsView.tsx b/frontend/src/documents/DocumentsView.tsx new file mode 100644 index 0000000..e2705a3 --- /dev/null +++ b/frontend/src/documents/DocumentsView.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useCallback } from 'react'; +import { useDocumentViewLogic, DocumentViewLogic } from './logic/useDocumentViewLogic'; +import { useDocumentsNavigation } from './logic/useDocumentsNavigation'; +import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext'; +import { usePanelManager } from '../app/PanelManagerContext'; +import DocumentsListRow from './components/DocumentsListRow'; +import DocumentsGridCard from './components/DocumentsGridCard'; +import DocumentsListContainer from './components/DocumentsListContainer'; +import DocumentsGridContainer from './components/DocumentsGridContainer'; +import type { DocumentsViewProps } from './panel/DocumentsPanel'; +import { useDocumentsViewStateContext } from './context/DocumentsViewStateContext'; +import { useDocumentsCommandContext } from './context/DocumentsCommandContext'; + +interface AbstractDocumentsViewProps<CProps extends { clearSelection: () => void; children: React.ReactNode }> extends DocumentsViewProps { + ContainerComponent: React.ComponentType<CProps>; + ItemComponent: React.ComponentType<{ entry: any; viewLogic: DocumentViewLogic } & DocumentsViewProps>; + containerProps?: Omit<CProps, 'children' | 'clearSelection'>; + [key: string]: any; +} + +const AbstractDocumentsView = <CProps extends { clearSelection: () => void; children: React.ReactNode }>({ + ContainerComponent, + ItemComponent, + containerProps, + ...props +}: AbstractDocumentsViewProps<CProps>) => { + const { entries, viewMode } = props; + const { + viewId, + scrollRef + } = useDocumentsViewStateContext(); + const { + document: { onRename: onDocumentRename }, + folder: { onRename: onFolderRename, onSelect: onFolderSelect } + } = useDocumentsCommandContext(); + + const viewLogic = useDocumentViewLogic({ + onDocumentRename, + onFolderRename, + }); + const { handleKeyDown, handleFocus } = useDocumentsNavigation({ + entries, + onFolderSelect, + viewMode: viewMode || props.viewMode, + scrollRef: scrollRef, + }); + const { clearSelection } = viewLogic; + + useEffect(() => { + if (scrollRef?.current) { + scrollRef.current.scrollTop = 0; + } + }, [scrollRef, viewId]); + + const { focusedEntryKey } = useWorkspaceSelectionContext(); + + const ensureFocusedEntryVisible = useCallback(() => { + if (!focusedEntryKey) return; + const container = scrollRef?.current; + if (!container) return; + let selector = null; + if (focusedEntryKey.startsWith('document:')) { + selector = `#document-${focusedEntryKey.slice('document:'.length)}`; + } else if (focusedEntryKey.startsWith('folder:')) { + selector = `#folder-${focusedEntryKey.slice('folder:'.length)}`; + } + if (!selector) { + return; + } + const entry = container.querySelector(selector) as HTMLElement; + if (!entry || !container.contains(entry)) { + return; + } + + entry.scrollIntoView({ block: 'nearest' }); + }, [focusedEntryKey, scrollRef]); + + useEffect(() => { + ensureFocusedEntryVisible(); + }, [ensureFocusedEntryVisible]); + + const { detailPanelOpen } = usePanelManager(); + + useEffect(() => { + const container = scrollRef?.current; + if (!container) return; + + const handleTransitionEnd = () => { + ensureFocusedEntryVisible(); + }; + + container.addEventListener('transitionend', handleTransitionEnd); + + // Immediate check in case there is no transition or it finished already + ensureFocusedEntryVisible(); + + return () => { + container.removeEventListener('transitionend', handleTransitionEnd); + }; + }, [scrollRef, ensureFocusedEntryVisible, detailPanelOpen]); + + return ( + <ContainerComponent + clearSelection={clearSelection} + onKeyDown={handleKeyDown} + onFocus={handleFocus} + tabIndex={0} + {...(containerProps as any)} + > + {entries.map((entry) => ( + <ItemComponent + key={entry.key} + entry={entry} + viewLogic={viewLogic} + {...props} + /> + ))} + </ContainerComponent> + ); +}; + +export const DocumentsList: React.FC<DocumentsViewProps & { iconSize?: number }> = (props) => { + return ( + <AbstractDocumentsView + ContainerComponent={DocumentsListContainer} + ItemComponent={DocumentsListRow} + viewMode="list" + containerProps={{ iconSize: props.iconSize }} + {...props} + /> + ); +}; + +export const DocumentsGrid: React.FC<DocumentsViewProps & { iconSize?: number }> = (props) => { + return ( + <AbstractDocumentsView + ContainerComponent={DocumentsGridContainer} + ItemComponent={DocumentsGridCard} + containerProps={{ iconSize: props.iconSize }} + viewMode="grid" + {...props} + /> + ); +}; diff --git a/frontend/src/documents/FoldersManager.ts b/frontend/src/documents/FoldersManager.ts new file mode 100644 index 0000000..6901286 --- /dev/null +++ b/frontend/src/documents/FoldersManager.ts @@ -0,0 +1,389 @@ +import { shallowEqual } from 'react-redux'; +import type { FolderNodeId } from '../types/identifiers'; +import type { Folder } from '../types/documents'; +import { createRootNode } from '../app/workspaceUtils'; + +import type { FolderTreeNode, FolderInfo } from '../lib/api/apiTypes'; +import { + createFolder as apiCreateFolder, + deleteFolder as apiDeleteFolder, + moveFolder as apiMoveFolder, + renameFolder as apiRenameFolder, + getFolderTree +} from '../lib/api/apiClient'; +import { flattenFolderTree } from '../app/workspaceUtils'; + +type FetchFolder = (id: FolderNodeId) => Promise<unknown>; + +class FoldersManager { + private byId: Map<FolderNodeId, Folder>; + private fetcher?: FetchFolder; + private inflight: Map<FolderNodeId, Promise<Folder | null>>; + private treePromise: Promise<FolderTreeNode[]> | null = null; + private treeSnapshot: FolderTreeNode[] = []; + private listeners: Set<() => void>; + private emitScheduled: boolean; + + + constructor( + fetchFolder?: FetchFolder, + ) { + this.byId = new Map(); + this.fetcher = fetchFolder; + this.inflight = new Map(); + this.listeners = new Set(); + this.emitScheduled = false; + } + + private emit() { + if (this.emitScheduled) { + return; + } + this.emitScheduled = true; + setTimeout(() => { + this.emitScheduled = false; + this.listeners.forEach((fn) => fn()); + }, 0); + } + + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + setFetcher(fetchFolder?: FetchFolder) { + this.fetcher = fetchFolder; + } + + ingest(rawFolders: unknown[] = []): { canonical: Folder[]; changed: boolean } { + const result = this.ingestInternal(rawFolders); + if (result.changed) { + this.emit(); + } + return result; + } + + private ingestInternal(rawFolders: unknown[] = []): { canonical: Folder[]; changed: boolean } { + const folders = rawFolders.map((f) => f as Folder).filter(Boolean); + let changed = false; + let nextById = this.byId; + const canonical: Folder[] = []; + + folders.forEach((folder) => { + const id = folder?.id; + if (id == null) { + canonical.push(folder); + return; + } + + const existing = nextById.get(id as FolderNodeId); + const merged = existing ? ({ ...existing, ...folder } as Folder) : ({ ...(folder as Folder) } as Folder); + const useExisting = existing && shallowEqual(existing, merged); + const nextFolder = useExisting ? (existing as Folder) : merged; + + if (!useExisting) { + if (!changed) { + nextById = new Map(this.byId); + } + nextById.set(id as FolderNodeId, nextFolder); + changed = true; + } + canonical.push(nextFolder); + }); + + if (changed) { + this.byId = nextById; + } + + return { canonical, changed }; + } + + async ensure(id: FolderNodeId, fetcherOverride?: FetchFolder): Promise<Folder | null> { + if (id == null) { + return null; + } + + const cached = this.byId.get(id); + if (cached) { + return cached; + } + + const fetcher = fetcherOverride || this.fetcher; + if (!fetcher) { + return null; + } + + const inflight = this.inflight.get(id); + if (inflight) { + return inflight; + } + + const request = (async () => { + try { + const fetched = await fetcher(id); + const { canonical } = this.ingest([fetched as unknown]); + return canonical[0] ?? null; + } finally { + this.inflight.delete(id); + } + })(); + + this.inflight.set(id, request); + return request; + } + + map(mapper: (folder: Folder) => Folder | undefined): boolean { + if (!this.byId.size) { + return false; + } + + let changed = false; + const next = new Map<FolderNodeId, Folder>(); + this.byId.forEach((folder, key) => { + const updated = mapper(folder); + const nextFolder = updated === undefined ? folder : updated; + if (nextFolder !== folder) { + changed = true; + } + next.set(key, nextFolder ?? folder); + }); + + if (changed) { + this.byId = next; + this.emit(); + } + + return changed; + } + + remove(ids: Array<FolderNodeId>): boolean { + if (!Array.isArray(ids) || ids.length === 0) { + return false; + } + let changed = false; + let next = this.byId; + ids.forEach((id) => { + if (next.has(id)) { + if (!changed) { + next = new Map(this.byId); + } + next.delete(id); + changed = true; + } + }); + if (changed) { + this.byId = next; + this.emit(); + } + return changed; + } + + async create(name: string, parentId: FolderNodeId | null): Promise<Folder> { + const payload = { + name, + parent_id: parentId === 'root' ? null : parentId + }; + const response = await apiCreateFolder(payload); + const folderData = response.folder as unknown as Folder; + + if (!folderData?.id) { + throw new Error('Folder creation failed: No ID returned'); + } + + this.addNode(folderData); + return folderData; + } + + async delete(id: FolderNodeId): Promise<void> { + await apiDeleteFolder(id); + this.removeNode(id); + } + + async rename(id: FolderNodeId, name: string): Promise<void> { + await apiRenameFolder(id, name); + + // Update local state + const existing = this.byId.get(id); + if (existing) { + this.ingest([{ ...existing, name }]); + } + + // Update tree node + const node = this.findNode(this.treeSnapshot, id); + if (node) { + node.name = name; + this.emit(); + } + } + + async move(id: FolderNodeId, parentId: FolderNodeId | null): Promise<void> { + const targetParentId = parentId === 'root' ? null : parentId; + await apiMoveFolder(id, targetParentId); + + // Update local state 'parent_id' + const existing = this.byId.get(id); + if (existing) { + this.ingest([{ ...existing, parent_id: targetParentId }]); + } + + this.moveNode(id, parentId); + } + + private moveNode(id: FolderNodeId, parentId: FolderNodeId | null) { + const node = this.findNode(this.treeSnapshot, id); + if (!node) return; + + this.removeNodeFromParent(this.treeSnapshot, id); + + node.parent_id = (parentId as string) || null; + + const attachToRoot = !parentId || parentId === 'root'; + if (attachToRoot) { + const root = this.treeSnapshot[0]; + if (root) { + root.children = [...(root.children || []), node]; + root.hasChildren = true; + } + } else { + const newParent = this.findNode(this.treeSnapshot, parentId); + if (newParent) { + newParent.children = [...(newParent.children || []), node]; + newParent.hasChildren = true; + } + } + this.emit(); + } + + private removeNodeFromParent(nodes: FolderTreeNode[], id: FolderNodeId): boolean { + for (const node of nodes) { + if (node.children) { + const idx = node.children.findIndex(c => c.id === id); + if (idx !== -1) { + node.children.splice(idx, 1); + if (node.children.length === 0) { + node.hasChildren = false; + } + return true; + } + if (this.removeNodeFromParent(node.children, id)) { + return true; + } + } + } + return false; + } + + getById(id: FolderNodeId): Folder | null { + return this.byId.get(id) ?? null; + } + + getMany(ids: Array<FolderNodeId> = []): Folder[] { + return ids + .map((id) => this.byId.get(id) || null) + .filter((folder): folder is Folder => Boolean(folder)); + } + + getSnapshot(): Map<FolderNodeId, Folder> { + return this.byId; + } + + getTreeSnapshot(): FolderTreeNode[] { + return this.treeSnapshot; + } + + async ensureTree(): Promise<FolderTreeNode[]> { + if (this.treeSnapshot.length > 0) { + return this.treeSnapshot; + } + + if (this.treePromise) { + return this.treePromise; + } + + this.treePromise = this.fetchTreeInternal(); + return this.treePromise; + } + + async refreshTree(): Promise<FolderTreeNode[]> { + this.treePromise = this.fetchTreeInternal(); + return this.treePromise; + } + + private async fetchTreeInternal(): Promise<FolderTreeNode[]> { + try { + const raw = await getFolderTree(); + const flattened = flattenFolderTree(raw); + this.ingest(flattened); + const rootsPromises = raw as FolderTreeNode[]; + const rootNode = createRootNode() as FolderTreeNode; + + rootNode.children = rootsPromises; + rootNode.hasChildren = rootsPromises.length > 0; + rootNode.loaded = true; + + this.treeSnapshot = [rootNode]; + this.emit(); + return [rootNode]; + } catch (error) { + console.warn('Failed to fetch folder tree', error); + // On error, do not clear existing snapshot if this was a refresh + return this.treeSnapshot.length > 0 ? this.treeSnapshot : []; + } finally { + this.treePromise = null; + } + } + + addNode(folder: Folder) { + this.ingest([folder]); + + const newNode: FolderTreeNode = { + ...(folder as unknown as FolderInfo), + children: [], + hasChildren: false, + loaded: true, + }; + + const parentId = folder.parent_id; + if (!parentId || parentId === 'root') { + const root = this.treeSnapshot[0]; + if (root) { + root.children = [...(root.children || []), newNode]; + root.hasChildren = true; + } + } else { + const parent = this.findNode(this.treeSnapshot, parentId); + if (parent) { + parent.children = [...(parent.children || []), newNode]; + parent.hasChildren = true; + } + } + this.emit(); + } + + removeNode(id: FolderNodeId) { + this.remove([id]); + + // The treeSnapshot usually contains one root node which holds the tree + const changed = this.removeNodeFromParent(this.treeSnapshot, id); + if (changed) { + this.emit(); + } + } + + private findNode(nodes: FolderTreeNode[], id: FolderNodeId): FolderTreeNode | null { + for (const node of nodes) { + if (node.id === id) { + return node; + } + if (node.children) { + const found = this.findNode(node.children, id); + if (found) return found; + } + } + return null; + } + + +} + +export default FoldersManager; diff --git a/frontend/src/documents/components/DocumentDownloadLink.tsx b/frontend/src/documents/components/DocumentDownloadLink.tsx new file mode 100644 index 0000000..048402e --- /dev/null +++ b/frontend/src/documents/components/DocumentDownloadLink.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { DownloadIcon } from '../../components/icons'; +import { resolveDocumentDownloadHref } from '../documentActions'; +import type { Document } from '../../types/documents'; + +interface DocumentDownloadLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> { + document?: Document | null; + children?: React.ReactNode; +} + +const DocumentDownloadLink: React.FC<DocumentDownloadLinkProps> = ({ + document, + children, + className = 'icon-button', + title = 'Download document', + 'aria-label': ariaLabel = 'Download document', + ...rest +}) => { + const downloadUrl = resolveDocumentDownloadHref(document); + + if (!downloadUrl) { + return null; + } + + return ( + <a + href={downloadUrl} + target="_blank" + rel="noopener noreferrer" + className={className} + title={title} + aria-label={ariaLabel} + {...rest} + > + {children || <DownloadIcon />} + </a> + ); +}; + +export default DocumentDownloadLink; diff --git a/frontend/src/documents/components/DocumentEntry.tsx b/frontend/src/documents/components/DocumentEntry.tsx new file mode 100644 index 0000000..a557e9b --- /dev/null +++ b/frontend/src/documents/components/DocumentEntry.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import type { DocumentViewLogic } from '../logic/useDocumentViewLogic'; +import { useDocumentItemLogic } from '../logic/useDocumentItemLogic'; +import EntryShell from './EntryShell'; +import type { TagInteractionHandlers } from '../interactions/useTagInteractions'; + +interface DocumentEntryProps { + doc: any; + tagHandlers?: TagInteractionHandlers; + viewLogic: DocumentViewLogic; + component: React.ElementType; + className?: string; + role?: string; + children: (logic: ReturnType<typeof useDocumentItemLogic>) => React.ReactNode; +} + +const DocumentEntry: React.FC<DocumentEntryProps> = (props) => { + const { doc, tagHandlers, component, className, role, children, viewLogic } = props; + const logic = useDocumentItemLogic({ doc, tagHandlers, viewLogic }); + + return ( + <EntryShell + component={component} + id={`document-${doc.id}`} + docId={doc.id} + handlers={logic.handlers} + isSelected={logic.isSelected} + isDragging={logic.isDraggingDoc} + canDrag={true} + className={className} + role={role} + > + {children(logic)} + </EntryShell> + ); +}; + +export default DocumentEntry; diff --git a/frontend/src/documents/components/DocumentTags.tsx b/frontend/src/documents/components/DocumentTags.tsx new file mode 100644 index 0000000..84b36fd --- /dev/null +++ b/frontend/src/documents/components/DocumentTags.tsx @@ -0,0 +1,79 @@ +import React, { useMemo } from 'react'; +import { getTagColorStyle } from '../../utils/colors'; +import type { Document, Tag } from '../../types/documents'; +import type { Identifier } from '../../types/identifiers'; +import type { TagInteractionHandlers } from '../interactions/useTagInteractions'; + +interface DocumentTagsProps { + tags: Identifier[]; + tagLookupById?: Map<Identifier, Tag> | null; + doc: Document; + tagHandlers?: TagInteractionHandlers; +} + +const DocumentTags: React.FC<DocumentTagsProps> = ({ + tags, + tagLookupById, + doc, + tagHandlers, +}) => { + const resolvedTags = useMemo(() => { + if (!tags) return []; + return tags + .map(id => tagLookupById?.get(id)) + .filter((tag): tag is Tag => Boolean(tag)) + .sort((a, b) => { + const labelA = a.label.toLowerCase(); + const labelB = b.label.toLowerCase(); + return labelA.localeCompare(labelB); + }); + }, [tags, tagLookupById]); + + if (resolvedTags.length === 0) { + return null; + } + + return ( + <> + {resolvedTags.map((tag, index) => { + const { color, label, id } = tag; + const tagId = id; + + const style = getTagColorStyle(color); + const clickable = tagId != null && typeof tagHandlers?.onTagClick === 'function'; + const draggable = !!tagId; + const key = tagId ?? `${doc.id}-tag-${index}`; + + return ( + <span + key={key} + className={`badge tag-chip${draggable ? ' tag-chip--draggable' : ''}${clickable ? ' tag-chip--clickable' : ''}`} + style={style || undefined} + title={label || ''} + role={clickable ? 'button' : undefined} + onClick={clickable ? (event) => { + event.stopPropagation(); + if (tagId == null) return; + tagHandlers?.onTagClick?.(tagId); + } : undefined} + draggable={draggable} + onDragStart={(event) => tagId && tagHandlers?.onTagDragStart(event, doc, tag)} + onDragEnd={tagHandlers?.onTagDragEnd} + onKeyDown={clickable ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + if (tagId == null) return; + tagHandlers?.onTagClick?.(tagId); + } + } : undefined} + > + {label} + </span> + ); + })} + </> + ); +}; + +export default DocumentTags; diff --git a/frontend/src/documents/components/DocumentsGridCard.tsx b/frontend/src/documents/components/DocumentsGridCard.tsx new file mode 100644 index 0000000..cc04579 --- /dev/null +++ b/frontend/src/documents/components/DocumentsGridCard.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { FolderIcon } from '../../components/icons'; +import DocumentThumbnailImage from '../DocumentThumbnailImage'; +import { resolveCorrespondents } from '../correspondents'; +import type { DocumentsListEntry } from '../../types/documents'; +import { useDocumentsAssetContext } from '../context/DocumentsAssetContext'; +import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext'; +import { useDocumentsCommandContext } from '../context/DocumentsCommandContext'; +import type { DocumentViewLogic } from '../logic/useDocumentViewLogic'; +import EditableEntryTitle from './EditableEntryTitle'; +import EntryCorrespondents from './EntryCorrespondents'; +import DocumentTags from './DocumentTags'; +import FolderEntry from './FolderEntry'; +import DocumentEntry from './DocumentEntry'; +import { TagInteractionHandlers } from '../interactions/useTagInteractions'; + +interface DocumentsGridCardProps { + entry: DocumentsListEntry; + viewLogic: DocumentViewLogic; + iconSize?: number; + tagHandlers?: TagInteractionHandlers; +} + +const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => { + const { entry, iconSize, tagHandlers } = props; + const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext(); + const { scrollRef, activeCorrespondentIdSet, tagLookupById, correspondentLookupById } = useDocumentsViewStateContext(); + const { + correspondents: { onClick: onCorrespondentClick }, + } = useDocumentsCommandContext(); + + if (entry.type === 'folder') { + const folder = entry.folder; + if (!folder) return null; + + return ( + <FolderEntry + folder={folder} + viewLogic={props.viewLogic} + component="div" + className="document-card folder-card" + role="listitem" + > + {(logic) => ( + <> + <div className="folder-card__icon"> + <FolderIcon className="folder-card__icon-svg" size={iconSize} /> + </div> + <div className="folder-card__meta"> + <div className="folder-card__label-row"> + <EditableEntryTitle + isEditing={logic.isFolderEditing} + draftValue={logic.folderDraftValue} + onChange={logic.handlers.onRenameChange} + onSubmit={logic.handlers.onRenameSubmit} + onCancel={logic.handlers.onRenameCancel} + isSaving={logic.isFolderSaving} + canSubmit={logic.canSubmitFolder} + inputRef={logic.attachFolderInputRef} + allowInlineEdit={logic.allowInlineFolderEdit} + onBeginEditing={logic.handlers.onRenameBegin} + className="folder-card__name" + > + {folder.name} + </EditableEntryTitle> + </div> + </div> + </> + )} + </FolderEntry> + ); + } + + const doc = entry.document; + if (!doc) return null; + + const correspondents = resolveCorrespondents(doc, correspondentLookupById); + + return ( + <DocumentEntry + doc={doc} + tagHandlers={tagHandlers} + viewLogic={props.viewLogic} + component="div" + className="document-card document" + role="listitem" + > + {(logic) => ( + <> + <DocumentThumbnailImage + document={doc} + ensureAssetUrl={ensureAssetUrl} + getAsset={getDocumentAsset} + alt={`Thumbnail for ${doc.title}`} + maxSize={iconSize} + scrollRootRef={scrollRef} + /> + <div className="document-card__meta"> + <div className="document-card__title" title={doc.title}> + <EntryCorrespondents + correspondents={correspondents} + activeCorrespondentIdSet={activeCorrespondentIdSet} + onCorrespondentClick={onCorrespondentClick} + /> + <div className="document-card__title-row"> + <EditableEntryTitle + isEditing={logic.isEditingDoc} + draftValue={logic.documentDraftValue} + onChange={logic.handlers.onRenameChange} + onSubmit={logic.handlers.onRenameSubmit} + onCancel={logic.handlers.onRenameCancel} + isSaving={logic.isDocumentSaving} + canSubmit={logic.canSubmitDocument} + inputRef={logic.attachDocumentInputRef} + allowInlineEdit={logic.allowInlineDocumentEdit} + onBeginEditing={logic.handlers.onRenameBegin} + className="document-card__title-badge" + > + {doc.title} + </EditableEntryTitle> + </div> + </div> + <div className="document-card__tags"> + <DocumentTags + tags={doc.tags || []} + tagLookupById={tagLookupById} + doc={doc} + tagHandlers={logic.handlers.tagHandlers} + /> + </div> + </div> + </> + )} + </DocumentEntry> + ); +}; + + +export default DocumentsGridCard; diff --git a/frontend/src/documents/components/DocumentsGridContainer.tsx b/frontend/src/documents/components/DocumentsGridContainer.tsx new file mode 100644 index 0000000..8b0b1ea --- /dev/null +++ b/frontend/src/documents/components/DocumentsGridContainer.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +interface DocumentsGridContainerProps { + children: React.ReactNode; + clearSelection: () => void; + iconSize?: number; + [key: string]: any; +} + +const DocumentsGridContainer: React.FC<DocumentsGridContainerProps> = ({ + children, + clearSelection, + iconSize, + ...props +}) => { + return ( + <div + className="documents-grid" + role="list" + {...props} + style={ + iconSize + ? ({ '--documents-grid-icon-size': `${iconSize}px` } as React.CSSProperties) + : undefined + } + onClick={(event) => { + if (event.target === event.currentTarget) { + clearSelection(); + } + }} + > + {children} + </div> + ); +}; + +export default DocumentsGridContainer; diff --git a/frontend/src/documents/components/DocumentsListContainer.tsx b/frontend/src/documents/components/DocumentsListContainer.tsx new file mode 100644 index 0000000..dd9f50b --- /dev/null +++ b/frontend/src/documents/components/DocumentsListContainer.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +interface DocumentsListContainerProps { + children: React.ReactNode; + clearSelection: () => void; + iconSize?: number; + [key: string]: any; +} + +const DocumentsListContainer: React.FC<DocumentsListContainerProps> = ({ + children, + clearSelection, + iconSize: _iconSize, + ...props +}) => { + return ( + <table aria-multiselectable="true" {...props}> + <thead + onClick={() => { + clearSelection(); + }} + > + <tr> + <th> </th> + <th>Name</th> + <th>Issued</th> + <th>Added</th> + </tr> + </thead> + <tbody>{children}</tbody> + </table> + ); +}; + +export default DocumentsListContainer; diff --git a/frontend/src/documents/components/DocumentsListRow.tsx b/frontend/src/documents/components/DocumentsListRow.tsx new file mode 100644 index 0000000..f0f79a2 --- /dev/null +++ b/frontend/src/documents/components/DocumentsListRow.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { FolderIcon } from '../../components/icons'; +import { formatDate } from '../../utils/date'; +import DocumentThumbnailImage from '../DocumentThumbnailImage'; +import { resolveCorrespondents } from '../correspondents'; +import type { DocumentsListEntry } from '../../types/documents'; +import { useDocumentsAssetContext } from '../context/DocumentsAssetContext'; +import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext'; +import { useDocumentsCommandContext } from '../context/DocumentsCommandContext'; +import type { DocumentViewLogic } from '../logic/useDocumentViewLogic'; +import EditableEntryTitle from './EditableEntryTitle'; +import EntryCorrespondents from './EntryCorrespondents'; +import DocumentTags from './DocumentTags'; +import FolderEntry from './FolderEntry'; +import DocumentEntry from './DocumentEntry'; + +import { TagInteractionHandlers } from '../interactions/useTagInteractions'; + +interface DocumentsListRowProps { + entry: DocumentsListEntry; + viewLogic: DocumentViewLogic; + iconSize?: number; + tagHandlers?: TagInteractionHandlers; +} + +const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => { + const { entry, iconSize, tagHandlers } = props; + const { ensureAssetUrl, getDocumentAsset } = useDocumentsAssetContext(); + const { scrollRef, activeCorrespondentIdSet, tagLookupById, correspondentLookupById } = useDocumentsViewStateContext(); + const { + correspondents: { onClick: onCorrespondentClick }, + } = useDocumentsCommandContext(); + + if (entry.type === 'folder') { + const folder = entry.folder; + if (!folder) return null; + + return ( + <FolderEntry + folder={folder} + viewLogic={props.viewLogic} + component="tr" + className="folder" + > + {(logic) => ( + <> + <td className="thumb-cell"> + <div className="thumb-icon"> + <FolderIcon className="thumb-icon__image" size={iconSize || 32} /> + </div> + </td> + <td className="doc-list__name"> + <div className="doc-list__name-content"> + <span className="doc-name__title"> + <span className="doc-name__primary"> + <EditableEntryTitle + isEditing={logic.isFolderEditing} + draftValue={logic.folderDraftValue} + onChange={logic.handlers.onRenameChange} + onSubmit={logic.handlers.onRenameSubmit} + onCancel={logic.handlers.onRenameCancel} + isSaving={logic.isFolderSaving} + canSubmit={logic.canSubmitFolder} + inputRef={logic.attachFolderInputRef} + allowInlineEdit={logic.allowInlineFolderEdit} + onBeginEditing={logic.handlers.onRenameBegin} + className="doc-name__primary-text" + > + {folder.name} + </EditableEntryTitle> + </span> + </span> + </div> + </td> + <td>—</td> + <td>—</td> + </> + )} + </FolderEntry> + ); + } + + const doc = entry.document; + if (!doc) return null; + + const correspondents = resolveCorrespondents(doc, correspondentLookupById); + const issuedLabel = formatDate(doc.issued_at); + const addedLabel = formatDate(doc.created_at || doc.uploaded_at); + + return ( + <DocumentEntry + doc={doc} + tagHandlers={tagHandlers} + viewLogic={props.viewLogic} + component="tr" + className="document" + > + {(logic) => ( + <> + <td className="thumb-cell"> + <DocumentThumbnailImage + document={doc} + ensureAssetUrl={ensureAssetUrl} + getAsset={getDocumentAsset} + alt={`Thumbnail for ${doc.title}`} + scrollRootRef={scrollRef} + maxSize={props.iconSize} + /> + </td> + <td className="doc-list__name"> + <div className="doc-name"> + <div className="doc-list__name-content"> + <span className="doc-name__title"> + <EntryCorrespondents + correspondents={correspondents} + activeCorrespondentIdSet={activeCorrespondentIdSet} + onCorrespondentClick={onCorrespondentClick} + /> + <span className="doc-name__primary"> + <EditableEntryTitle + isEditing={logic.isEditingDoc} + draftValue={logic.documentDraftValue} + onChange={logic.handlers.onRenameChange} + onSubmit={logic.handlers.onRenameSubmit} + onCancel={logic.handlers.onRenameCancel} + isSaving={logic.isDocumentSaving} + canSubmit={logic.canSubmitDocument} + inputRef={logic.attachDocumentInputRef} + allowInlineEdit={logic.allowInlineDocumentEdit} + onBeginEditing={logic.handlers.onRenameBegin} + className="doc-name__primary-text" + > + {doc.title} + </EditableEntryTitle> + </span> + </span> + </div> + <div className="doc-name__tags"> + <DocumentTags + tags={doc.tags || []} + tagLookupById={tagLookupById} + doc={doc} + tagHandlers={logic.handlers.tagHandlers} + /> + </div> + </div> + </td> + <td>{issuedLabel}</td> + <td>{addedLabel}</td> + </> + )} + </DocumentEntry> + ); +}; + + +export default DocumentsListRow; diff --git a/frontend/src/documents/components/EditableEntryTitle.tsx b/frontend/src/documents/components/EditableEntryTitle.tsx new file mode 100644 index 0000000..1ff3e75 --- /dev/null +++ b/frontend/src/documents/components/EditableEntryTitle.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import InlineRenameInput from './InlineRenameInput'; + +interface EditableEntryTitleProps { + isEditing: boolean; + draftValue: string; + onChange: (value: string) => void; + onSubmit: () => void; + onCancel: (event?: React.SyntheticEvent) => void; + isSaving: boolean; + canSubmit: boolean; + inputRef: (ref: HTMLInputElement | null) => void; + allowInlineEdit: boolean | undefined; + onBeginEditing: (event: React.SyntheticEvent) => void; + children: React.ReactNode; + className?: string; +} + +const EditableEntryTitle: React.FC<EditableEntryTitleProps> = ({ + isEditing, + draftValue, + onChange, + onSubmit, + onCancel, + isSaving, + canSubmit, + inputRef, + allowInlineEdit, + onBeginEditing, + children, + className, +}) => { + if (isEditing) { + return ( + <div className={`doc-title-edit ${className || ''}`}> + <InlineRenameInput + value={draftValue} + onChange={onChange} + onSubmit={onSubmit} + onCancel={onCancel} + isSaving={isSaving} + canSubmit={canSubmit} + inputRef={inputRef} + /> + </div> + ); + } + + return ( + <span + className={className} + role={allowInlineEdit ? 'button' : undefined} + tabIndex={allowInlineEdit ? 0 : undefined} + onClick={onBeginEditing} + onKeyDown={(event) => { + if (!allowInlineEdit) return; + if (event.key === 'Enter') { + onBeginEditing(event); + } + }} + > + {children} + </span> + ); +}; + +export default EditableEntryTitle; diff --git a/frontend/src/documents/components/EntryCorrespondents.tsx b/frontend/src/documents/components/EntryCorrespondents.tsx new file mode 100644 index 0000000..77b91bb --- /dev/null +++ b/frontend/src/documents/components/EntryCorrespondents.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import CorrespondentLinks from '../CorrespondentLinks'; +import type { Identifier } from '../../types/identifiers'; + +interface EntryCorrespondentsProps { + correspondents: any[]; + activeCorrespondentIdSet?: Set<Identifier> | null; + onCorrespondentClick?: (correspondentId: Identifier) => void; +} + +const EntryCorrespondents: React.FC<EntryCorrespondentsProps> = (props) => { + if (!props.correspondents || props.correspondents.length === 0) { + return null; + } + + return ( + <span className="doc-correspondents"> + <CorrespondentLinks + correspondents={props.correspondents} + activeCorrespondentIdSet={props.activeCorrespondentIdSet} + onCorrespondentClick={props.onCorrespondentClick} + /> + </span> + ); +}; + +export default EntryCorrespondents; diff --git a/frontend/src/documents/components/EntryShell.tsx b/frontend/src/documents/components/EntryShell.tsx new file mode 100644 index 0000000..d5f4cf5 --- /dev/null +++ b/frontend/src/documents/components/EntryShell.tsx @@ -0,0 +1,66 @@ +import React, { type DragEvent } from 'react'; + +interface EntryShellHandlers { + onClick: (event: React.MouseEvent) => void; + onDoubleClick: (event: React.MouseEvent) => void; + onDragStart: (event: DragEvent<HTMLElement>) => void; + onDragEnd: (event: DragEvent<HTMLElement>) => void; + onDragOver: (event: DragEvent<HTMLElement>) => void; + onDragLeave: (event: DragEvent<HTMLElement>) => void; + onDrop: (event: DragEvent<HTMLElement>) => void; + onDragOverCapture?: (event: DragEvent<HTMLElement>) => void; + onDragLeaveCapture?: (event: DragEvent<HTMLElement>) => void; +} + +interface EntryShellProps { + component: React.ElementType; + handlers: EntryShellHandlers; + isSelected?: boolean; + isDragging?: boolean; + canDrag?: boolean; + id: string; + className?: string; + children: React.ReactNode; + docId?: number; + role?: string; +} + +const EntryShell: React.FC<EntryShellProps> = ({ + component: Component, + handlers, + isSelected, + isDragging, + canDrag, + id, + className = '', + children, + docId, + role, +}) => { + const classes = [className]; + if (isSelected) classes.push('selected'); + if (isDragging) classes.push('is-dragging'); + + const commonProps = { + id, + className: classes.join(' '), + onClick: handlers.onClick, + onDoubleClick: handlers.onDoubleClick, + draggable: canDrag, + onDragStart: handlers.onDragStart, + onDragEnd: handlers.onDragEnd, + onDragOver: handlers.onDragOver, + onDragLeave: handlers.onDragLeave, + onDrop: handlers.onDrop, + ...(docId ? { 'data-doc-id': docId } : {}), + ...(role ? { role } : {}), + }; + + return ( + <Component {...commonProps}> + {children} + </Component> + ); +}; + +export default EntryShell; diff --git a/frontend/src/documents/components/FolderEntry.tsx b/frontend/src/documents/components/FolderEntry.tsx new file mode 100644 index 0000000..e092aef --- /dev/null +++ b/frontend/src/documents/components/FolderEntry.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { DocumentViewLogic } from '../logic/useDocumentViewLogic'; +import { useFolderItemLogic } from '../features/folders/useFolderItemLogic'; +import EntryShell from './EntryShell'; + +interface FolderEntryProps { + folder: any; + viewLogic: DocumentViewLogic; + component: React.ElementType; + className?: string; + role?: string; + children: (logic: ReturnType<typeof useFolderItemLogic>) => React.ReactNode; +} + +const FolderEntry: React.FC<FolderEntryProps> = (props) => { + const { folder, component, className, role, children, viewLogic } = props; + const logic = useFolderItemLogic({ folder, viewLogic }); + + return ( + <EntryShell + component={component} + id={`folder-${folder.id}`} + handlers={logic.handlers} + isSelected={logic.isSelectedFolder} + isDragging={logic.isDraggingFolder} + canDrag={logic.canDragFolder} + className={className} + role={role} + > + {children(logic)} + </EntryShell> + ); +}; + +export default FolderEntry; diff --git a/frontend/src/documents/components/InlineRenameInput.tsx b/frontend/src/documents/components/InlineRenameInput.tsx new file mode 100644 index 0000000..3ca74e2 --- /dev/null +++ b/frontend/src/documents/components/InlineRenameInput.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { CheckIcon, CloseIcon } from '../../components/icons'; + +interface InlineRenameInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'onSubmit' | 'value'> { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + onCancel: (event?: React.SyntheticEvent) => void; + isSaving?: boolean; + canSubmit?: boolean; + inputRef?: React.Ref<HTMLInputElement>; + className?: string; +} + +const InlineRenameInput: React.FC<InlineRenameInputProps> = ({ + value, + onChange, + onSubmit, + onCancel, + isSaving = false, + canSubmit = true, + inputRef, + className = 'doc-title-edit', + type = 'text', + ...props +}) => { + return ( + <span className={className}> + <input + type={type} + ref={inputRef} + value={value} + onChange={(event) => onChange(event.target.value)} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + onSubmit(); + } else if (event.key === 'Escape') { + event.preventDefault(); + onCancel(event); + } + }} + onBlur={(event) => { + const nextFocus = event.relatedTarget; + if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) { + onCancel(); + } + }} + {...props} + /> + <button + type="button" + className="icon-button" + aria-label="Save" + title="Save" + disabled={!canSubmit || isSaving} + onClick={(event) => { + event.stopPropagation(); + onSubmit(); + }} + > + <CheckIcon /> + </button> + <button + type="button" + className="icon-button" + aria-label="Cancel" + title="Cancel" + onClick={(event) => { + onCancel(event); + }} + > + <CloseIcon /> + </button> + </span> + ); +}; + +export default InlineRenameInput; diff --git a/frontend/src/documents/components/TagRemovalZone.css b/frontend/src/documents/components/TagRemovalZone.css new file mode 100644 index 0000000..e1b5cc7 --- /dev/null +++ b/frontend/src/documents/components/TagRemovalZone.css @@ -0,0 +1,31 @@ +.tag-removal-zone { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 4rem; + background-color: var(--surface-subtle); + border-top: 2px dashed var(--border); + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + z-index: 6000002; + transition: all 0.2s ease; + pointer-events: all; + gap: 0.5rem; +} + +.tag-removal-zone--drag-over { + height: 6rem; + background: linear-gradient(var(--surface-danger-subtle), var(--surface-danger-subtle)), var(--surface); + border-color: var(--danger); + color: var(--danger); +} + +/* Ensure icon inherits color and overrides .icon class size */ +.tag-removal-zone__icon { + width: 60%; + height: 60%; + stroke: currentColor; +} \ No newline at end of file diff --git a/frontend/src/documents/components/TagRemovalZone.tsx b/frontend/src/documents/components/TagRemovalZone.tsx new file mode 100644 index 0000000..578f20e --- /dev/null +++ b/frontend/src/documents/components/TagRemovalZone.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useState } from 'react'; +import { subscribeToTagDrag } from '../features/tagging/tagTransfer'; +import { TrashIcon } from '../../components/icons'; +import './TagRemovalZone.css'; + +const TagRemovalZone: React.FC = () => { + const [isVisible, setIsVisible] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const [isInteractive, setIsInteractive] = useState(false); + + useEffect(() => { + // Subscribe to global tag drag state. + // This avoids issues with event bubbling (stopPropagation) preventing window listeners. + return subscribeToTagDrag((state) => { + if (state.sourceDocId) { + setIsVisible(true); + // Delay interactivity to allow drag to start without immediate capture + // and to allow dropping on documents 'behind' the zone if done quickly + setTimeout(() => setIsInteractive(true), 200); + } else { + setIsVisible(false); + setIsDragOver(false); + setIsInteractive(false); + } + }); + }, []); + + const onDragOver = (event: React.DragEvent) => { + if (!isVisible || !isInteractive) return; + event.preventDefault(); + event.stopPropagation(); // Exclusive zone + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + setIsDragOver(true); + }; + + const onDragLeave = () => { + setIsDragOver(false); + }; + + const onDrop = (event: React.DragEvent) => { + if (!isInteractive) return; + event.preventDefault(); + event.stopPropagation(); + // Drop accepted. Browser sets dropEffect='move'. + // Source component's onDragEnd will handle the data removal. + setIsVisible(false); + setIsDragOver(false); + setIsInteractive(false); + }; + + if (!isVisible) { + return null; + } + + return ( + <div + className={`tag-removal-zone ${isDragOver ? 'tag-removal-zone--drag-over' : ''}`} + onDragOver={onDragOver} + onDragLeave={onDragLeave} + onDrop={onDrop} + style={{ pointerEvents: isInteractive ? 'all' : 'none' }} + > + <TrashIcon className="tag-removal-zone__icon" /> + </div> + ); +}; + +export default TagRemovalZone; diff --git a/frontend/src/documents/context/DocumentsAssetContext.tsx b/frontend/src/documents/context/DocumentsAssetContext.tsx new file mode 100644 index 0000000..3afcba9 --- /dev/null +++ b/frontend/src/documents/context/DocumentsAssetContext.tsx @@ -0,0 +1,10 @@ +import { createContext, useContext } from 'react'; + +interface DocumentsAssetContextValue { + ensureAssetUrl?: (...args: any[]) => unknown; + getDocumentAsset?: (...args: any[]) => unknown; +} + +export const DocumentsAssetContext = createContext<DocumentsAssetContextValue>({}); + +export const useDocumentsAssetContext = () => useContext(DocumentsAssetContext); diff --git a/frontend/src/documents/context/DocumentsCommandContext.tsx b/frontend/src/documents/context/DocumentsCommandContext.tsx new file mode 100644 index 0000000..62c67f8 --- /dev/null +++ b/frontend/src/documents/context/DocumentsCommandContext.tsx @@ -0,0 +1,38 @@ +import React, { createContext, useContext, type DragEvent } from 'react'; +import type { Document } from '../../types/documents'; +import type { Identifier } from '../../types/identifiers'; + +interface DocumentsCommandContextValue { + folder: { + onClick?: (folder: any, event: React.MouseEvent) => void; + onSelect?: (folderId: Identifier | 'root') => void; + onRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean; + onDrag: { + start?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void; + end?: (event: DragEvent<HTMLElement>) => void; + over?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void; + leave?: (event: DragEvent<HTMLElement>) => void; + drop?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void; + }; + }; + document: { + onRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean; + onDrag: { + start?: (event: DragEvent<HTMLElement>, document: Document) => void; + end?: (event: DragEvent<HTMLElement>) => void; + }; + }; + correspondents: { + onClick?: (correspondentId: Identifier) => void; + }; + // General entry pointer for selection/etc + onEntryPointer?: (entry: any, event: any) => void; +} + +export const DocumentsCommandContext = createContext<DocumentsCommandContextValue>({ + folder: { onDrag: {} }, + document: { onDrag: {} }, + correspondents: {}, +}); + +export const useDocumentsCommandContext = () => useContext(DocumentsCommandContext); diff --git a/frontend/src/documents/context/DocumentsFilterContext.tsx b/frontend/src/documents/context/DocumentsFilterContext.tsx new file mode 100644 index 0000000..524f8b7 --- /dev/null +++ b/frontend/src/documents/context/DocumentsFilterContext.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import type { Identifier } from '../../types/identifiers'; +import { createSafeContext } from '../../utils/createSafeContext'; + +export interface DocumentsFilterValue { + query: string; + searchResultIds: Array<string> | null; + searchLoading: boolean; + includeDescendants: boolean; + activeTagIds: Identifier[]; + activeCorrespondentIds: Identifier[]; + isActive: boolean; + setQuery: (value: string) => void; + submit: () => void; + clear: () => void; + toggleTag: (tagId: Identifier) => void; + toggleCorrespondent: (correspondentId?: Identifier | null) => void; + toggleIncludeDescendants: () => void; +} + +const [DocumentsFilterContext, useDocumentsFilter] = createSafeContext<DocumentsFilterValue>('DocumentsFilter'); + +interface DocumentsFilterProviderProps { + value: DocumentsFilterValue; + children: React.ReactNode; +} + +export const DocumentsFilterProvider: React.FC<DocumentsFilterProviderProps> = ({ value, children }) => ( + <DocumentsFilterContext.Provider value={value}>{children}</DocumentsFilterContext.Provider> +); + +export { useDocumentsFilter }; diff --git a/frontend/src/documents/context/DocumentsViewStateContext.tsx b/frontend/src/documents/context/DocumentsViewStateContext.tsx new file mode 100644 index 0000000..e9faefd --- /dev/null +++ b/frontend/src/documents/context/DocumentsViewStateContext.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext, type RefObject } from 'react'; +import type { Tag, Correspondent } from '../../types/documents'; +import type { Identifier } from '../../types/identifiers'; + +interface DocumentsViewStateContextValue { + viewId?: string | null; + scrollRef?: RefObject<HTMLElement | null>; + tagLookupById?: Map<Identifier, Tag> | null; + correspondentLookupById?: Map<Identifier, Correspondent> | null; + activeCorrespondentIdSet?: Set<Identifier> | null; + draggingDocumentIdsSet?: Set<Identifier> | null; + draggedFolderId?: Identifier | 'root' | null; +} + +export const DocumentsViewStateContext = createContext<DocumentsViewStateContextValue>({}); + +export const useDocumentsViewStateContext = () => useContext(DocumentsViewStateContext); diff --git a/frontend/src/documents/correspondents.ts b/frontend/src/documents/correspondents.ts new file mode 100644 index 0000000..297a879 --- /dev/null +++ b/frontend/src/documents/correspondents.ts @@ -0,0 +1,27 @@ +import type { Identifier } from '../types/identifiers'; +import type { Document, Correspondent } from '../types/documents'; + +export const resolveCorrespondents = ( + doc?: Document | null, + lookup?: Map<Identifier, Correspondent> | null +): Correspondent[] => { + if (!doc || !Array.isArray(doc.correspondents)) { + return []; + } + + const seen = new Set<Identifier>(); + const results: Correspondent[] = []; + + doc.correspondents.forEach((id) => { + if (!id) return; + if (seen.has(id)) return; + seen.add(id); + + const resolved = lookup?.get(id); + if (resolved) { + results.push(resolved); + } + }); + + return results.sort((a, b) => (a.name || '').localeCompare(b.name || '')); +}; diff --git a/frontend/src/documents/data/useAuthManager.ts b/frontend/src/documents/data/useAuthManager.ts new file mode 100644 index 0000000..a87667c --- /dev/null +++ b/frontend/src/documents/data/useAuthManager.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { MutableRefObject } from 'react'; +import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/api/apiClient'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; + +import { useAppDispatch, useAppState } from '../../lib/store/appState'; + +interface UseAuthManagerArgs { } + +interface UseAuthManagerResult { + tokenRef: MutableRefObject<string | null>; + refreshAccessToken: () => Promise<string>; + handleLogout: () => Promise<void>; +} + +const useAuthManager = (_: UseAuthManagerArgs = {}): UseAuthManagerResult => { + const { token, status: appStatus } = useAppState(); + const appDispatch = useAppDispatch(); + const tokenRef = useRef<string | null>(token); + const initialRefreshAttemptedRef = useRef(Boolean(token)); + const { showToast } = useStatusToast(); + + const refreshAccessToken = useCallback(async (): Promise<string> => { + console.log('[Auth] Attempting to refresh access token…'); + appDispatch({ type: 'TOKEN_REFRESH_START' }); + try { + const data = await refreshSession(); + if (data?.access_token) { + setAuthToken(data.access_token); + appDispatch({ + type: 'TOKEN_REFRESH_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); + console.log('[Auth] Access token refreshed at', new Date().toISOString()); + return data.access_token; + } + throw new Error('Missing access token in refresh response'); + } catch (error) { + console.warn('[Auth] Failed to refresh access token', error); + appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null }); + throw error; + } + }, [appDispatch]); + + useEffect(() => { + tokenRef.current = token; + }, [token]); + + useEffect(() => { + if (!token && !initialRefreshAttemptedRef.current && appStatus === 'logged-out') { + initialRefreshAttemptedRef.current = true; + console.log('[Auth] Attempting refresh at startup'); + refreshAccessToken().catch(() => { }); + } + }, [token, appStatus, refreshAccessToken]); + + const handleLogout = useCallback(async () => { + try { + await logoutSession(); + } catch (error) { + console.warn('[Auth] Failed to revoke refresh token during logout', error); + } finally { + clearAuthToken(); + appDispatch({ type: 'LOGOUT' }); + showToast('Logged out.', 'info'); + } + }, [appDispatch, showToast]); + + return { tokenRef, refreshAccessToken, handleLogout }; +}; + +export default useAuthManager; diff --git a/frontend/src/documents/data/useBulkDocumentActions.ts b/frontend/src/documents/data/useBulkDocumentActions.ts new file mode 100644 index 0000000..06b050e --- /dev/null +++ b/frontend/src/documents/data/useBulkDocumentActions.ts @@ -0,0 +1,98 @@ +import { useCallback } from 'react'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import type { Identifier } from '../../types/identifiers'; +import type { MessageOptions } from '../../types/documents'; + + +interface UseBulkDocumentActionsArgs { + selectedDocumentIds?: Identifier[]; + selectedFolderIds?: Identifier[]; + handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise<boolean>; + handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise<boolean>; + clearDocumentSelection: () => void; +} + +const useBulkDocumentActions = ({ + selectedDocumentIds, + selectedFolderIds, + handleDocumentsDelete, + handleFolderDelete, + clearDocumentSelection, +}: UseBulkDocumentActionsArgs) => { + const { showToast } = useStatusToast(); + + /* + * Bulk Deletion Logic (Handles both Documents and Folders) + * Moved other bulk actions to useDocumentMutations to resolve circular dependencies. + */ + const handleDeleteSelection = useCallback(async () => { + const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; + const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : []; + + if (docIds.length === 0 && folderIds.length === 0) { + return; + } + + const parts = []; + if (docIds.length) { + parts.push(`${docIds.length} document${docIds.length === 1 ? '' : 's'}`); + } + if (folderIds.length) { + parts.push(`${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}`); + } + const descriptor = parts.join(' and '); + const confirmation = parts.length === 1 + ? `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.` + : `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`; + + if (!window.confirm(confirmation)) { + return; + } + + let docsOk = true; + let foldersOk = true; + + if (docIds.length) { + docsOk = await handleDocumentsDelete(docIds, { showMessage: false }); + } + + if (folderIds.length) { + for (const folderId of folderIds) { + const success = await handleFolderDelete(folderId, { showMessage: false }); + if (!success) { + foldersOk = false; + } + } + } + + if (!docsOk || !foldersOk) { + showToast('Some items could not be deleted. Ensure folders are empty before deletion.', 'error'); + return; + } + + clearDocumentSelection(); + + const successParts = []; + if (docIds.length) { + successParts.push(docIds.length === 1 ? 'Document deleted.' : 'Documents deleted.'); + } + if (folderIds.length) { + successParts.push(folderIds.length === 1 ? 'Folder deleted.' : 'Folders deleted.'); + } + + showToast(successParts.join(' '), 'success'); + }, [ + clearDocumentSelection, + handleDocumentsDelete, + handleFolderDelete, + selectedDocumentIds, + selectedFolderIds, + showToast, + ]); + + return { + handleDeleteSelection, + }; +}; + +export default useBulkDocumentActions; diff --git a/frontend/src/documents/data/useCorrespondents.ts b/frontend/src/documents/data/useCorrespondents.ts new file mode 100644 index 0000000..89d4bcd --- /dev/null +++ b/frontend/src/documents/data/useCorrespondents.ts @@ -0,0 +1,139 @@ +import { useCallback, useSyncExternalStore, useMemo } from 'react'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import type { Correspondent } from '../../types/documents'; +import type { Identifier } from '../../types/identifiers'; +import type CorrespondentManager from '../../lib/assets/CorrespondentManager'; + +import useNotifyApiError from '../../hooks/useNotifyApiError'; + +interface UseCorrespondentsOptions { + correspondentManager: CorrespondentManager; + documentsManager?: { map: (mapper: (doc: any) => any) => void }; +} + +const useCorrespondents = ({ + correspondentManager, + documentsManager, +}: UseCorrespondentsOptions) => { + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + + const correspondentsSnapshot = useSyncExternalStore<Map<Identifier, Correspondent>>( + useCallback((cb) => correspondentManager.subscribe(cb), [correspondentManager]), + () => correspondentManager.getSnapshot(), + () => correspondentManager.getSnapshot(), + ); + + const correspondents = Array.from(correspondentsSnapshot.values()) + .filter((corr): corr is Correspondent => (corr as any).id != null && (corr as any).name != null) + .sort((a, b) => (a.name || '').localeCompare(b.name || '')); + + const refreshCorrespondents = useCallback(async () => { + try { + await correspondentManager.ensureAll(true); + } catch (error) { + notifyApiError(error, 'Unable to load correspondents.'); + } + }, [notifyApiError, correspondentManager]); + + const handleCorrespondentUpdate = useCallback( + async (correspondentId: Identifier, changes: { name?: string }) => { + if (correspondentId == null) { + throw new Error('Missing correspondent identifier.'); + } + + const payload: Record<string, unknown> = {}; + if (changes?.name != null) { + payload.name = changes.name; + } + + if (Object.keys(payload).length === 0) { + return false; + } + + try { + await correspondentManager.update(correspondentId, payload); + showToast('Correspondent updated.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to update correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [notifyApiError, correspondentManager, showToast], + ); + + const handleCorrespondentCreate = useCallback( + async ({ name }: { name?: string }) => { + try { + const payload = correspondentManager.buildPayload({ name }); + const data = await correspondentManager.create(payload); + showToast('Correspondent created.', 'success'); + return data; + } catch (error) { + const message = error.response?.data?.error || 'Failed to create correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [notifyApiError, correspondentManager, showToast], + ); + + const handleCorrespondentDelete = useCallback( + async (correspondentId: Identifier) => { + if (correspondentId == null) { + throw new Error('Missing correspondent identifier.'); + } + + try { + await correspondentManager.delete(correspondentId); + + const stripFromDoc = (doc: any) => { + if (!doc || !Array.isArray(doc.correspondents)) { + return doc; + } + // doc.correspondents is allowed to be Identifier[] now + const next = doc.correspondents.filter((id: Identifier) => id !== correspondentId); + if (next.length === doc.correspondents.length) { + return doc; + } + return { ...doc, correspondents: next }; + }; + + documentsManager?.map(stripFromDoc); + + showToast('Correspondent deleted.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [documentsManager, notifyApiError, correspondentManager, showToast], + ); + + const correspondentLookupByName = useMemo(() => { + const map = new Map<string, Correspondent>(); + for (const correspondent of correspondents) { + if (correspondent.name) { + map.set(correspondent.name.toLowerCase(), correspondent); + } + } + return map; + }, [correspondents]); + + return { + correspondents, + correspondentLookupById: correspondentsSnapshot, + correspondentLookupByName, + refreshCorrespondents, + handleCorrespondentCreate, + handleCorrespondentUpdate, + handleCorrespondentDelete, + correspondentManager, + }; +}; + +export default useCorrespondents; diff --git a/frontend/src/documents/data/useDocumentCorrespondentMutations.ts b/frontend/src/documents/data/useDocumentCorrespondentMutations.ts new file mode 100644 index 0000000..e32a5ac --- /dev/null +++ b/frontend/src/documents/data/useDocumentCorrespondentMutations.ts @@ -0,0 +1,190 @@ +import { useCallback } from 'react'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import type { Identifier } from '../../types/identifiers'; +import type { Correspondent } from '../../types/documents'; + +import { addDocumentCorrespondent, removeDocumentCorrespondent } from '../../lib/api/apiClient'; + +import useNotifyApiError from '../../hooks/useNotifyApiError'; +import type { CorrespondentsState, DocumentsState } from '../types/workspaceTypes'; + +interface UseDocumentCorrespondentMutationsArgs { + correspondentsState: CorrespondentsState; + documentsState: Pick<DocumentsState, 'documentsManager'>; +} + +const useDocumentCorrespondentMutations = ({ + correspondentsState, + documentsState, +}: UseDocumentCorrespondentMutationsArgs) => { + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + + const { + correspondentManager, + correspondentLookupByName, + } = correspondentsState; + + const { documentsManager } = documentsState; + + const handleDocumentCorrespondentAttach = useCallback( + async ( + { + documentId, + correspondentId, + }: { documentId: Identifier; correspondentId: Identifier; correspondent?: Correspondent | Partial<Correspondent> | null }, + { notify = true }: { notify?: boolean } = {}, + ) => { + if (documentId == null || correspondentId == null) { + throw new Error('Missing document or correspondent.'); + } + try { + await addDocumentCorrespondent(documentId, correspondentId); + + documentsManager.map((doc) => { + if (doc.id !== documentId) return undefined; + + const current = Array.isArray(doc.correspondents) ? doc.correspondents : []; + if (current.includes(correspondentId)) { + return doc; + } + return { ...doc, correspondents: [...current, correspondentId] }; + }); + + if (notify) { + showToast('Correspondent assigned.', 'success'); + } + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to assign correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [notifyApiError, showToast, documentsManager], + ); + + const handleDocumentCorrespondentDetach = useCallback( + async ( + { documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier }, + { notify = true }: { notify?: boolean } = {}, + ) => { + if (documentId == null || correspondentId == null) { + throw new Error('Missing document or correspondent.'); + } + try { + await removeDocumentCorrespondent(documentId, correspondentId); + + documentsManager.map((doc) => { + if (doc.id !== documentId) return undefined; + if (!doc || !Array.isArray(doc.correspondents)) { + return doc; + } + + const filtered = doc.correspondents.filter((id) => id !== correspondentId); + return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered }; + }); + + if (notify) { + showToast('Correspondent removed.', 'success'); + } + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to remove correspondent.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [notifyApiError, showToast, documentsManager], + ); + + const normalizeOption = ( + option: Correspondent | Partial<Correspondent> | string | null, + ): Correspondent | Partial<Correspondent> | null => { + if (!option) { + return null; + } + if (typeof option === 'string') { + const trimmed = option.trim(); + if (trimmed) { + return { id: null, name: trimmed }; + } + return null; + } + return option; + }; + + const handleCorrespondentCreate = useCallback( + async ({ name }: { name: string }) => { + const payload = correspondentManager.buildPayload({ name }); + const data = await correspondentManager.create(payload); + + return data; + }, + [correspondentManager] + ); + + const handleDocumentCorrespondentAdd = useCallback( + async ({ document, name, input = null, option = null }: { document?: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: Correspondent | Partial<Correspondent> | string | null }) => { + if (!document?.id) { + throw new Error('Missing document for correspondent assignment.'); + } + const trimmed = name?.trim?.() || ''; + if (!trimmed) { + showToast('Correspondent name is required.', 'error'); + return; + } + + let target = correspondentLookupByName.get(trimmed.toLowerCase()) || normalizeOption(option); + if (!target) { + try { + target = await handleCorrespondentCreate({ name: trimmed }); + // Force refresh or ingest? + if (target) { + const asCorr = target as Correspondent; + if (asCorr.id) { + // Creating often yields an object we can use immediately + } + } + } catch { + showToast('Failed to create correspondent.', 'error'); + return; + } + } + + if (!target?.id) { + showToast('Unable to resolve correspondent.', 'error'); + return; + } + + try { + await handleDocumentCorrespondentAttach({ + documentId: document.id, + correspondentId: target.id, + correspondent: target.name ? target : { ...target, name: trimmed }, + }); + if (input) { + input.value = ''; + } + } catch (error) { + showToast('Failed to assign correspondent.', 'error'); + console.error('[documents] assign correspondent failed', error); + } + }, + [ + correspondentLookupByName, + handleCorrespondentCreate, + handleDocumentCorrespondentAttach, + showToast, + ], + ); + + return { + correspondentLookupByName, + handleDocumentCorrespondentAttach, + handleDocumentCorrespondentDetach, // Renamed from handleCorrespondentRemove + handleDocumentCorrespondentAdd, // Renamed from handleCorrespondentAdd + }; +}; + +export default useDocumentCorrespondentMutations; diff --git a/frontend/src/documents/data/useDocumentMoveMutations.ts b/frontend/src/documents/data/useDocumentMoveMutations.ts new file mode 100644 index 0000000..27f9b39 --- /dev/null +++ b/frontend/src/documents/data/useDocumentMoveMutations.ts @@ -0,0 +1,177 @@ +import { useCallback } from 'react'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import useNotifyApiError from '../../hooks/useNotifyApiError'; +import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils'; +import { getEntryId, isDocumentEntry } from '../../app/entryKey'; +import { + moveDocumentsBulk, + moveDocumentToFolder, + listFolderContents, +} from '../../lib/api/apiClient'; +import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; +import type { + DocumentsState, + FolderState, + SelectionState, +} from '../types/workspaceTypes'; + +type FolderId = FolderIdentifier | 'root'; +type NullableFolderId = FolderId | null; + +interface UseDocumentMoveMutationsArgs { + documentsState: DocumentsState; + folderState: FolderState; + selectionState: SelectionState; +} + +export const useDocumentMoveMutations = ({ + documentsState, + folderState, + selectionState, +}: UseDocumentMoveMutationsArgs) => { + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + + const normalizeDocumentId = (value: unknown): DocumentId | null => { + if (!value) return null; + if (value && typeof value === 'object' && 'id' in value && value.id != null) { + return value.id as DocumentId; + } + return value as DocumentId; + }; + + const moveDocumentsToFolder = useCallback( + async (documentIds: Array<DocumentId | Document>, targetFolderId?: NullableFolderId) => { + const uniqueIds = Array.from( + new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]), + ); + if (!uniqueIds.length) return; + + const uniqueIdSet = new Set(uniqueIds); + const target = targetFolderId === 'root' ? null : targetFolderId ?? null; + const targetLabel = + target === null ? DEFAULT_FOLDER_NAME : folderState.folderLabelMap.get(targetFolderId as FolderId) || 'target folder'; + + const movedDocs = uniqueIds + .map((id) => { + const doc = documentsState.documentLookup.get(id) || null; + if (!doc) { + return null; + } + return { + id, + sourceFolderId: (doc.folder_id ?? null) as NullableFolderId, + document: doc, + }; + }) + .filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: Document }>; + + const updatedDocsMap = new Map<DocumentId, Document>(); + const resolveTargetName = () => { + if (!targetLabel) { + return null; + } + const segments = String(targetLabel).split('/'); + return segments[segments.length - 1] || targetLabel; + }; + const targetName = resolveTargetName(); + + movedDocs.forEach(({ id, document }) => { + if (!document) { + return; + } + const updated: Document = { + ...document, + folder_id: target, + }; + if (targetLabel) { + updated.folder_path = targetLabel; + if (targetName) { + updated.folder_name = targetName; + } + } else if (target === null) { + updated.folder_path = DEFAULT_FOLDER_NAME; + updated.folder_name = DEFAULT_FOLDER_NAME; + } + updatedDocsMap.set(id, updated); + }); + + + try { + if (uniqueIds.length === 1) { + await moveDocumentToFolder(uniqueIds[0], target); + } else { + await moveDocumentsBulk(uniqueIds, target); + } + + const count = uniqueIds.length; + const suffix = count === 1 ? '' : 's'; + showToast(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success'); + + if (updatedDocsMap.size) { + documentsState.documentsManager.map((doc) => { + if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { + return undefined; + } + const updated = updatedDocsMap.get(doc.id as DocumentId); + if (updated) { + return updated; + } + return doc; + }); + } + + if (uniqueIdSet.size) { + const pruneRow = (rows: string[]) => rows.filter(id => !uniqueIdSet.has(getEntryId(id) as DocumentId)); + const { selectionOrderRef, selectionAnchorRef, setSelectionOrder, setFocusedDocumentId, setFocusedEntryKey, setSelectedEntries } = selectionState; + + setSelectedEntries((prev) => pruneRow(prev)); + setSelectionOrder((prev) => pruneRow(prev)); + + const nextSelectionOrder = pruneRow(selectionOrderRef.current || []); + selectionOrderRef.current = nextSelectionOrder; + + if ( + selectionAnchorRef.current && + isDocumentEntry(selectionAnchorRef.current) && + uniqueIdSet.has(getEntryId(selectionAnchorRef.current) as DocumentId) + ) { + selectionAnchorRef.current = null; + } + if ( + selectionState.focusedDocumentId && + uniqueIdSet.has(selectionState.focusedDocumentId) + ) { + setFocusedDocumentId(null); + } + if ( + selectionState.focusedEntryKey && + isDocumentEntry(selectionState.focusedEntryKey) && + uniqueIdSet.has(getEntryId(selectionState.focusedEntryKey) as DocumentId) + ) { + setFocusedEntryKey(null); + } + } + + if (targetFolderId && targetFolderId !== folderState.selectedFolder) { + await listFolderContents(targetFolderId as FolderId); + } + + } catch (error) { + const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.'; + notifyApiError(error, message); + } + }, + [ + documentsState, + folderState.folderLabelMap, + folderState.selectedFolder, + selectionState, + notifyApiError, + showToast, + ], + ); + + return { moveDocumentsToFolder }; +}; diff --git a/frontend/src/documents/data/useDocumentMutations.ts b/frontend/src/documents/data/useDocumentMutations.ts new file mode 100644 index 0000000..9d13607 --- /dev/null +++ b/frontend/src/documents/data/useDocumentMutations.ts @@ -0,0 +1,475 @@ +import { useCallback } from 'react'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import useNotifyApiError from '../../hooks/useNotifyApiError'; + +import { + queueDocumentReanalysis, + trashDocument, + updateDocument, + createTag, + bulkTagDocuments, + bulkReanalyzeDocuments, + assignCorrespondentsBulk, +} from '../../lib/api/apiClient'; +import type { DocumentId, FolderId as FolderIdentifier, Identifier } from '../../types/identifiers'; +import type { Document, MessageOptions } from '../../types/documents'; +import { useDocumentTagMutations } from './useDocumentTagMutations'; +import { useDocumentMoveMutations } from './useDocumentMoveMutations'; +import type { + DocumentsState, + FolderState, + SelectionState, + TagsState, + CorrespondentsState, +} from '../types/workspaceTypes'; +import type { Tag, Correspondent } from '../../types/documents'; +import useDocumentCorrespondentMutations from './useDocumentCorrespondentMutations'; + +type FolderId = FolderIdentifier | 'root'; +type NullableFolderId = FolderId | null; + +interface DocumentTagExtras { + option?: Tag | null; + input?: { value?: string } | null; +} + +interface BulkTagOperationArgs { + labels: string[]; + action: 'add' | 'remove'; + documentIds?: Identifier[]; +} + +interface BulkTagOperationResult { + ok: boolean; + reason?: 'no-labels' | 'no-selection' | 'tag-missing' | 'no-tags' | 'request-failed'; + label?: string; + tagCount?: number; + docsCount?: number; +} + +type CorrespondentAssignment = { + correspondent_id?: Identifier; +}; + +interface UseDocumentMutationsArgs { + documentsState: DocumentsState; + folderState: FolderState; + selectionState: SelectionState; + tagsState: TagsState; + correspondentsState: CorrespondentsState; + closeDocumentViewer: () => void; + viewerDocumentId?: DocumentId | null; + resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; +} + +interface UseDocumentMutationsResult { + moveDocumentsToFolder: ( + documentIds: Array<DocumentId | Document>, + targetFolderId?: NullableFolderId, + ) => Promise<void>; + handleThumbnailRegeneration: (documentId: DocumentId) => Promise<void>; + handleDocumentsDelete: ( + documentIds: DocumentId[], + options?: MessageOptions, + ) => Promise<boolean>; + handleDocumentTagAdd: ( + document: Document, + label: string, + extras?: DocumentTagExtras | null, + ) => Promise<void>; + handleDocumentTagAttach: (documentId: DocumentId, tagId: DocumentId) => Promise<boolean>; + handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise<boolean>; + handleDocumentIssuedUpdate: ( + documentId: DocumentId, + nextIssuedDate: number | null, + ) => Promise<boolean>; + handleDocumentTagDetach: ( + documentId?: DocumentId, + tagId?: DocumentId, + ) => Promise<boolean>; + handleDocumentCorrespondentAttach: (args: { documentId: DocumentId; correspondentId: DocumentId; correspondent?: Correspondent | Partial<Correspondent> | null }) => Promise<boolean>; + handleDocumentCorrespondentDetach: (args: { documentId: DocumentId; correspondentId: DocumentId }) => Promise<boolean>; + handleDocumentCorrespondentAdd: (args: { document: { id?: string }; name?: string; input?: HTMLInputElement | null; option?: Correspondent | Partial<Correspondent> | string | null }) => Promise<void>; + handleBulkCorrespondentAdd: (args: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise<void>; + handleBulkCorrespondentRemove: (args: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => Promise<void>; + handleBulkTagAddFromDetail: (args: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise<void>; + handleBulkTagRemoveFromDetail: (args: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => Promise<void>; + handleBulkSelectionReanalyze: (documentIdsOverride?: Identifier[] | null) => Promise<void>; +} + +const useDocumentMutations = ({ + documentsState, + folderState, + selectionState, + tagsState, + correspondentsState, + closeDocumentViewer, + viewerDocumentId, + resolveTargetDocumentIds, +}: UseDocumentMutationsArgs): UseDocumentMutationsResult => { + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + + const { moveDocumentsToFolder } = useDocumentMoveMutations({ + documentsState, + folderState, + selectionState, + }); + + const { + handleDocumentTagAdd, + handleDocumentTagAttach, + handleDocumentTagDetach, + } = useDocumentTagMutations({ + tagsState, + documentsState: { documentsManager: documentsState.documentsManager }, + }); + + const { + handleDocumentCorrespondentAttach, + handleDocumentCorrespondentDetach, + handleDocumentCorrespondentAdd, + } = useDocumentCorrespondentMutations({ + correspondentsState, + documentsState: { documentsManager: documentsState.documentsManager }, + }); + + const handleThumbnailRegeneration = useCallback( + async (documentId: DocumentId) => { + try { + await queueDocumentReanalysis(documentId); + showToast('Analysis queued.', 'info'); + // Close preview if it's the current one to allow refresh? + if (viewerDocumentId === documentId) { + closeDocumentViewer(); + } + } catch (error) { + notifyApiError(error, 'Failed to queue analysis.'); + } + }, + [closeDocumentViewer, notifyApiError, viewerDocumentId, showToast], + ); + + const handleDocumentsDelete = useCallback( + async (documentIds: DocumentId[], { showMessage = true }: MessageOptions = {}) => { + if (!documentIds?.length) return false; + + // Optimistic update could happen here but usually we wait for standardized confirmation + // However workspace expects mutation here. + try { + await Promise.all(documentIds.map((id) => trashDocument(id))); + + // Remove from local state and manager + documentsState.documentsManager.remove(documentIds); + + if (showMessage) { + const count = documentIds.length; + const suffix = count === 1 ? '' : 's'; + showToast(`${count} document${suffix} deleted.`, 'success'); + } + return true; + } catch (error) { + const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.'; + notifyApiError(error, message); + return false; + } + }, + [ + documentsState, + notifyApiError, + showToast, + ], + ); + + const handleDocumentTitleUpdate = useCallback( + async (documentId: DocumentId, nextTitle: string) => { + const trimmed = nextTitle?.trim?.() || ''; + if (!trimmed) { + showToast('Document title cannot be empty.', 'error'); + return false; + } + try { + const data = await updateDocument(documentId, { title: trimmed }); + const updatedDocument = documentsState.extractDocumentFromResponse?.(data); + + if (updatedDocument && documentsState.ingestDocuments) { + documentsState.ingestDocuments([updatedDocument]); + } else { + documentsState.documentsManager.update(documentId, (doc) => { + if (updatedDocument) { + return { ...doc, ...updatedDocument }; + } + return { ...doc, title: trimmed }; + }); + } + + showToast('Document title updated.', 'success'); + return true; + } catch (error) { + const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.'; + notifyApiError(error, message); + return false; + } + }, + [ + documentsState, + notifyApiError, + showToast, + ], + ); + + const handleDocumentIssuedUpdate = useCallback( + async (documentId: DocumentId, nextIssuedDate: number | null) => { + const payload = { issued_at: nextIssuedDate || null }; + try { + const data = await updateDocument(documentId, payload); + const updatedDocument = documentsState.extractDocumentFromResponse?.(data); + + if (updatedDocument && documentsState.ingestDocuments) { + documentsState.ingestDocuments([updatedDocument]); + } else { + documentsState.documentsManager.update(documentId, (doc) => { + if (updatedDocument) { + return { ...doc, ...updatedDocument }; + } + return { ...doc, issued_at: payload.issued_at }; + }); + } + + const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.'; + showToast(message, 'success'); + return true; + } catch (error) { + const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.'; + notifyApiError(error, message); + return false; + } + }, + [ + documentsState, + notifyApiError, + showToast, + ], + ); + + const bulkTagOperation = useCallback( + async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise<BulkTagOperationResult> => { + if (!labels?.length) return { ok: false, reason: 'no-labels' }; + + const targetIds = resolveTargetDocumentIds(documentIds); + if (!targetIds?.length) return { ok: false, reason: 'no-selection' }; + + const existingTags = tagsState.tags || []; + const tagMap = new Map(existingTags.map((t) => [t.label, t])); + + const tagsToProcess: Tag[] = []; + const labelsToCreate: string[] = []; + + for (const lbl of labels) { + const tag = tagMap.get(lbl); + if (tag) { + tagsToProcess.push(tag); + } else if (action === 'add') { + labelsToCreate.push(lbl); + } + } + + for (const lbl of labelsToCreate) { + try { + // Use API directly to create tag + const created = await createTag({ label: lbl, color: '#c0c0c0' }); + if (created) { + tagsToProcess.push(created as Tag); + if (tagsState.tagManager && typeof tagsState.tagManager.ingest === 'function') { + tagsState.tagManager.ingest([created as Tag]); + } + } + } catch (e) { + console.error('Failed to create tag', lbl, e); + } + } + + if (!tagsToProcess.length && action === 'add') { + return { ok: false, reason: 'tag-missing' }; + } + + try { + const tagIds = tagsToProcess.map(t => t.id); + await bulkTagDocuments({ document_ids: targetIds, tag_ids: tagIds, action }); + + documentsState.documentsManager.map((doc) => { + if (!targetIds.includes(doc.id)) return undefined; + const oldTags = doc.tags || []; + let newTags = [...oldTags]; + const processIds = new Set(tagIds); + + if (action === 'add') { + const currentIds = new Set(oldTags); + tagIds.forEach(tid => { + if (!currentIds.has(tid)) newTags.push(tid); + }); + } else { + newTags = newTags.filter(tid => !processIds.has(tid)); + } + return { ...doc, tags: newTags }; + }); + + return { ok: true, docsCount: targetIds.length, tagCount: tagsToProcess.length, label: labels[0] }; + } catch (e) { + notifyApiError(e, 'Bulk tag operation failed'); + return { ok: false, reason: 'request-failed' }; + } + }, + [documentsState, resolveTargetDocumentIds, tagsState, notifyApiError] + ); + + const handleBulkTagAddFromDetail = useCallback(async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => { + const text = label || input?.value?.trim(); + if (!text) return; + + const res = await bulkTagOperation({ labels: [text], action: 'add', documentIds }); + if (res.ok) { + showToast(`Added tag "${text}" to ${res.docsCount} documents.`, 'success'); + if (input) input.value = ''; + } + }, [bulkTagOperation, showToast]); + + const handleBulkTagRemoveFromDetail = useCallback(async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => { + const text = label || input?.value?.trim(); + if (!text) return; + + const res = await bulkTagOperation({ labels: [text], action: 'remove', documentIds }); + if (res.ok) { + showToast(`Removed tag "${text}" from ${res.docsCount} documents.`, 'success'); + } + }, [bulkTagOperation, showToast]); + + const handleBulkSelectionReanalyze = useCallback(async (documentIdsOverride?: Identifier[] | null) => { + const ids = resolveTargetDocumentIds(documentIdsOverride || undefined); + if (!ids.length) { + showToast('No documents selected.', 'info'); + return; + } + try { + await bulkReanalyzeDocuments({ document_ids: ids }); + showToast(`Queued reanalysis for ${ids.length} documents.`, 'success'); + } catch (e) { + notifyApiError(e, 'Failed to queue reanalysis'); + } + }, [resolveTargetDocumentIds, showToast, notifyApiError]); + + const handleBulkCorrespondentAdd = useCallback(async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => { + const text = name || input?.value?.trim(); + if (!text) return; + const ids = resolveTargetDocumentIds(documentIds); + if (!ids.length) return; + + const { correspondentManager, correspondentLookupByName } = correspondentsState; + const normalized = text.trim(); + + let corr = correspondentLookupByName?.get(normalized.toLowerCase()); + + if (!corr) { + try { + // Create new correspondent + const payload = correspondentManager.buildPayload({ name: normalized }); + corr = await correspondentManager.create(payload); + } catch (e) { + console.error('Failed to create correspondent', e); + showToast('Failed to create correspondent.', 'error'); + return; + } + } + + if (!corr) { + showToast('Correspondent could not be found or created.', 'error'); + return; + } + + try { + await assignCorrespondentsBulk({ + document_ids: ids, + assignments: [{ correspondent_id: corr.id }], + action: 'add' + }); + + documentsState.documentsManager.map((doc) => { + if (ids.includes(doc.id)) { + const current = doc.correspondents || []; + if (corr?.id && !current.includes(corr.id)) { + return { ...doc, correspondents: [...current, corr.id] }; + } + } + return undefined; + }); + showToast(`Assigned "${corr.name}" to ${ids.length} documents.`, 'success'); + if (input) input.value = ''; + } catch (e) { + notifyApiError(e, 'Failed to assign correspondent'); + } + }, [documentsState, correspondentsState, resolveTargetDocumentIds, showToast, notifyApiError]); + + const handleBulkCorrespondentRemove = useCallback(async ({ documentIds }: { documentIds?: Identifier[] }) => { + const ids = resolveTargetDocumentIds(documentIds); + if (!ids.length) return; + + const correspondentsToRemove = new Set<Identifier>(); + ids.forEach(docId => { + const doc = documentsState.documentLookup.get(docId); + if (doc?.correspondents?.length) { + doc.correspondents.forEach(cId => correspondentsToRemove.add(cId)); + } + }); + + if (correspondentsToRemove.size === 0) { + showToast('No correspondents found to remove.', 'info'); + return; + } + + const assignments = Array.from(correspondentsToRemove).map(id => ({ correspondent_id: id })); + + try { + await assignCorrespondentsBulk({ + document_ids: ids, + assignments, + action: 'remove' + }); + + documentsState.documentsManager.map((doc) => { + if (ids.includes(doc.id)) { + // Remove any of the targeted correspondents from the document + const current = doc.correspondents || []; + const newCorrespondents = current.filter(cId => !correspondentsToRemove.has(cId)); + if (current.length !== newCorrespondents.length) { + return { ...doc, correspondents: newCorrespondents }; + } + } + return undefined; + }); + showToast(`Removed correspondents from ${ids.length} documents.`, 'success'); + } catch (e) { + notifyApiError(e, 'Failed to remove correspondents'); + } + }, [documentsState, resolveTargetDocumentIds, showToast, notifyApiError]); + + return { + moveDocumentsToFolder, + handleThumbnailRegeneration, + handleDocumentsDelete, + handleDocumentTagAdd, + handleDocumentTagAttach, + handleDocumentTitleUpdate, + handleDocumentIssuedUpdate, + handleDocumentTagDetach, + handleDocumentCorrespondentAttach, + handleDocumentCorrespondentDetach, + handleDocumentCorrespondentAdd, + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleBulkTagAddFromDetail, + handleBulkTagRemoveFromDetail, + handleBulkSelectionReanalyze, + }; +}; + +export default useDocumentMutations; diff --git a/frontend/src/documents/data/useDocumentTagMutations.ts b/frontend/src/documents/data/useDocumentTagMutations.ts new file mode 100644 index 0000000..6dd5383 --- /dev/null +++ b/frontend/src/documents/data/useDocumentTagMutations.ts @@ -0,0 +1,153 @@ +import { useCallback } from 'react'; +import type { DocumentId } from '../../types/identifiers'; +import type { Document, Tag } from '../../types/documents'; +import { + addDocumentTags, + createTag, + deleteDocumentTag, +} from '../../lib/api/apiClient'; +import type { TagsState, DocumentsState } from '../types/workspaceTypes'; + +interface DocumentTagExtras { + option?: Tag | null; + input?: { value?: string } | null; +} + +interface UseDocumentTagMutationsArgs { + tagsState: TagsState; + documentsState: Pick<DocumentsState, 'documentsManager'>; +} + +export const useDocumentTagMutations = ({ + tagsState, + documentsState, +}: UseDocumentTagMutationsArgs) => { + // Note: Toasts are handled by the caller, e.g. useDetailWorkspace or ResultQueue. + + const attachTagToDocument = useCallback( + async ({ + documentId, + tag, + }: { + documentId?: DocumentId; + tag?: Tag | null; + }) => { + if (!documentId || !tag?.id) { + return false; + } + + await addDocumentTags(documentId, [tag.id]); + documentsState.documentsManager.map((doc) => { + if (doc.id !== documentId) { + return undefined; + } + const currentTags = Array.isArray(doc.tags) ? doc.tags : []; + + if (currentTags.includes(tag.id)) { + return doc; + } + return { ...doc, tags: [...currentTags, tag.id] }; + }); + + return true; + }, + [documentsState], + ); + + const handleDocumentTagAdd = useCallback( + async (document: Document, label: string, extras?: DocumentTagExtras | null) => { + const normalizedLabel = tagsState.tagManager.normalizeLabel(label); + const optionCandidate = extras?.option ?? null; + const input = extras?.input ?? null; + + let tag: Tag | null = null; + // Lookup via ID + if (optionCandidate && optionCandidate.id) { + tag = tagsState.tagLookupById.get(optionCandidate.id) || (optionCandidate as Tag); + } + // Lookup via Label if not found + if (!tag) { + const knownTags = Array.from(tagsState.tagLookupById.values()); + tag = knownTags.find((item) => item.label?.toLowerCase() === normalizedLabel.toLowerCase()) || null; + } + + // Create tag if needed. Errors bubble up. + if (!tag) { + const payload = tagsState.tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null }; + const data = await createTag(payload); + tag = data as Tag; + // Ingest new tag into manager to ensure it's available + tagsState.tagManager.ingest([tag]); + await tagsState.refreshTags(); + } + await attachTagToDocument({ + documentId: document.id as DocumentId, + tag, + }); + if (input && typeof input === 'object' && 'value' in input) { + (input as { value?: string }).value = ''; + } + }, + [tagsState, attachTagToDocument], + ); + + const handleDocumentTagAttach = useCallback( + async (documentId: DocumentId, tagId: DocumentId) => { + if (!documentId || !tagId) { + return false; + } + + const resolveTagForCache = (): Tag | null => { + const lookupTag = tagsState.tagLookupById.get(tagId); + if (!lookupTag || lookupTag.id == null) { + return null; + } + + return lookupTag; + }; + + const resolvedTag = resolveTagForCache(); + return attachTagToDocument({ + documentId, + tag: resolvedTag, + }); + }, + [ + attachTagToDocument, + tagsState, + ], + ); + + const handleDocumentTagDetach = useCallback( + async (documentId?: DocumentId, tagId?: DocumentId) => { + if (!documentId || !tagId) { + return false; + } + + await deleteDocumentTag(documentId, tagId); + // Inlined applyTagRemovalToCaches logic + documentsState.documentsManager.map((doc) => { + if (doc.id !== documentId) { + return undefined; + } + if (!doc || !Array.isArray(doc.tags)) { + return doc; + } + // Filter IDs + const nextTags = doc.tags.filter((id) => id !== tagId); + if (nextTags.length === doc.tags.length) { + return doc; + } + return { ...doc, tags: nextTags }; + }); + return true; + }, + [documentsState], + ); + + return { + handleDocumentTagAdd, + handleDocumentTagAttach, + handleDocumentTagDetach, + }; +}; diff --git a/frontend/src/documents/data/useDocuments.ts b/frontend/src/documents/data/useDocuments.ts new file mode 100644 index 0000000..a9504a7 --- /dev/null +++ b/frontend/src/documents/data/useDocuments.ts @@ -0,0 +1,79 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import DocumentsManager from '../DocumentsManager'; +import type { DocumentId } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; + +interface UseDocumentsOptions { + fetchDocumentById?: (id: DocumentId) => Promise<Document | null>; +} + +const useDocuments = ({ + fetchDocumentById, +}: UseDocumentsOptions) => { + const managerRef = useRef( + new DocumentsManager<Document>(fetchDocumentById), + ); + // Store only IDs in local state + const [documentIds, setDocumentIds] = useState<DocumentId[]>([]); + + useEffect(() => { + managerRef.current.setFetcher(fetchDocumentById); + }, [fetchDocumentById]); + + // Subscribe to the manager for reactive updates + const managerSnapshot = useSyncExternalStore( + useCallback((cb) => managerRef.current.subscribe(cb), []), + () => managerRef.current.getSnapshot(), + () => managerRef.current.getSnapshot(), + ); + + // Derive the full document objects from IDs + Snapshot + const documents = useMemo(() => { + if (!documentIds.length) return []; + + // Efficiently map IDs to current document objects from the snapshot + // If an ID is missing in the snapshot (unlikely if ingested correctly), return null/undefined and filter + return documentIds + .map(id => managerSnapshot.get(id)) + .filter((doc): doc is Document => Boolean(doc)); + }, [documentIds, managerSnapshot]); + + // Keep a ref to the latest documents to avoid setDocuments dependency + const documentsRef = useRef(documents); + useEffect(() => { + documentsRef.current = documents; + }, [documents]); + + const setDocuments = useCallback( + (value: Document[] | ((prev: Document[]) => Document[])) => { + // Support functional updates using the current derived documents as the previous state. + // Use ref to avoid re-creating this callback when documents change. + const prevDocs = documentsRef.current; + const newDocs = typeof value === 'function' ? value(prevDocs) : value; + + if (!Array.isArray(newDocs)) { + return; + } + + const { canonical } = managerRef.current.ingest(newDocs); + const newIds = canonical.map(d => d.id as DocumentId).filter(Boolean); + setDocumentIds(newIds); + }, + [] // Stable callback + ); + + return { + documents, + setDocuments, + documentsManager: managerRef.current, + }; +}; + +export default useDocuments; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts new file mode 100644 index 0000000..93d4f7d --- /dev/null +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -0,0 +1,1041 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import { + matchPath, + useLocation, + useMatch, + useNavigate, +} from 'react-router-dom'; +import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager'; +import useNotifyApiError from '../../hooks/useNotifyApiError'; +import TagManager from '../../lib/assets/TagManager'; +import CorrespondentManager from '../../lib/assets/CorrespondentManager'; +import { fetchAsset } from '../../lib/api/apiClient'; +import { useEntryPointer as useEntryPointerCore } from '../features/selection/useEntryPointer'; +import useDocumentsSelection from '../features/selection/useDocumentsSelection'; +import useBulkDocumentActions from './useBulkDocumentActions'; +import { + DEFAULT_SORT_DIRECTION, + DEFAULT_SORT_FIELD, + mergeAssetIntoDocument, +} from '../../app/workspaceUtils'; +import { + createDocumentEntryKey, + createFolderEntryKey, + isFolderEntry, + isDocumentEntry +} from '../../app/entryKey'; +import useDocumentsSearch from '../../app/useDocumentsSearch'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import useAuthManager from './useAuthManager'; +import useTenantManager from './useTenantManager'; +import useDocuments from './useDocuments'; +import FoldersManager from '../FoldersManager'; +import { fetchDocument } from '../../lib/api/apiClient'; +import useFolderTree from '../features/folders/useFolderTree'; +import useFolderTreeActions from '../features/folders/useFolderTreeActions'; +import useDocumentUploads from '../features/upload/useDocumentUploads'; +import useDocumentDragHandlers from '../features/upload/useDocumentDragHandlers'; +import useDocumentMutations from './useDocumentMutations'; +import useDetailWorkspace from '../../viewer/logic/useDetailWorkspace'; +import useTags from './useTags'; +import useCorrespondents from './useCorrespondents'; +import usePasskeys from '../../settings/usePasskeys'; +import { resolveBreadcrumbs } from '../logic/breadcrumbs'; +import useWorkspaceSelectionSync from '../features/selection/useWorkspaceSelectionSync'; +import useWorkspaceViewData from './useWorkspaceViewData'; +import { useManagementModals } from '../../app/useManagementModals'; +import { useAppDispatch, useAppState } from '../../lib/store/appState'; +import { listFolderContents } from '../../lib/api/apiClient'; +import { useApi } from '../../lib/context/ApiContext'; +import { useWorkspaceSelection } from '../../app/useWorkspaceSelection'; +import useDocumentViewer from '../../app/useDocumentViewer'; +import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers'; +import type { Document, Folder } from '../../types/documents'; + +const EntryType = Object.freeze({ + document: 'document', + folder: 'folder', +}); + +const noop = () => { }; + +interface TenantOption { + id?: Identifier | null; + name?: string | null; + slug?: string | null; + [key: string]: unknown; +} + +interface UseDocumentsWorkspaceOptions { + documentsViewMode?: string; + documentsSortField?: string; + documentsSortDirection?: string; + onDocumentsViewModeChange?: (mode: string) => void; + onDocumentsSortFieldChange?: (field: string) => void; + onDocumentsSortDirectionToggle?: () => void; + searchIncludeDescendants?: boolean; + onSetSearchIncludeDescendants?: (value: boolean) => void; +} + +const useDocumentsWorkspace = ({ + documentsViewMode = 'list', + documentsSortField = DEFAULT_SORT_FIELD, + documentsSortDirection = DEFAULT_SORT_DIRECTION, + onDocumentsViewModeChange, + onDocumentsSortFieldChange, + onDocumentsSortDirectionToggle, + searchIncludeDescendants = true, + onSetSearchIncludeDescendants, +}: UseDocumentsWorkspaceOptions = {}) => { + const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop; + const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop; + const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop; + const setSearchIncludeDescendants = onSetSearchIncludeDescendants || noop; + + const activeSortFieldRef = useRef(documentsSortField); + useEffect(() => { + activeSortFieldRef.current = documentsSortField; + }, [documentsSortField]); + + const activeSortDirectionRef = useRef(documentsSortDirection); + useEffect(() => { + activeSortDirectionRef.current = documentsSortDirection; + }, [documentsSortDirection]); + + const navigate = useNavigate(); + const location = useLocation(); + const appState = useAppState(); + const appDispatch = useAppDispatch(); + const folderMatch = matchPath('/documents/folder/:folderId', location.pathname); + const docMatch = matchPath('/documents/:documentId', location.pathname); + const routeFolderId = folderMatch?.params?.folderId || null; + const routeDocumentId = docMatch?.params?.documentId || null; + const viewerDocumentId = routeDocumentId; + + const handleBreadcrumbNavigate = useCallback((crumb: { id?: Identifier | string } | null) => { + if (!crumb || !crumb.id) { + return; + } + const target = crumb.id === 'root' ? '/documents' : `/documents/folder/${crumb.id}`; + navigate(target); + }, [navigate]); + + const { + status: appStatus, + token, + tenant, + tenants: tenantOptionsRaw = [], + } = appState; + const { client: apiClient } = useApi(); + + const tenantRecord = (tenant ?? null) as TenantOption | null; + const currentTenantId: Identifier | null = (tenantRecord?.id ?? null) as Identifier | null; + + const tenantOptions: TenantOption[] = Array.isArray(tenantOptionsRaw) + ? (tenantOptionsRaw as TenantOption[]) + : []; + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + const [creatingFolder, setCreatingFolder] = useState(false); + const { handleLogout } = useAuthManager({}); + + const tenantIdRef = useRef(currentTenantId); + const detailPanelControlRef = useRef({ open: () => { }, close: () => { } }); + const documentsRouteMatch = useMatch('/documents'); + const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId'); + const documentsDetailRouteMatch = useMatch('/documents/:documentId'); + const isDocumentsRoute = Boolean( + documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch, + ); + + const [draggedDocumentIds, setDraggedDocumentIds] = useState<DocumentId[]>([]); + const [draggedFolderId, setDraggedFolderId] = useState<FolderNodeId | null>(null); + const [activeViewerId, setActiveViewerId] = useState<DocumentId | null>(routeDocumentId || null); + const shellRef = useRef(null); + const assetManagerRef = useRef(null); + if (!assetManagerRef.current) { + const fetcher = async (id: Identifier) => { + const asset = await fetchAsset(id); + return (asset as unknown) as any; + }; + assetManagerRef.current = new AssetManager({ fetchAsset: fetcher }); + } + const assetManager = assetManagerRef.current; + + const extractDocumentFromResponse = useCallback( + (payload) => { + if (!payload) { + return null; + } + return payload.document || payload; + }, + [], + ); + + const fetchDocumentById = useCallback( + async (documentId: DocumentId) => { + if (!documentId) { + return null; + } + const data = await fetchDocument(documentId); + return extractDocumentFromResponse(data); + }, + [extractDocumentFromResponse], + ); + + const tagManagerRef = useRef(null); + if (!tagManagerRef.current) { + tagManagerRef.current = new TagManager(); + } + const tagManager = tagManagerRef.current; + + const correspondentManagerRef = useRef<CorrespondentManager | null>(null); + if (!correspondentManagerRef.current) { + correspondentManagerRef.current = new CorrespondentManager(); + } + const correspondentManager = correspondentManagerRef.current; + + const selectionState = useWorkspaceSelection(); + + const { + selectedEntries, + selectedDocumentIds, + selectedFolderIds, + setSelectedEntries, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + selectionInitializedRef, + focusedDocumentId, + setFocusedDocumentId, + focusedEntryKey, + setFocusedEntryKey, + applySelection, + handleEntrySelection, + clearSelection, + promoteSelectionOrder: promoteSelectionOrderRaw, + configureSelectionEnvironment, + } = selectionState; + + const { + documents, + setDocuments, + documentsManager, + } = useDocuments({ + fetchDocumentById, + }); + + useEffect(() => { + if (tagManager) { + documentsManager.setTagManager(tagManager); + } + if (correspondentManager) { + documentsManager.setCorrespondentManager(correspondentManager); + } + }, [documentsManager, tagManager, correspondentManager]); + + const documentLookup = useSyncExternalStore( + (onStoreChange) => documentsManager.subscribe(onStoreChange), + () => documentsManager.getSnapshot(), + () => documentsManager.getSnapshot(), + ); + + const foldersManagerRef = useRef<FoldersManager | null>(null); + if (!foldersManagerRef.current) { + foldersManagerRef.current = new FoldersManager(); + } + const foldersManager = foldersManagerRef.current; + + const folderStateRaw = useFolderTree({ + initialSelectedFolder: routeFolderId || 'root', + foldersManager, + }); + const { + folderNodes, + selectedFolder, + setSelectedFolder, + currentFolderName, + folderOptions, + isInvalidFolderDrop, + } = folderStateRaw; + + const folderState = { + ...folderStateRaw, + setCreatingFolder, + foldersManager, + }; + + const resolveFolderPath = useCallback( + (folderId) => { + return resolveBreadcrumbs(folderId || 'root', folderNodes as any); + }, + [folderNodes], + ); + + const foldersSnapshot = useSyncExternalStore( + useCallback((cb) => foldersManager.subscribe(cb), [foldersManager]), + () => foldersManager.getSnapshot(), + () => foldersManager.getSnapshot(), + ); + + const visibleSubfolders = useMemo(() => { + // Derive subfolders directly from the source of truth (FoldersManager) + const currentId = selectedFolder || 'root'; + const allFolders = Array.from(foldersSnapshot.values()); + + return allFolders.filter((folder: Folder) => { + const parentId = folder.parent_id || 'root'; + return parentId === currentId; + }); + }, [foldersSnapshot, selectedFolder]); + + const reconcileSelectionWithFolderData = useCallback( + (currentSelection: string[], docs: Document[], subfolders: any[]) => { + const availableDocKeys = docs + .map((doc) => createDocumentEntryKey(doc?.id as Identifier)) + .filter(Boolean); + const availableDocKeySet = new Set(availableDocKeys); + const availableFolderKeys = new Set( + subfolders + .map((folder) => createFolderEntryKey(folder?.id as Identifier)) + .filter(Boolean), + ); + + const previousFolderKeys = currentSelection + .filter(isFolderEntry) + .filter((key) => availableFolderKeys.has(key)); + const previousDocKeys = currentSelection.filter(isDocumentEntry); + const nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); + return [...previousFolderKeys, ...nextDocKeys]; + }, + [], + ); + + const selectedFolderRef = useRef<FolderNodeId>(selectedFolder); + useEffect(() => { + selectedFolderRef.current = selectedFolder; + }, [selectedFolder]); + + const updateViewState = useCallback( + (folderId: FolderNodeId, data: any, includeDocuments: boolean) => { + // Guard against race conditions: only update if the folder is still selected + if (folderId === selectedFolderRef.current) { + if (includeDocuments) { + setDocuments((data.documents || []) as Document[]); + } + + const subfolders = (data.subfolders || []) as any[]; + foldersManager.ingest(subfolders); + + if (includeDocuments) { + setSelectedEntries((prev) => reconcileSelectionWithFolderData( + prev, + (data.documents || []) as Document[], + (data.subfolders || []) as any[] + )); + } + } + }, + [ + setDocuments, + setSelectedEntries, + reconcileSelectionWithFolderData, + selectedFolderRef, + foldersManager, + ] + ); + + const fetchFolderData = useCallback( + async ( + folderId: FolderNodeId, + options: { includeDocuments?: boolean } = {} + ) => { + const path = folderId === 'root' ? 'root' : folderId; + const includeDocuments = options.includeDocuments ?? true; + const params: Record<string, unknown> = { + include_documents: includeDocuments, + sort: activeSortFieldRef.current, + dir: activeSortDirectionRef.current, + }; + + const data = await listFolderContents(path, params); + return { data, includeDocuments }; + }, + [ + activeSortFieldRef, + activeSortDirectionRef, + ] + ); + + useEffect(() => { + if (selectedFolder) { + fetchFolderData(selectedFolder) + .then(({ data, includeDocuments }) => { + updateViewState(selectedFolder, data, includeDocuments); + }) + .catch((error) => { + notifyApiError(error, 'Failed to fetch folder contents'); + }); + } + }, [selectedFolder, documentsSortField, documentsSortDirection, fetchFolderData, updateViewState, notifyApiError]); + + const documentsSearch = useDocumentsSearch({ + api: apiClient, + selectedFolder, + locationPathname: location.pathname, + isDocumentsRoute, + searchIncludeDescendants, + documentsSortField, + documentsSortDirection, + setSearchIncludeDescendants, + documentsManager, + }); + + const showingSearchResults = documentsSearch.searchResultIds !== null; + + const { + viewDocuments, + visibleEntryKeySet, + } = useWorkspaceViewData({ + documents, + documentLookup, + searchResultIds: documentsSearch.searchResultIds, + showingSearchResults, + currentSubfolders: visibleSubfolders, + selectedFolder: selectedFolder, + }); + + const { + openDocumentViewer, + closeDocumentViewer, + resetViewerState, + viewerWorkspaceDocument, + viewerActive, + } = useDocumentViewer({ + routeDocumentId: viewerDocumentId, + documentsManager, + selectedFolder, + locationPathname: location.pathname, + locationSearch: location.search, + detailPanelControlRef, + setActiveViewerId, + }); + + const openDocumentViewerForDetail = useCallback( + ({ documentIds }: { documentIds?: Identifier[] } = {}) => { + const targetId = documentIds?.find((value): value is Identifier => value != null); + if (targetId == null) { + return; + } + openDocumentViewer(targetId, { replace: true }); + }, + [openDocumentViewer], + ); + + const getDocumentAsset = useCallback((doc, type) => { + if (!doc || !type) return null; + return getAssetFromVersion(doc.current_version || null, type); + }, []); + + const bootstrapInitializedRef = useRef(false); + const detailFolderFetchRef = useRef(new Set()); + + useWorkspaceSelectionSync({ + showingSearchResults, + searchQuery: documentsSearch.searchQuery, + setSelectedEntries, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + setFocusedDocumentId, + selectedDocumentIds, + activeViewerId, + setActiveViewerId, + selectionInitializedRef, + }); + + const tagsState = useTags({ + tenantIdRef, + tagManager, + setActiveTagFilters: documentsSearch.setActiveTagFilters, + documentsManager, + }); + + // Correspondents state + const correspondentsState = useCorrespondents({ + correspondentManager, + documentsManager, + }); + + const { refreshTags } = tagsState; + const { refreshCorrespondents } = correspondentsState; + + // Prefetch tags/correspondents when tenant changes + useEffect(() => { + refreshTags(); + refreshCorrespondents(); + }, [refreshTags, refreshCorrespondents, currentTenantId]); + + const passkeys = usePasskeys({}); + + const resolveTargetDocumentIds = useCallback( + (candidateIds) => { + const normalized = Array.isArray(candidateIds) + ? candidateIds.filter(Boolean) + : []; + if (normalized.length) { + return Array.from(new Set(normalized)); + } + return selectedDocumentIds; + }, + [selectedDocumentIds], + ); + + const refreshFolderData = useCallback(async () => { + if (selectedFolder) { + const { data, includeDocuments } = await fetchFolderData(selectedFolder); + updateViewState(selectedFolder, data, includeDocuments); + } + }, [selectedFolder, fetchFolderData, updateViewState]); + + const handleManualRefresh = useCallback(async () => { + try { + await refreshFolderData(); + showToast('Folder refreshed successfully', 'success'); + } catch (error) { + showToast('Failed to refresh folder', 'error'); + console.error('Failed to refresh folder:', error); + } + }, [refreshFolderData, showToast]); + + const upload = useDocumentUploads({ + selectedFolder, + currentFolderName, + refreshCurrentFolder: refreshFolderData, + shellRef, + }); + + const { + handleDocumentDragStart, + handleDocumentDragEnd, + handleFolderDragStart, + handleFolderDragEnd, + } = useDocumentDragHandlers({ + documentLookup, + setDraggedDocumentIds, + setDraggedFolderId, + documentsViewMode, + selectedEntries, + selectedDocumentIds, + selectedFolderIds, + applySelection, + handleEntrySelection, + }); + + const resetWorkspaceState = useCallback(() => { + setSelectedFolder('root'); + setDocuments([]); + setSelectedEntries([]); + setSelectionOrder([]); + selectionOrderRef.current = []; + setFocusedDocumentId(null); + selectionAnchorRef.current = null; + setDraggedDocumentIds([]); + setDraggedFolderId(null); + documentsSearch.setSearchResultIds(null); + documentsSearch.setSearchQuery(''); + documentsSearch.setActiveTagFilters([]); + documentsSearch.setActiveCorrespondentFilters([]); + setActiveViewerId(null); + detailPanelControlRef.current.close(); + assetManager.reset(); + resetViewerState(); + upload.resetUploadsState(); + upload.clearUploadQueue(); + + detailFolderFetchRef.current = new Set(); + bootstrapInitializedRef.current = false; + selectionInitializedRef.current = false; + tenantIdRef.current = null; + }, [ + assetManager, + selectionAnchorRef, + selectionInitializedRef, + selectionOrderRef, + setFocusedDocumentId, + setSelectedEntries, + setSelectionOrder, + setSelectedFolder, + setDocuments, + setDraggedDocumentIds, + setDraggedFolderId, + documentsSearch, + setActiveViewerId, + resetViewerState, + upload, + ]); + + useEffect(() => { + if (appStatus === 'logged-out' || appStatus === 'selecting-tenant') { + foldersManager.invalidateTree(); + resetWorkspaceState(); + } + }, [appStatus, resetWorkspaceState, foldersManager]); + + const documentsState = { + documentLookup, + setDocuments, + setSearchResultIds: documentsSearch.setSearchResultIds, + documentsManager, + extractDocumentFromResponse, + ingestDocuments: (docs: unknown[]) => documentsManager.ingest(docs), + }; + + const documentMutationsResult = useDocumentMutations({ + documentsState, + folderState, + selectionState, + tagsState, + correspondentsState, + closeDocumentViewer, + viewerDocumentId, + resolveTargetDocumentIds, + }); + + const { + moveDocumentsToFolder, + handleDocumentsDelete, + handleDocumentTagAdd, + handleDocumentTagAttach, + handleDocumentTitleUpdate, + handleDocumentIssuedUpdate, + handleDocumentTagDetach, + handleDocumentCorrespondentAttach, + handleDocumentCorrespondentDetach, + handleDocumentCorrespondentAdd, + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleBulkTagAddFromDetail, + handleBulkTagRemoveFromDetail, + handleBulkSelectionReanalyze, + } = documentMutationsResult; + + // Wait, mutations object is line 663. handleDeleteSelection is defined later (line 787). + // This ordering is problematic if mutations is used before. + + const dragState = { + draggedDocumentIds, + draggedFolderId, + setDraggedDocumentIds, + setDraggedFolderId, + }; + + const folderActions = useFolderTreeActions({ + folderState, + dragState, + actions: { + handleFileDrop: upload.handleFileDrop, + moveDocumentsToFolder, + }, + utils: { + isInvalidFolderDrop, + }, + }); + + const { + loadFolder, + selectFolder, + handleFolderCreate, + handleFolderDelete, + } = folderActions; + + const selectionContext = useDocumentsSelection({ + showingSearchResults, + currentSubfolders: visibleSubfolders, + visibleDocuments: viewDocuments, + configureSelectionEnvironment, + visibleEntryKeySet, + selectedEntries, + selectionAnchorRef, + promoteSelectionOrderRaw, + setFocusedDocumentId, + setActiveViewerId, + clearSelection, + focusedDocumentId, + setFocusedEntryKey, + focusedEntryKey, + }); + + const initializeAfterLogin = useCallback(async () => { + await Promise.all([ + refreshTags(), + refreshCorrespondents(), + foldersManager.ensureTree(), + ]); + const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; + await loadFolder(initialFolder, {}); + }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, foldersManager]); + + useEffect(() => { + if (!token) { + return; + } + if (appStatus !== 'ready') { + return; + } + + const targetParam = routeFolderId ?? 'root'; + + if (targetParam === 'root' && routeDocumentId) { + return; + } + + // Checking cache (folderContents) is removed, now we rely on selectedFolder effect to fetch. + if (targetParam !== selectedFolder) { + selectFolder(targetParam, { immediate: true }); + } + }, [ + token, + appStatus, + routeFolderId, + routeDocumentId, + selectedFolder, + selectFolder, + ]); + + const mountedRef = useRef(true); + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (appStatus !== 'authenticated') { + return; + } + if (bootstrapInitializedRef.current) { + return; + } + + const bootstrap = async () => { + bootstrapInitializedRef.current = true; + appDispatch({ type: 'BOOTSTRAP_START' }); + try { + await initializeAfterLogin(); + if (mountedRef.current) { + appDispatch({ type: 'BOOTSTRAP_SUCCESS' }); + } + } catch (error) { + if (mountedRef.current) { + appDispatch({ + type: 'BOOTSTRAP_FAILURE', + error: error?.message || 'Failed to initialize data.', + }); + bootstrapInitializedRef.current = false; + } + } + }; + + bootstrap(); + }, [appStatus, appDispatch, initializeAfterLogin]); + + const { + handleDeleteSelection, + } = useBulkDocumentActions({ + selectedDocumentIds, + selectedFolderIds, + handleDocumentsDelete, + handleFolderDelete, + clearDocumentSelection: selectionContext.clearDocumentSelection, + }); + + const mutations = { + ...documentMutationsResult, + handleDocumentDragStart, + handleDocumentDragEnd, + draggedDocumentIds, + handleDeleteSelection, + }; + + const ensureAssetUrl = useCallback( + async (documentId, asset, { force = false } = {}) => { + if (!documentId || !asset?.id) { + return null; + } + + try { + const entry = await assetManager.ensureAsset(documentId, asset, { + force, + }); + + if (!entry) { + return null; + } + + documentsManager.update(documentId, (doc) => mergeAssetIntoDocument(doc, entry)); + + return entry; + } catch (error) { + notifyApiError(error, 'Unable to refresh document asset.'); + throw error; + } + }, + [assetManager, documentsManager, notifyApiError], + ); + + const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => { + if (creatingFolder) { + return; + } + const input = window.prompt('New folder name'); + if (!input) { + return; + } + const trimmed = input.trim(); + if (!trimmed) { + showToast('Folder name cannot be empty.', 'error'); + return; + } + setCreatingFolder(true); + try { + const success = await handleFolderCreate(trimmed, parentId); + if (!success) { + showToast('Unable to create folder. Check the status message for details.', 'error'); + } + } finally { + setCreatingFolder(false); + } + }, [creatingFolder, handleFolderCreate, showToast]); + + const { managementModals, openTagsModal, openCorrespondentsModal } = useManagementModals({ + locationPathname: location.pathname, + tags: tagsState.tags, + refreshTags: tagsState.refreshTags, + onTagCreate: tagsState.handleTagCreate, + onTagUpdate: async (tagId: string, changes: any) => { await tagsState.handleTagUpdate(tagId, changes); }, + onTagDelete: async (tagId: string) => { await tagsState.handleTagDelete(tagId); }, + correspondents: correspondentsState.correspondents, + correspondentLookupById: correspondentsState.correspondentLookupById, + correspondentLookupByName: correspondentsState.correspondentLookupByName, + refreshCorrespondents, + onCorrespondentCreate: correspondentsState.handleCorrespondentCreate, + onCorrespondentUpdate: correspondentsState.handleCorrespondentUpdate, + onCorrespondentDelete: correspondentsState.handleCorrespondentDelete, + correspondentManager, + }); + + const [settingsOpen, setSettingsOpen] = useState(false); + const openSettings = useCallback(() => { + setSettingsOpen(true); + }, []); + const closeSettings = useCallback(() => { + setSettingsOpen(false); + }, []); + + useEffect(() => { + if (!settingsOpen) { + return undefined; + } + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + setSettingsOpen(false); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [settingsOpen]); + + const detailWorkspace = useDetailWorkspace({ + documentLookup, + folderNodes, + detailPanelControlRef, + detailFolderFetchRef, + openDocumentViewer: openDocumentViewerForDetail, + handleDocumentTitleUpdate, + handleDocumentIssuedUpdate, + handleDocumentTagAdd, + handleDocumentTagDetach, + ensureAssetUrl, + getAsset: getDocumentAsset, + correspondents: correspondentsState.correspondents, + handleCorrespondentAdd: handleDocumentCorrespondentAdd, + handleCorrespondentRemove: handleDocumentCorrespondentDetach, + selectFolder, + tags: tagsState.tags, + tagLookupById: tagsState.tagLookupById, + correspondentLookupById: correspondentsState.correspondentLookupById, + resolveFolderPath, + }); + + + + const handleEntryPointerCore = useEntryPointerCore({ + onSelectEntry: (entry, event, { rowKey, modifierClick, primaryClick }) => { + const { type, id } = entry; + const key = rowKey + || (type === EntryType.document ? createDocumentEntryKey(id) : createFolderEntryKey(id)); + if (key) { + if (documentsViewMode === 'grid' && (event as any).shiftKey) { + // Additive selection for Shift+Click in Grid View + const newSelection = Array.from(new Set([...selectedEntries, key])); + applySelection(newSelection, { anchor: key, interactedKeys: [key] }); + } else { + handleEntrySelection(key, event); + } + } + if (type === EntryType.folder && !modifierClick && primaryClick) { + selectFolder(id); + } + }, + }); + + const breadcrumbs = useMemo(() => { + return resolveBreadcrumbs(selectedFolder || 'root', folderNodes as any); + }, [selectedFolder, folderNodes]); + + const { handleTenantSelect } = useTenantManager({ + currentTenantId, + handleDocumentsViewModeChange, + }); + + const session = { + token, + appStatus, + handleLogout, + tenant: tenantRecord, + tenants: tenantOptions, + tenantOptions, + handleTenantSelect, + }; + + const ui = { + notifyApiError, + settingsOpen, + openSettings, + closeSettings, + managementModals, + refreshCurrentFolder: handleManualRefresh, + }; + + const tags = { + ...tagsState, + tagManager, + activeTagFilters: documentsSearch.activeTagFilters, + // Add derived/action handlers that were previously in tagsContext + handleDocumentTagAttach, + handleDocumentTagDetach, + handleBulkTagAddFromDetail, + handleBulkTagRemoveFromDetail, + openTagsModal, + }; + + const correspondents = { + ...correspondentsState, + activeCorrespondentFilters: documentsSearch.activeCorrespondentFilters, + // Add derived/action handlers + handleDocumentCorrespondentAttach, + handleDocumentCorrespondentDetach, + handleDocumentCorrespondentAdd, + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + openCorrespondentsModal, + }; + + const preview = { + openDocumentViewerForDetail, + viewerActive, + viewerWorkspaceDocument, + viewerDocumentId, + closeDocumentViewer, + selectionContext, + ensureAssetUrl, + getDocumentAsset, + }; + + const search = { + searchQuery: documentsSearch.searchQuery, + searchLoading: documentsSearch.searchLoading, + documentsViewMode, + handleDocumentsViewModeChange, + documentsSortField, + documentsSortDirection, + handleDocumentsSortFieldChange, + handleDocumentsSortDirectionToggle, + searchResultIds: documentsSearch.searchResultIds, + documents: viewDocuments, + documentsFilter: documentsSearch.documentsFilterValue, + }; + + const folderTree = { + foldersManager, + selectedFolder, + currentFolderName, + folderOptions, + handleBreadcrumbNavigate, + resolveFolderPath, + selectFolder: folderActions.selectFolder, + moveDocumentsToFolder, + folderClickHandlers: folderActions.folderClickHandlers, + handleFolderRename: folderActions.handleFolderRename, + handleFolderDelete: folderActions.handleFolderDelete, + handleFolderDragStart, + handleFolderDragEnd, + draggedFolderId, + handlePromptCreateFolder, + creatingFolder, + currentSubfolders: visibleSubfolders, + breadcrumbs, + }; + + const selection = { + clearDocumentSelection: selectionContext.clearDocumentSelection, + handleDeleteSelection, + handleEntryPointerCore, + handleBulkSelectionReanalyze, + selectionValue: selectionState, + }; + + const managers = { + documentsManager, + documentLookup, + }; + + const contextValue = { + session, + ui, + upload, + tags, + correspondents, + passkeys, + preview, + detailPanel: detailWorkspace, + search, + folderTree, + selection, + mutations, + managers, + }; + + // hook callers handle rendering / routing + return { + appStatus, + location, + shellRef, + dropOverlayState: upload.dropOverlayState, + managementModals, + contextValue, + settingsOpen, + closeSettings, + }; +}; + +export default useDocumentsWorkspace; diff --git a/frontend/src/documents/data/useTags.ts b/frontend/src/documents/data/useTags.ts new file mode 100644 index 0000000..2aa655b --- /dev/null +++ b/frontend/src/documents/data/useTags.ts @@ -0,0 +1,144 @@ +import { MutableRefObject, useCallback, useSyncExternalStore } from 'react'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import type { TagId, TenantId } from '../../types/identifiers'; +import type { Tag } from '../../types/documents'; +import useNotifyApiError from '../../hooks/useNotifyApiError'; +import TagManager from '../../lib/assets/TagManager'; + +interface UseTagsOptions { + tagManager: TagManager; + tenantIdRef: MutableRefObject<TenantId | null>; + setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void; + documentsManager?: { map: (mapper: (doc: any) => any) => void }; +} + +interface UseTagsResult { + tags: Tag[]; + tagLookupById: Map<TagId, Tag>; + refreshTags: () => Promise<void>; + handleTagUpdate: (tagId: TagId, changes: { label?: string; color?: string | null }) => Promise<boolean>; + handleTagCreate: (payload?: { label?: string; color?: string | null }) => Promise<void>; + handleTagDelete: (tagId: TagId) => Promise<boolean>; + tagManager: TagManager; +} + +const useTags = ({ + tagManager, + setActiveTagFilters, + documentsManager, +}: UseTagsOptions): UseTagsResult => { + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + + const tagsSnapshot = useSyncExternalStore<Map<TagId, Tag>>( + useCallback((cb) => tagManager.subscribe(cb), [tagManager]), + () => tagManager.getSnapshot(), + () => tagManager.getSnapshot(), + ); + + const tags = Array.from(tagsSnapshot.values()) + .filter((tag): tag is Tag => (tag as any).id != null && (tag as any).label != null) // Ensure strict adherence + .sort((a, b) => + (a.label || '').localeCompare(b.label || '') + ); + + const refreshTags = useCallback(async () => { + try { + await tagManager.ensureAll(true); + } catch (error) { + notifyApiError(error, 'Unable to load tags.'); + } + }, [notifyApiError, tagManager]); + + const handleTagUpdate = useCallback( + async (tagId: TagId, changes: { label?: string; color?: string | null }) => { + if (tagId == null) { + throw new Error('Missing tag identifier.'); + } + + const payload: Record<string, string> = {}; + if (changes?.label != null) { + payload.label = changes.label; + } + if (Object.prototype.hasOwnProperty.call(changes, 'color')) { + payload.color = changes.color || ''; // API might behave differently if color is literally null, usually string expected + } + + if (Object.keys(payload).length === 0) { + return false; + } + + try { + await tagManager.update(tagId, payload as any); + showToast('Tag updated.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to update tag.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [notifyApiError, tagManager, showToast], + ); + + const handleTagCreate = useCallback( + async ({ label, color }: { label?: string; color?: string | null } = {}) => { + const payload = tagManager.buildPayload({ label, color }); + try { + const newTag = await tagManager.create(payload); + showToast('Tag created.', 'success'); + return newTag; + } catch (error) { + const message = error.response?.data?.error || 'Failed to create tag.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [notifyApiError, showToast, tagManager], + ); + + const handleTagDelete = useCallback( + async (tagId: TagId) => { + if (tagId == null) { + throw new Error('Missing tag identifier.'); + } + + try { + await tagManager.delete(tagId); + setActiveTagFilters((prev) => prev.filter((id) => id !== tagId)); + + const stripTagFromDoc = (doc: any) => { + if (!doc || !Array.isArray(doc.tags)) { + return doc; + } + const nextTags = doc.tags.filter((tag: Tag) => tag.id !== tagId); + if (nextTags.length === doc.tags.length) { + return doc; + } + return { ...doc, tags: nextTags }; + }; + + documentsManager?.map(stripTagFromDoc); + showToast('Tag deleted.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete tag.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [documentsManager, notifyApiError, setActiveTagFilters, showToast, tagManager], + ); + + return { + tags, + tagLookupById: tagsSnapshot, + refreshTags, + handleTagUpdate, + handleTagCreate, + handleTagDelete, + tagManager, + }; +}; + +export default useTags; diff --git a/frontend/src/documents/data/useTenantManager.ts b/frontend/src/documents/data/useTenantManager.ts new file mode 100644 index 0000000..b94971c --- /dev/null +++ b/frontend/src/documents/data/useTenantManager.ts @@ -0,0 +1,97 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import type { TenantId } from '../../types/identifiers'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import { useAppDispatch } from '../../lib/store/appState'; + +import { api, listTenants, switchTenant } from '../../lib/api/apiClient'; + +import useNotifyApiError from '../../hooks/useNotifyApiError'; + +interface TenantOption { + id?: TenantId; + name?: string; +} + +interface UseTenantManagerOptions { + currentTenantId: TenantId | null; + handleDocumentsViewModeChange: (mode: string) => void; +} + +const useTenantManager = ({ + currentTenantId, + handleDocumentsViewModeChange, +}: UseTenantManagerOptions) => { + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + const navigate = useNavigate(); + const appDispatch = useAppDispatch(); + + const handleTenantSelect = useCallback( + async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => { + const requestedTenantId = tenantOption?.id ?? null; + + // 1. Guard Clauses + if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) { + return; + } + + try { + // 2. Refresh Logic + if (refreshOnly) { + const data = await listTenants(); + appDispatch({ type: 'SET_TENANTS', tenants: data }); + return; + } + + // 3. Switch Logic + const data = await switchTenant(requestedTenantId); + + if (!data?.access_token) { + throw new Error('Missing access token in tenant switch response.'); + } + + // 4. Reset UI to safe state BEFORE updating global auth + // This prevents old components from reacting to state changes. + + + // 5. Update Global State IMMEDIATELY + // Don't wait for navigation. Data consistency comes first. + handleDocumentsViewModeChange('list'); + api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`; + + appDispatch({ + type: 'LOGIN_SUCCESS', + token: data.access_token, + tenant: data.tenant || null, + }); + + if (Array.isArray(data?.tenants)) { + appDispatch({ type: 'SET_TENANTS', tenants: data.tenants }); + } + + const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant'; + showToast(`Switched to ${tenantLabel}.`, 'info'); + + // 5. Handle UI/Navigation changes AFTER state is secure + navigate('/documents', { replace: true }); + + } catch (error) { + notifyApiError(error, 'Failed to switch tenant.'); + } + }, + [ + appDispatch, + currentTenantId, + handleDocumentsViewModeChange, + navigate, + notifyApiError, + showToast, + ], + ); + + return { handleTenantSelect }; +}; + +export default useTenantManager; diff --git a/frontend/src/documents/data/useWorkspaceViewData.ts b/frontend/src/documents/data/useWorkspaceViewData.ts new file mode 100644 index 0000000..9d2625e --- /dev/null +++ b/frontend/src/documents/data/useWorkspaceViewData.ts @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { DocumentId, FolderId } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; +import { createDocumentEntryKey, createFolderEntryKey } from '../../app/entryKey'; + +interface UseWorkspaceViewDataArgs { + documents: Document[]; + documentLookup: Map<DocumentId, Document>; + searchResultIds: DocumentId[] | null; + showingSearchResults: boolean; + currentSubfolders: any[]; + selectedFolder: FolderId; +} + +const useWorkspaceViewData = ({ + documents, + documentLookup, + searchResultIds, + showingSearchResults, + currentSubfolders, +}: UseWorkspaceViewDataArgs) => { + const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]); + + useEffect(() => { + const arraysEqual = (a: DocumentId[], b: DocumentId[]) => + a.length === b.length && a.every((value, index) => value === b[index]); + + if (showingSearchResults && Array.isArray(searchResultIds)) { + const ids = searchResultIds.filter((id): id is DocumentId => id != null); + setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids)); + return; + } + + const folderIds = documents + .map((doc) => (doc?.id ?? null) as DocumentId | null) + .filter((id): id is DocumentId => id != null); + setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds)); + }, [showingSearchResults, searchResultIds, documents]); + + const viewDocuments = useMemo( + () => + visibleDocumentIds + .map((id) => documentLookup.get(id) || null) + .filter((doc): doc is Document => Boolean(doc)), + [visibleDocumentIds, documentLookup], + ); + + const visibleDocumentKeys = useMemo( + () => visibleDocumentIds.map((id) => createDocumentEntryKey(id)).filter(Boolean), + [visibleDocumentIds], + ); + + const visibleFolderKeys = useMemo( + () => + showingSearchResults + ? [] + : (currentSubfolders || []) + .map((folder: any) => createFolderEntryKey(folder.id)) + .filter(Boolean), + [showingSearchResults, currentSubfolders], + ); + + const visibleEntryKeys = useMemo( + () => [...visibleFolderKeys, ...visibleDocumentKeys], + [visibleFolderKeys, visibleDocumentKeys], + ); + + const visibleEntryKeySet = useMemo( + () => new Set(visibleEntryKeys), + [visibleEntryKeys], + ); + + return { + viewDocuments, + visibleDocumentIds, + visibleEntryKeys, + visibleEntryKeySet, + }; +}; + +export default useWorkspaceViewData; diff --git a/frontend/src/documents/documentActions.ts b/frontend/src/documents/documentActions.ts new file mode 100644 index 0000000..c311d49 --- /dev/null +++ b/frontend/src/documents/documentActions.ts @@ -0,0 +1,15 @@ +import type { Document } from '../types/documents'; + +export const resolveDocumentDownloadHref = (document?: Document | null): string | null => { + if (!document) { + return null; + } + const download = document.current_version?.download; + if (!download?.url) { + return null; + } + if (download.expires_at && download.expires_at <= Date.now()) { + return null; + } + return download.url; +}; diff --git a/frontend/src/documents/features/folders/useFolderItemLogic.ts b/frontend/src/documents/features/folders/useFolderItemLogic.ts new file mode 100644 index 0000000..b964e9d --- /dev/null +++ b/frontend/src/documents/features/folders/useFolderItemLogic.ts @@ -0,0 +1,122 @@ +import React, { type DragEvent } from 'react'; +import { isTagTransferEvent } from '../tagging/tagTransfer'; +import type { DocumentViewLogic } from '../../logic/useDocumentViewLogic'; +import { useDocumentsCommandContext } from '../../context/DocumentsCommandContext'; +import { useDocumentsViewStateContext } from '../../context/DocumentsViewStateContext'; + +interface UseFolderItemLogicProps { + folder: any; + viewLogic: DocumentViewLogic; +} + +export const useFolderItemLogic = (props: UseFolderItemLogicProps) => { + const { folder, viewLogic } = props; + const { + draggedFolderId, + } = useDocumentsViewStateContext(); + + const { + folder: { + onClick: onFolderClick, + onSelect: onFolderSelect, + onRename: onFolderRename, + onDrag: { + start: onFolderDragStart, + end: onFolderDragEnd, + over: onFolderDragOver, + leave: onFolderDragLeave, + drop: onFolderDrop, + } + } + + } = useDocumentsCommandContext(); + + const { + selectedFolderIdsSet, + totalSelectionCount, + folderRename: { + editingId: editingFolderId, + draftValue: folderDraft, + setDraftValue: setFolderDraft, + beginEditing: beginFolderEditing, + cancelEditing: cancelFolderEditing, + submitEditing: submitFolderEditing, + savingId: savingFolderId, + attachInputRef: attachFolderInputRef, + }, + } = viewLogic; + + const canDragFolder = folder.id !== 'root'; + const isDraggingFolder = draggedFolderId === folder.id; + const isSelectedFolder = selectedFolderIdsSet?.has(folder.id); + const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root'; + const isFolderEditing = editingFolderId === folder.id; + const folderDraftValue = isFolderEditing ? folderDraft : folder.name; + const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : ''; + const isFolderSaving = savingFolderId === folder.id; + const canSubmitFolder = + isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name; + const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1; + + const handlers = { + onClick: (event: React.MouseEvent) => onFolderClick?.(folder, event), + onDoubleClick: (event: React.MouseEvent) => { + event.preventDefault(); + onFolderSelect?.(folder.id); + }, + onDragOver: (event: DragEvent<HTMLElement>) => { + if (isTagTransferEvent(event)) { + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'none'; + } + return; + } + onFolderDragOver?.(event, folder.id); + }, + onDragLeave: onFolderDragLeave, + onDrop: (event: DragEvent<HTMLElement>) => { + if (isTagTransferEvent(event)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onFolderDrop?.(event, folder.id); + }, + onDragStart: (event: DragEvent<HTMLElement>) => { + if (canDragFolder) { + onFolderDragStart?.(event, folder.id); + } + }, + onDragEnd: (event: DragEvent<HTMLElement>) => { + if (canDragFolder) { + onFolderDragEnd?.(event); + } + }, + onRenameChange: setFolderDraft, + onRenameSubmit: () => submitFolderEditing(folder), + onRenameCancel: (event?: React.SyntheticEvent) => cancelFolderEditing(event), + onRenameBegin: (event: React.SyntheticEvent) => { + if (!allowInlineFolderEdit) return; + event.preventDefault(); + event.stopPropagation(); + beginFolderEditing(folder); + }, + }; + + return { + canDragFolder, + isDraggingFolder, + isSelectedFolder, + isFolderEditing, + folderDraftValue, + isFolderSaving, + canSubmitFolder, + allowInlineFolderEdit, + attachFolderInputRef, + handlers, + }; +}; + + diff --git a/frontend/src/documents/features/folders/useFolderTree.ts b/frontend/src/documents/features/folders/useFolderTree.ts new file mode 100644 index 0000000..ec85939 --- /dev/null +++ b/frontend/src/documents/features/folders/useFolderTree.ts @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { createRootNode, DEFAULT_FOLDER_NAME, flattenFolderTree } from '../../../app/workspaceUtils'; +import type { FolderNodeId, FolderId } from '../../../types/identifiers'; +import type { FolderNode } from '../../../types/documents'; +import type FoldersManager from '../../FoldersManager'; + +interface UseFolderTreeOptions { + initialSelectedFolder?: FolderNodeId; + foldersManager?: FoldersManager; +} + +interface FolderOption { + id: FolderNodeId; + label: string; +} + +const useFolderTree = ({ + initialSelectedFolder = 'root', + foldersManager, +}: UseFolderTreeOptions) => { + // Subscribe to manager updates + const managerSnapshot = useSyncExternalStore( + useCallback(cb => foldersManager ? foldersManager.subscribe(cb) : () => { }, [foldersManager]), + () => foldersManager ? foldersManager.getSnapshot() : null, + () => foldersManager ? foldersManager.getSnapshot() : null, + ); + + const treeSnapshot = useSyncExternalStore( + useCallback(cb => foldersManager ? foldersManager.subscribe(cb) : () => { }, [foldersManager]), + () => foldersManager ? foldersManager.getTreeSnapshot() : [], + () => foldersManager ? foldersManager.getTreeSnapshot() : [], + ); + + // Track expanded state locally + const [expandedIds, setExpandedIds] = useState<Set<FolderNodeId>>(new Set(['root'])); + + // Fetch tree on mount + useEffect(() => { + if (foldersManager) { + foldersManager.ensureTree().catch(err => console.error(err)); + } + }, [foldersManager]); + + const folderNodes = useMemo(() => { + if (!foldersManager || !managerSnapshot) { + const rootNode = createRootNode() as FolderNode; + return new Map([[rootNode.id, rootNode]]); + } + + const map = new Map<FolderNodeId, FolderNode>(); + // Use the synced tree snapshot + const roots = treeSnapshot; + + if (roots.length === 0) { + // Return placeholder or empty + const rootNode = createRootNode() as FolderNode; + return new Map([[rootNode.id, rootNode]]); + } + + const flatStructure = flattenFolderTree(roots); + // Use the synced data snapshot + const dataSnapshot = managerSnapshot; + + // Reconstruct the nodes integrating data from byId and structure from tree + // plus local UI state (expanded) + const rootChildren: FolderNodeId[] = []; + + flatStructure.forEach((item) => { + const id = item.id as FolderNodeId; + const data = dataSnapshot.get(id); + + // Merge: structure (children, parent) comes from flatStructure (which comes from treeSnapshot) + // Data (name) comes from dataSnapshot (byId) to ensure renames propagate instantly + const name = data?.name ?? item.name; + const parentId = (item.parent_id || 'root') as FolderNodeId; + const children = (item.children || []).map(c => c.id as FolderNodeId); + + map.set(id, { + id, + name, + parentId, + children, + expanded: expandedIds.has(id), + loaded: true, + hasChildren: children.length > 0 + }); + + if (parentId === 'root') { + rootChildren.push(id); + } + }); + + return map; + }, [foldersManager, managerSnapshot, treeSnapshot, expandedIds]); + + const [selectedFolder, setSelectedFolder] = useState<FolderNodeId>(initialSelectedFolder || 'root'); + + const isInvalidFolderDrop = useCallback( + (sourceId: FolderNodeId | null, targetId: FolderNodeId | null) => { + if (!sourceId) return false; + if (!targetId || targetId === 'root') { + return false; + } + if (sourceId === targetId) { + return true; + } + + let current = targetId; + const visited = new Set(); + while (current && current !== 'root' && !visited.has(current)) { + visited.add(current); + if (current === sourceId) { + return true; + } + const node = folderNodes.get(current); + if (!node) break; + current = (node.parentId ?? 'root') as FolderNodeId; + } + return false; + }, + [folderNodes], + ); + + const resetFolderTreeState = useCallback(() => { + setExpandedIds(new Set(['root'])); + setSelectedFolder('root'); + }, []); + + const currentFolderName = useMemo(() => { + if (selectedFolder === 'root') return DEFAULT_FOLDER_NAME; + const node = folderNodes.get(selectedFolder); + return node?.name || DEFAULT_FOLDER_NAME; + }, [selectedFolder, folderNodes]); + + const folderOptions: FolderOption[] = useMemo(() => { + const cache = new Map<FolderNodeId, string>(); + const computePath = (id: FolderNodeId | null): string => { + if (cache.has(id as FolderNodeId)) { + return cache.get(id as FolderNodeId) as string; + } + if (!id || id === 'root') { + cache.set('root', DEFAULT_FOLDER_NAME); + return DEFAULT_FOLDER_NAME; + } + const node = folderNodes.get(id); + if (!node) { + return 'Folder'; + } + const parentId = (node.parentId || 'root') as FolderId; + const parentPath = computePath(parentId); + const name = node.name || 'Folder'; + const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`; + cache.set(id, fullPath); + return fullPath; + }; + + const entries: FolderOption[] = []; + folderNodes.forEach((node, id) => { + if (!node) return; + entries.push({ id, label: computePath(id) }); + }); + + entries.sort((a, b) => { + if (a.id === 'root') return -1; + if (b.id === 'root') return 1; + return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); + }); + + return entries; + }, [folderNodes]); + + + const folderLabelMap = useMemo(() => { + const map = new Map<FolderNodeId, string>(); + folderOptions.forEach((option) => { + map.set(option.id, option.label); + }); + return map; + }, [folderOptions]); + + // Exposed helper to toggle expansion (if needed by consumers who can reach here) + const toggleFolder = useCallback((folderId: FolderNodeId) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(folderId)) next.delete(folderId); + else next.add(folderId); + return next; + }); + }, []); + + return { + folderNodes, + selectedFolder, + setSelectedFolder, + currentFolderName, + folderOptions, + folderLabelMap, + isInvalidFolderDrop, + resetFolderTreeState, + toggleFolder, + setExpandedIds + }; +}; + +export default useFolderTree; diff --git a/frontend/src/documents/features/folders/useFolderTreeActions.ts b/frontend/src/documents/features/folders/useFolderTreeActions.ts new file mode 100644 index 0000000..0bebdcf --- /dev/null +++ b/frontend/src/documents/features/folders/useFolderTreeActions.ts @@ -0,0 +1,396 @@ +import { useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import type { DragEvent } from 'react'; +import { useStatusToast } from '../../../lib/context/StatusToastContext'; +import { hasFiles } from '../../../app/workspaceUtils'; +import type { FolderId } from '../../../types/identifiers'; +import type { MessageOptions } from '../../../types/documents'; + +type FolderKey = FolderId | 'root'; + +interface LoadFolderOptions { + preserveSearch?: boolean; +} + +interface SelectFolderOptions { + replace?: boolean; + immediate?: boolean; +} + +interface FolderClickHandlers { + onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise<void>; + onDrop: (event: DragEvent<HTMLElement>, folderId: FolderKey) => Promise<void>; + onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => void; + onDragLeave: (event: DragEvent<HTMLElement>) => void; +} + +import useNotifyApiError from '../../../hooks/useNotifyApiError'; + +import type { + FolderState, + DragState, +} from '../../types/workspaceTypes'; + +import type FoldersManager from '../../FoldersManager'; + +interface UseFolderTreeActionsOptions { + folderState: Pick<FolderState, 'folderNodes' | 'selectedFolder' | 'setSelectedFolder' | 'setCreatingFolder'> & { foldersManager: FoldersManager }; + dragState: DragState; + actions: { + handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void; + moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise<void>; + }; + utils: { + isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean; + }; +} + +const useFolderTreeActions = ({ + folderState, + dragState, + actions, + utils, +}: UseFolderTreeActionsOptions) => { + const { + folderNodes, + selectedFolder, + setSelectedFolder, + setCreatingFolder, + foldersManager, + } = folderState; + + const { + draggedDocumentIds, + draggedFolderId, + setDraggedDocumentIds, + setDraggedFolderId, + } = dragState; + + const { + handleFileDrop, + moveDocumentsToFolder, + } = actions; + + const { isInvalidFolderDrop } = utils; + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + const navigate = useNavigate(); + + const moveFolder = useCallback( + async (folderId: FolderKey, targetFolderId: FolderKey | null) => { + const node = folderNodes.get(folderId); + if (!node) { + showToast('Folder metadata unavailable. Try refreshing.', 'error'); + return; + } + + const previousParentKey = node.parentId ?? 'root'; + const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'; + + if (previousParentKey === targetKey) { + return; + } + + try { + await foldersManager.move(folderId, targetKey); + + if (selectedFolder === folderId) { + setSelectedFolder(folderId); + } + + showToast('Folder moved.', 'success'); + } catch (error) { + const message = error.response?.data?.error || 'Failed to move folder.'; + notifyApiError(error, message); + } + }, + [ + folderNodes, + notifyApiError, + selectedFolder, + foldersManager, + setSelectedFolder, + showToast, + ], + ); + + const loadFolder = useCallback( + async (folderId: FolderKey | null, { preserveSearch: _preserveSearch = false }: LoadFolderOptions = {}) => { + const targetId = folderId || 'root'; + setSelectedFolder(targetId); + }, + [setSelectedFolder], + ); + + const selectFolder = useCallback( + async (folderId: FolderKey | null, { replace = false, immediate = false }: SelectFolderOptions = {}) => { + const targetId = folderId && folderId !== 'root' ? folderId : 'root'; + + if (!navigate || immediate) { + await loadFolder(targetId); + return; + } + + const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; + navigate(path, { replace }); + }, + [ + loadFolder, + navigate, + ], + ); + + const handleFolderRename = useCallback( + async (folderId: FolderKey, nextName: string) => { + const trimmed = nextName?.trim?.() || ''; + if (!trimmed) { + showToast('Folder name cannot be empty.', 'error'); + return false; + } + try { + await foldersManager.rename(folderId, trimmed); + + showToast('Folder renamed.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to rename folder.'; + notifyApiError(error, message); + return false; + } + }, + [ + notifyApiError, + foldersManager, + showToast, + ], + ); + + const handleFolderCreate = useCallback( + async (name: string, parentId?: FolderKey | null) => { + if (!name.trim()) { + showToast('Folder name cannot be empty.', 'error'); + return false; + } + + const targetParentId = parentId !== undefined + ? (parentId === 'root' ? null : parentId) + : (selectedFolder === 'root' ? null : selectedFolder); + + setCreatingFolder(true); + let succeeded = false; + try { + await foldersManager.create(name.trim(), targetParentId); + + showToast('Folder created.', 'success'); + + succeeded = true; + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to create folder.'; + notifyApiError(error, message); + return false; + } finally { + setCreatingFolder(false); + if (!succeeded) { + showToast('Folder creation failed.', 'error'); + } + } + }, + [ + notifyApiError, + selectedFolder, + setCreatingFolder, + foldersManager, + showToast, + ], + ); + + const handleFolderDelete = useCallback( + async (folderId: FolderKey, { showMessage = true }: MessageOptions = {}) => { + if (!folderId || folderId === 'root') { + if (showMessage) { + showToast('The root folder cannot be removed.', 'error'); + } + return false; + } + + try { + await foldersManager.delete(folderId); + + if (selectedFolder === folderId) { + // Fallback selection logic + const node = folderNodes.get(folderId); + const parentId = node?.parentId || 'root'; + setSelectedFolder(parentId); + } + + if (showMessage) { + showToast('Folder deleted.', 'success'); + } + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete folder.'; + notifyApiError(error, message); + if (showMessage) { + showToast(message, 'error'); + } + return false; + } + }, + [ + folderNodes, + notifyApiError, + selectedFolder, + foldersManager, + setSelectedFolder, + showToast, + ], + ); + + const folderClickHandlers: FolderClickHandlers = useMemo( + () => ({ + onSelect: selectFolder, + onDrop: async (event: DragEvent<HTMLElement>, folderId: FolderKey) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.remove('is-drop-target'); + + let folderIds: FolderId[] = []; + try { + const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list'); + if (rawFolderList) { + const parsed = JSON.parse(rawFolderList); + if (Array.isArray(parsed)) { + folderIds = parsed.filter(Boolean); + } + } + } catch (error) { + console.warn('[folders] Failed to parse folder list drag payload', error); + } + + if (!folderIds.length) { + let folderSourceId = draggedFolderId; + if (!folderSourceId) { + try { + if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) { + folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder'); + } + } catch (error) { + console.warn('[folders] Failed to read folder id from drag payload', error); + } + } + + if (folderSourceId) { + folderIds = [folderSourceId]; + } + } + + folderIds = Array.from(new Set(folderIds.filter(Boolean))); + + if (folderIds.length) { + setDraggedFolderId(null); + const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId)); + if (invalidMove) { + showToast( + 'Cannot move a folder into itself or one of its descendants.', + 'error', + ); + return; + } + + for (const sourceId of folderIds) { + await moveFolder(sourceId, folderId); + } + } + + if (hasFiles(event)) { + await handleFileDrop(event.dataTransfer, folderId); + return; + } + + let docIds: FolderId[] = []; + try { + const raw = event.dataTransfer.getData('application/x-papercrate-doc-list'); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + docIds = parsed.filter(Boolean); + } + } + } catch (error) { + console.warn('[documents] Failed to parse document list drag payload', error); + } + + if (!docIds.length) { + try { + const single = event.dataTransfer.getData('application/x-papercrate-doc'); + if (single) { + docIds = [single]; + } + } catch (error) { + console.warn('[documents] Failed to read single document drag payload', error); + } + } + + if (!docIds.length && draggedDocumentIds.length) { + docIds = draggedDocumentIds; + } + + docIds = Array.from(new Set(docIds)); + + if (!docIds.length || folderId === selectedFolder) { + return; + } + + setDraggedDocumentIds([]); + await moveDocumentsToFolder(docIds, folderId); + }, + onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => { + const folderDragActive = Boolean(draggedFolderId); + if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) { + return; + } + + if (hasFiles(event)) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + event.currentTarget.classList.add('is-drop-target'); + return; + } + + if (draggedDocumentIds.length || folderDragActive) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + event.currentTarget.classList.add('is-drop-target'); + } + }, + onDragLeave: (event: DragEvent<HTMLElement>) => { + event.currentTarget.classList.remove('is-drop-target'); + }, + }), + [ + draggedDocumentIds, + draggedFolderId, + handleFileDrop, + isInvalidFolderDrop, + moveDocumentsToFolder, + moveFolder, + selectFolder, + selectedFolder, + setDraggedDocumentIds, + setDraggedFolderId, + showToast, + ], + ); + + return { + loadFolder, + selectFolder, + handleFolderRename, + handleFolderCreate, + handleFolderDelete, + folderClickHandlers, + }; +}; + +export default useFolderTreeActions; diff --git a/frontend/src/documents/features/renaming/useInlineRename.ts b/frontend/src/documents/features/renaming/useInlineRename.ts new file mode 100644 index 0000000..e50293d --- /dev/null +++ b/frontend/src/documents/features/renaming/useInlineRename.ts @@ -0,0 +1,164 @@ +import { + Dispatch, + SetStateAction, + SyntheticEvent, + useCallback, + useRef, + useState, +} from 'react'; + +type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & { + select?: () => void; +}; + +type InlineRenameOptions<TEntity> = { + getCurrentValue?: (entity: TEntity) => string | null; + getEntityId?: (entity: TEntity) => string | null; +}; + +type InlineRenameHandler = ( + id: string, + value: string, +) => boolean | void | Promise<boolean | void>; + +type InlineRenameReturn<TEntity> = { + editingId: string | null; + draftValue: string; + setDraftValue: Dispatch<SetStateAction<string>>; + beginEditing: (entity?: TEntity | null, event?: SyntheticEvent | Event) => void; + cancelEditing: (event?: SyntheticEvent | Event) => void; + submitEditing: (entity?: TEntity | null) => Promise<boolean>; + savingId: string | null; + attachInputRef: (node: FocusableInput | null) => void; +}; + +const focusInput = (node: FocusableInput | null) => { + if (!node) { + return; + } + const applyFocus = () => { + node.focus(); + node.select?.(); + }; + const raf = window.requestAnimationFrame; + if (raf) { + raf(applyFocus); + return; + } + applyFocus(); +}; + +const identity = (value: unknown) => value as string; + +const defaultGetEntityId = <T,>(entity?: T | null) => + (entity as { id?: string } | null)?.id ?? null; + +const useInlineRename = <TEntity,>( + onRename?: InlineRenameHandler, + { + getCurrentValue = identity as (entity: TEntity) => string | null, + getEntityId = defaultGetEntityId as (entity: TEntity) => string | null, + }: InlineRenameOptions<TEntity> = {}, +): InlineRenameReturn<TEntity> => { + const [editingId, setEditingId] = useState<string | null>(null); + const [draftValue, setDraftValue] = useState(''); + const [savingId, setSavingId] = useState<string | null>(null); + const inputRef = useRef<FocusableInput | null>(null); + + const resetState = useCallback(() => { + setEditingId(null); + setDraftValue(''); + setSavingId(null); + inputRef.current = null; + }, []); + + const beginEditing = useCallback( + (entity?: TEntity | null, event?: SyntheticEvent | Event) => { + if (!entity) { + return; + } + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + const entityId = getEntityId(entity); + if (!entityId) { + return; + } + const currentValue = getCurrentValue(entity) ?? ''; + setEditingId(entityId); + setDraftValue(currentValue); + setSavingId(null); + }, + [getCurrentValue, getEntityId], + ); + + const cancelEditing = useCallback( + (event?: SyntheticEvent | Event) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + resetState(); + }, + [resetState], + ); + + const submitEditing = useCallback( + async (entity?: TEntity | null) => { + if (!entity) { + return false; + } + const entityId = getEntityId(entity); + if (!entityId || editingId !== entityId) { + return false; + } + const trimmed = draftValue.trim(); + const currentValue = getCurrentValue(entity) ?? ''; + if (!trimmed || trimmed === currentValue) { + resetState(); + return true; + } + if (!onRename) { + resetState(); + return true; + } + setSavingId(entityId); + try { + const result = await onRename(entityId, trimmed); + if (result === false) { + return false; + } + resetState(); + return true; + } catch { + return false; + } finally { + setSavingId((current) => (current === entityId ? null : current)); + } + }, + [draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState], + ); + + const attachInputRef = useCallback((node: FocusableInput | null) => { + if (node) { + inputRef.current = node; + focusInput(node); + } else if (inputRef.current) { + inputRef.current = null; + } + }, []); + + return { + editingId, + draftValue, + setDraftValue, + beginEditing, + cancelEditing, + submitEditing, + savingId, + attachInputRef, + }; +}; + +export default useInlineRename; diff --git a/frontend/src/documents/features/selection/SelectionAssignmentMenu.tsx b/frontend/src/documents/features/selection/SelectionAssignmentMenu.tsx new file mode 100644 index 0000000..c6843ed --- /dev/null +++ b/frontend/src/documents/features/selection/SelectionAssignmentMenu.tsx @@ -0,0 +1,369 @@ +import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import useFloatingMenu from '../../../components/useFloatingMenu'; +import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../../../components/icons'; + +type AssignmentState = 'all' | 'partial' | 'none'; + +export interface SelectionAssignmentMenuItem { + id?: string; + label?: string; + state?: AssignmentState; + count?: number | null; + total?: number | null; + color?: string | null; + value?: string; + payload?: unknown; +} + +export interface NormalizedSelectionAssignmentItem { + id: string; + label: string; + state: AssignmentState; + count: number | null; + total: number | null; + payload: unknown; +} + +interface SelectionAssignmentMenuProps { + label: React.ReactNode; + items?: SelectionAssignmentMenuItem[]; + placeholder?: string; + emptyMessage?: string; + createLabel?: string; + onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise<void> | void; + onCreate?: (value: string) => Promise<void> | void; + disabled?: boolean; + className?: string; + triggerContent?: React.ReactNode; + triggerClassName?: string; + showStateIndicators?: boolean; + showCounts?: boolean; + onOpenMenu?: () => void; + renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode; + positionStrategy?: 'absolute' | 'fixed'; + closeOnSelection?: boolean; + sortByState?: boolean; + freezeSortOnOpen?: boolean; +} + +const STATE_ORDER: Record<AssignmentState, number> = { + all: 0, + partial: 1, + none: 2, +}; + +const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] => + (Array.isArray(items) ? items : []) + .map<NormalizedSelectionAssignmentItem | null>((item) => { + if (!item) { + return null; + } + const trimmedLabel = item.label?.trim?.() || ''; + if (!trimmedLabel) { + return null; + } + const state: AssignmentState = item.state === 'all' + ? 'all' + : item.state === 'partial' + ? 'partial' + : 'none'; + const numericCount = item.count ?? null; + const numericTotal = item.total ?? null; + return { + id: item.id ?? trimmedLabel, + label: trimmedLabel, + state, + count: numericCount, + total: numericTotal, + payload: item.payload ?? item, + }; + }) + .filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item)); + +const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({ + label, + items = [], + placeholder = 'Search…', + emptyMessage = 'No entries', + createLabel = 'Add', + onToggle, + onCreate, + disabled = false, + className, + triggerContent = null, + triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger', + showStateIndicators = true, + showCounts = true, + onOpenMenu, + renderItemLabel, + positionStrategy = 'absolute', + closeOnSelection = true, + sortByState = true, + freezeSortOnOpen = false, +}) => { + const anchorRef = useRef<HTMLButtonElement | null>(null); + const inputRef = useRef<HTMLInputElement | null>(null); + const [query, setQuery] = useState(''); + const [pending, setPending] = useState(false); + const [sortSnapshot, setSortSnapshot] = useState<Array<string> | null>(null); + + const { + isOpen, + toggle, + close, + menuRef, + menuStyle, + updatePosition, + } = useFloatingMenu({ + anchorRef, + align: 'center', + positionStrategy, + minWidth: 220, + }) as { + isOpen: boolean; + toggle: () => void; + close: () => void; + menuRef: React.MutableRefObject<HTMLDivElement | null>; + menuStyle: CSSProperties | null; + updatePosition: () => void; + }; + + useEffect(() => { + if (disabled && isOpen) { + close(); + } + }, [disabled, isOpen, close]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + setQuery(''); + setPending(false); + const frame = requestAnimationFrame(() => { + updatePosition(); + if (inputRef.current) { + inputRef.current.focus(); + inputRef.current.select?.(); + } + }); + return () => cancelAnimationFrame(frame); + }, [isOpen, updatePosition]); + + const normalizedItems = useMemo(() => normalizeItems(items), [items]); + + const sortedByStateItems = useMemo(() => { + if (!sortByState) { + return normalizedItems; + } + return normalizedItems.slice().sort((a, b) => { + const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state]; + if (stateDiff !== 0) { + return stateDiff; + } + return a.label.localeCompare(b.label); + }); + }, [normalizedItems, sortByState]); + + useEffect(() => { + if (!isOpen || !freezeSortOnOpen || !sortByState) { + setSortSnapshot(null); + return; + } + setSortSnapshot((prev) => prev ?? sortedByStateItems.map((item) => item.id)); + }, [isOpen, freezeSortOnOpen, sortByState, sortedByStateItems]); + + const orderedItems = useMemo(() => { + if (freezeSortOnOpen && sortSnapshot && sortByState) { + const itemMap = new Map<string, NormalizedSelectionAssignmentItem>( + sortedByStateItems.map((item) => [item.id, item]), + ); + const seen = new Set<string>(); + const fromSnapshot = sortSnapshot + .map((id) => { + const entry = itemMap.get(id); + if (entry) { + seen.add(entry.id); + } + return entry || null; + }) + .filter((entry): entry is NormalizedSelectionAssignmentItem => Boolean(entry)); + const remaining = sortedByStateItems.filter((item) => !seen.has(item.id)); + return [...fromSnapshot, ...remaining]; + } + return sortedByStateItems; + }, [freezeSortOnOpen, sortSnapshot, sortByState, sortedByStateItems]); + + const filteredItems = useMemo(() => { + const search = query.trim().toLowerCase(); + if (!search) { + return orderedItems; + } + return orderedItems.filter((item) => item.label.toLowerCase().includes(search)); + }, [orderedItems, query]); + + const handleToggle = useCallback( + async (item: NormalizedSelectionAssignmentItem) => { + if (!onToggle) { + return; + } + setPending(true); + try { + await onToggle(item); + setPending(false); + if (closeOnSelection) { + close(); + } + } catch (error) { + setPending(false); + console.error('[selection-assignment] toggle failed', error); + } + }, + [onToggle, close, closeOnSelection], + ); + + const handleCreate = useCallback( + async (event: React.FormEvent<HTMLFormElement>) => { + event?.preventDefault?.(); + if (!onCreate) { + return; + } + const value = query.trim(); + if (!value) { + return; + } + setPending(true); + try { + await onCreate(value); + setPending(false); + close(); + } catch (error) { + setPending(false); + console.error('[selection-assignment] creation failed', error); + } + }, + [onCreate, query, close], + ); + + const existingLabels = useMemo( + () => new Set(normalizedItems.map((item) => item.label.toLowerCase())), + [normalizedItems], + ); + + const canCreate = Boolean(onCreate); + const trimmedQuery = query.trim(); + const queryKey = trimmedQuery.toLowerCase(); + const canSubmitCreate = canCreate + && trimmedQuery.length > 0 + && !existingLabels.has(queryKey) + && !pending; + + const handleTriggerClick = useCallback(() => { + if (disabled) { + return; + } + if (!isOpen) { + onOpenMenu?.(); + } + toggle(); + }, [disabled, isOpen, onOpenMenu, toggle]); + + return ( + <div className={className ? `selection-assignment ${className}` : 'selection-assignment'}> + <button + type="button" + ref={anchorRef} + className={triggerClassName} + onClick={handleTriggerClick} + aria-haspopup="menu" + aria-expanded={isOpen} + disabled={disabled} + > + {triggerContent ? triggerContent : ( + <span className="quick-add__chip-label"> + {label} + </span> + )} + </button> + {isOpen ? ( + <div + className="menu menu--floating selection-assignment__menu" + ref={menuRef} + style={menuStyle || undefined} + role="menu" + data-floating-position + > + <div className="selection-assignment__header"> + <form className="selection-assignment__form" onSubmit={handleCreate}> + <input + ref={inputRef} + type="text" + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + disabled={pending} + /> + {canCreate ? ( + <button + type="submit" + className="icon-button selection-assignment__add" + disabled={!canSubmitCreate} + aria-label={createLabel} + title={createLabel} + > + <PlusIcon aria-hidden="true" /> + </button> + ) : null} + </form> + </div> + <div className="selection-assignment__list" role="presentation"> + {filteredItems.length ? ( + filteredItems.map((item) => { + const isAll = item.state === 'all'; + const isPartial = item.state === 'partial'; + const icon = showStateIndicators + ? isAll + ? <CheckIcon className="selection-assignment__icon" aria-hidden="true" /> + : isPartial + ? <CircleDashedCheckIcon className="selection-assignment__icon" aria-hidden="true" /> + : <span className="selection-assignment__icon selection-assignment__icon--empty" aria-hidden="true" /> + : null; + const countLabel = showCounts && item.total && (isPartial || isAll) + ? `${item.count ?? 0}/${item.total}` + : null; + const labelContent = renderItemLabel ? renderItemLabel(item) : item.label; + const labelClassName = [ + 'selection-assignment__label', + (!showStateIndicators || !icon) ? 'selection-assignment__label--nowrap' : null, + ].filter(Boolean).join(' '); + return ( + <button + key={item.id} + type="button" + className={`menu__item selection-assignment__item selection-assignment__item--${item.state}`} + onClick={() => handleToggle(item)} + disabled={pending} + role="menuitem" + > + {icon} + <span className={labelClassName}> + {labelContent} + </span> + {countLabel ? ( + <span className="selection-assignment__count">{countLabel}</span> + ) : null} + </button> + ); + }) + ) : ( + <div className="menu__empty selection-assignment__empty">{emptyMessage}</div> + )} + </div> + </div> + ) : null} + </div> + ); +}; + +export default SelectionAssignmentMenu; diff --git a/frontend/src/documents/features/selection/SelectionFloatingActions.tsx b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx new file mode 100644 index 0000000..f570e33 --- /dev/null +++ b/frontend/src/documents/features/selection/SelectionFloatingActions.tsx @@ -0,0 +1,526 @@ +import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { DEFAULT_FOLDER_NAME } from '../../../app/workspaceUtils'; +import { useAppShell } from '../../../lib/context/AppShellContext'; +import FoldersManager from '../../FoldersManager'; + +import { + TrashIcon, + AnalyzeIcon, + IconX, + FolderOutlineIcon, + TagIcon, + CorrespondentIcon, +} from '../../../components/icons'; +import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu'; +import SelectionFolderMenu from './SelectionFolderMenu'; +import SelectionSummary from './SelectionSummary'; + +import { useWorkspaceSelectionContext } from '../../../app/WorkspaceSelectionContext'; +import type { DocumentId } from '../../../types/identifiers'; +import type { FolderTreeNode } from '../../../lib/api/apiTypes'; + +type NullableDocumentId = DocumentId | null; + +type SelectedIdList = NullableDocumentId[] | null; + +interface TagOption { + id?: DocumentId; + label?: string; + name?: string; + color?: string | null; +} + +interface CorrespondentOption { + id?: DocumentId; + name?: string; + label?: string; +} + +import type { Document } from '../../../types/documents'; + +interface BulkTagMutationArgs { + label: string; + input: unknown; + documentIds: DocumentId[]; +} + +interface BulkCorrespondentAddArgs { + name: string; + input: unknown; + documentIds: DocumentId[]; +} + +interface BulkCorrespondentRemoveArgs { + assignments: Array<{ correspondent_id: DocumentId }>; + documentIds: DocumentId[]; +} + +interface SelectionFloatingActionsProps { + selectionCount?: number; + selectedDocumentIds?: SelectedIdList; + selectedFolderIds?: SelectedIdList; + documentLookup?: Map<DocumentId, Document> | null; + tags?: TagOption[] | null; + tagLookupById?: Map<DocumentId, TagOption> | null; + correspondents?: CorrespondentOption[] | null; + correspondentLookupById?: Map<DocumentId, CorrespondentOption> | null; + onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void; + onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void; + onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | void; + onBulkCorrespondentRemove?: (args: BulkCorrespondentRemoveArgs) => Promise<void> | void; + onBulkReanalyze?: (documentIds: DocumentId[]) => Promise<void> | void; + onDeleteSelection?: () => void; + onClearSelection?: () => void; + onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId | null) => Promise<void> | void; +} + +const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] => + Array.isArray(selectedIds) + ? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined) + : []; + +const buildTagAssignments = ( + selectedDocuments: Document[], + tagLookupById: Map<DocumentId, TagOption> | null, + tags: TagOption[] | null, + total: number, +): SelectionAssignmentMenuItem[] => { + if (!total) { + return []; + } + + const map = new Map<string, { + id?: DocumentId; + label: string; + color: string | null; + count: number; + total: number; + }>(); + + const ensureEntry = (id?: DocumentId, label?: string, color: string | null = null) => { + const key = id ?? label; + if (!key || !label) { + return null; + } + if (!map.has(key)) { + map.set(key, { + id, + label, + color, + count: 0, + total, + }); + } + return map.get(key) ?? null; + }; + + selectedDocuments.forEach((doc) => { + (doc?.tags || []).forEach((tagId) => { + const tag = tagLookupById instanceof Map ? tagLookupById.get(tagId) : null; + const lookupColor = tag?.color ?? null; + const label = tag?.label; + + const entry = ensureEntry(tagId, label, lookupColor); + if (entry) { + entry.count += 1; + } + }); + }); + + (tags || []).forEach((tag) => { + const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null; + ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null); + }); + + return Array.from(map.values()).map((entry) => { + const count = entry.count || 0; + const state = count === total ? 'all' : count > 0 ? 'partial' : 'none'; + return { + id: entry.id ?? entry.label, + label: entry.label, + color: entry.color ?? null, + count, + total, + state, + payload: entry, + }; + }); +}; + +const buildCorrespondentAssignments = ( + selectedDocuments: Document[], + correspondents: CorrespondentOption[] | null, + correspondentLookupById: Map<DocumentId, CorrespondentOption> | null, + total: number, +): SelectionAssignmentMenuItem[] => { + if (!total) { + return []; + } + + const map = new Map<string, { + id?: DocumentId; + label: string; + count: number; + total: number; + }>(); + + const ensureEntry = (id?: DocumentId, name?: string) => { + const key = id ?? name; + if (!key || !name) { + return null; + } + if (!map.has(key)) { + map.set(key, { + id, + label: name, + count: 0, + total, + }); + } + return map.get(key) ?? null; + }; + + selectedDocuments.forEach((doc) => { + (doc?.correspondents || []).forEach((correspondentId) => { + const resolved = correspondentLookupById instanceof Map ? correspondentLookupById.get(correspondentId) : null; + const name = resolved?.name; + + const target = ensureEntry(correspondentId, name); + if (target) { + target.count += 1; + } + }); + }); + + (correspondents || []).forEach((entry) => { + ensureEntry(entry?.id, entry?.name || entry?.label); + }); + + return Array.from(map.values()).map((entry) => { + const count = entry.count || 0; + const state = count === total ? 'all' : count > 0 ? 'partial' : 'none'; + return { + id: entry.id ?? entry.label, + label: entry.label, + count, + total, + state, + payload: entry, + }; + }); +}; + +const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({ + selectionCount = 0, + selectedDocumentIds = [], + selectedFolderIds = [], + documentLookup, + tags = [], + tagLookupById, + correspondents = [], + correspondentLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + onDeleteSelection, + onClearSelection = null, + onMoveDocumentsToFolder, +}) => { + + + const documentLookupMap = useMemo(() => ( + documentLookup instanceof Map ? documentLookup : new Map<DocumentId, Document>() + ), [documentLookup]); + const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null; + + const shell = useAppShell() as any; + const foldersManager = shell.folderTree?.foldersManager as FoldersManager; + + const [remoteFolderTree, setRemoteFolderTree] = useState<FolderTreeNode[]>([]); + + // Sync with manager + const treeSnapshot = useSyncExternalStore( + useCallback(cb => foldersManager.subscribe(cb), [foldersManager]), + () => foldersManager.getTreeSnapshot(), + () => foldersManager.getTreeSnapshot(), + ); + + const dataSnapshot = useSyncExternalStore( + useCallback(cb => foldersManager.subscribe(cb), [foldersManager]), + () => foldersManager.getSnapshot(), + () => foldersManager.getSnapshot(), + ); + + useEffect(() => { + if (!treeSnapshot || treeSnapshot.length === 0) { + setRemoteFolderTree([]); + return; + } + + const map = dataSnapshot; + + const mergeNode = (node: FolderTreeNode): FolderTreeNode => { + const liveData = map.get(node.id); + const name = liveData?.name ?? node.name; + const children = node.children ? node.children.map(mergeNode) : []; + return { ...node, name, children }; + }; + + const mergedTree = treeSnapshot.map(mergeNode); + setRemoteFolderTree(mergedTree); + }, [treeSnapshot, dataSnapshot]); + + const requestFolderTree = useCallback(() => { + foldersManager.ensureTree(); + }, [foldersManager]); + + + const handleMoveMenuOpen = useCallback(() => { + requestFolderTree(); + }, [requestFolderTree]); + + const documentIdList = useMemo<DocumentId[]>( + () => normalizeDocumentList(selectedDocumentIds), + [selectedDocumentIds], + ); + + const folderIdList = useMemo<DocumentId[]>( + () => normalizeDocumentList(selectedFolderIds), + [selectedFolderIds], + ); + + const documentCount = documentIdList.length; + const folderCount = folderIdList.length; + const totalCount = selectionCount ?? documentCount + folderCount; + + const selectedDocuments = useMemo<Document[]>(() => { + if (!documentIdList.length || !(documentLookupMap instanceof Map)) { + return []; + } + return documentIdList + .map((id) => documentLookupMap.get(id)) + .filter((doc): doc is Document => Boolean(doc)); + }, [documentIdList, documentLookupMap]); + + const selectedDocCount = selectedDocuments.length; + + const tagAssignments = useMemo( + () => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount), + [selectedDocuments, tagLookupMap, tags, selectedDocCount], + ); + + const correspondentAssignments = useMemo( + () => buildCorrespondentAssignments(selectedDocuments, correspondents, correspondentLookupById, selectedDocCount), + [selectedDocuments, correspondents, correspondentLookupById, selectedDocCount], + ); + + const handleToggleTagAssignment = useCallback( + async (item: SelectionAssignmentMenuItem) => { + if (!selectedDocCount || !item) { + return; + } + if (item.state === 'all') { + await onBulkTagRemove?.({ label: item.label || '', input: null, documentIds: documentIdList }); + } else { + await onBulkTagAdd?.({ label: item.label || '', input: null, documentIds: documentIdList }); + } + }, + [selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList], + ); + + const handleCreateTagAssignment = useCallback( + async (label: string) => { + if (!selectedDocCount || !label) { + return; + } + await onBulkTagAdd?.({ label, input: null, documentIds: documentIdList }); + }, + [selectedDocCount, onBulkTagAdd, documentIdList], + ); + + const handleToggleCorrespondentAssignment = useCallback( + async (item: SelectionAssignmentMenuItem) => { + if (!selectedDocCount || !item) { + return; + } + if (item.state === 'all') { + if (!item.id) { + return; + } + await onBulkCorrespondentRemove?.({ + assignments: [{ correspondent_id: item.id }], + documentIds: documentIdList, + }); + } else { + await onBulkCorrespondentAdd?.({ name: item.label || '', input: null, documentIds: documentIdList }); + } + }, + [selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList], + ); + + const handleCreateCorrespondentAssignment = useCallback( + async (name: string) => { + if (!selectedDocCount || !name) { + return; + } + await onBulkCorrespondentAdd?.({ name, input: null, documentIds: documentIdList }); + }, + [selectedDocCount, onBulkCorrespondentAdd, documentIdList], + ); + + const handleMoveSelectionToFolder = useCallback( + async (folderId: DocumentId | null) => { + const itemsToMove = [...documentIdList, ...folderIdList]; + if (!itemsToMove.length || !onMoveDocumentsToFolder) { + return; + } + await onMoveDocumentsToFolder(itemsToMove, folderId); + }, + [documentIdList, folderIdList, onMoveDocumentsToFolder], + ); + + const summaryNode = totalCount > 0 ? ( + <SelectionSummary + documentCount={documentCount} + folderCount={folderCount} + totalCount={totalCount} + /> + ) : null; + + const showPrimaryButtons = Boolean(onBulkReanalyze || onDeleteSelection || onClearSelection); + + const rootTitle = (remoteFolderTree && remoteFolderTree.length === 1) + ? remoteFolderTree[0].name + : DEFAULT_FOLDER_NAME; + + const moveMenu = onMoveDocumentsToFolder ? ( + <SelectionFolderMenu + label="Move" + triggerContent={( + <span className="quick-add__chip-label" title="Move"> + <FolderOutlineIcon className="icon-inline" aria-hidden="true" /> + <span className="quick-add__chip-text" aria-hidden="true">Move</span> + </span> + )} + folderTree={remoteFolderTree || []} + placeholder="Search folders…" + emptyMessage="No folders" + onSelectFolder={handleMoveSelectionToFolder} + disabled={!documentCount && !folderCount} + onOpenMenu={handleMoveMenuOpen} + rootTitle={rootTitle} + /> + ) : null; + + const primaryButtons = showPrimaryButtons ? ( + <div className="panel-floating__buttons"> + {onBulkReanalyze ? ( + <button + type="button" + className="icon-button panel-floating-actions__button" + onClick={() => onBulkReanalyze(documentIdList)} + aria-label="Re-run analysis for selection" + title="Re-run analysis for selection" + disabled={documentIdList.length === 0} + > + <AnalyzeIcon className="icon-inline" /> + </button> + ) : null} + {onDeleteSelection ? ( + <button + type="button" + className="icon-button danger panel-floating-actions__button" + onClick={onDeleteSelection} + aria-label="Delete selected items" + disabled={totalCount === 0} + > + <TrashIcon className="icon-inline" /> + </button> + ) : null} + {onClearSelection ? ( + <button + type="button" + className="icon-button panel-floating-actions__button" + onClick={onClearSelection} + aria-label="Clear selection" + title="Clear selection" + disabled={totalCount === 0} + > + <IconX className="icon-inline" /> + </button> + ) : null} + </div> + ) : null; + + return ( + <> + {summaryNode ? ( + <span className="panel-floating__label">{summaryNode}</span> + ) : null} + <div className="panel-floating-actions panel-floating-actions--assignments"> + {moveMenu} + <SelectionAssignmentMenu + label="Tags" + triggerContent={( + <span className="quick-add__chip-label" title="Tags"> + <TagIcon className="icon-inline" aria-hidden="true" /> + <span className="quick-add__chip-text" aria-hidden="true">Tags</span> + </span> + )} + items={tagAssignments} + placeholder="Search tags…" + emptyMessage="No tags" + createLabel="Create" + onToggle={handleToggleTagAssignment} + onCreate={handleCreateTagAssignment} + disabled={!documentCount} + /> + <SelectionAssignmentMenu + label="Correspondents" + triggerContent={( + <span className="quick-add__chip-label" title="Correspondents"> + <CorrespondentIcon className="icon-inline" aria-hidden="true" /> + <span className="quick-add__chip-text" aria-hidden="true">Correspondents</span> + </span> + )} + items={correspondentAssignments} + placeholder="Search correspondents…" + emptyMessage="No correspondents" + createLabel="Create" + onToggle={handleToggleCorrespondentAssignment} + onCreate={handleCreateCorrespondentAssignment} + disabled={!documentCount} + /> + </div> + {primaryButtons} + </> + ); +}; + +type SelectionFloatingPanelProps = Omit<SelectionFloatingActionsProps, 'selectedDocumentIds' | 'selectedFolderIds' | 'selectionCount'>; + +export const SelectionFloatingPanel: React.FC<SelectionFloatingPanelProps> = ({ onClearSelection, ...rest }) => { + const { selectedDocumentIds, selectedFolderIds, clearSelection } = useWorkspaceSelectionContext(); + const documentIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; + const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : []; + const selectionCount = documentIds.length + folderIds.length; + if (selectionCount === 0) { + return null; + } + const handleClear = onClearSelection || clearSelection; + return ( + <div className="panel-floating-region" aria-live="polite" aria-atomic="true"> + <div className="panel-floating"> + <SelectionFloatingActions + selectionCount={selectionCount} + selectedDocumentIds={documentIds} + selectedFolderIds={folderIds} + onClearSelection={handleClear} + {...rest} + /> + </div> + </div> + ); +}; diff --git a/frontend/src/documents/features/selection/SelectionFolderMenu.tsx b/frontend/src/documents/features/selection/SelectionFolderMenu.tsx new file mode 100644 index 0000000..0f6f2fc --- /dev/null +++ b/frontend/src/documents/features/selection/SelectionFolderMenu.tsx @@ -0,0 +1,302 @@ +import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import useFloatingMenu from '../../../components/useFloatingMenu'; +import { + ArrowLeftIcon, + FolderIcon, + FolderMoveIcon, +} from '../../../components/icons'; +import type { FolderTreeNode } from '../../../lib/api/apiTypes'; +import type { DocumentId } from '../../../types/identifiers'; + +interface SelectionFolderMenuProps { + label: React.ReactNode; + folderTree?: FolderTreeNode[]; + onSelectFolder?: (folderId: DocumentId | null) => Promise<void> | void; + disabled?: boolean; + className?: string; + triggerContent?: React.ReactNode; + triggerClassName?: string; + placeholder?: string; + emptyMessage?: string; + onOpenMenu?: () => void; + positionStrategy?: 'absolute' | 'fixed'; + rootTitle?: string; +} + +const SelectionFolderMenu: React.FC<SelectionFolderMenuProps> = ({ + label, + folderTree = [], + onSelectFolder, + disabled = false, + className, + triggerContent = null, + triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger', + placeholder = 'Search folders…', + emptyMessage = 'No folders', + onOpenMenu, + positionStrategy = 'absolute', + rootTitle = 'Folders', +}) => { + const anchorRef = useRef<HTMLButtonElement | null>(null); + const inputRef = useRef<HTMLInputElement | null>(null); + const [query, setQuery] = useState(''); + const [currentFolderId, setCurrentFolderId] = useState<DocumentId | null>(null); + const [pending, setPending] = useState(false); + + const { + isOpen, + toggle, + close, + menuRef, + menuStyle, + updatePosition, + } = useFloatingMenu({ + anchorRef, + align: 'center', + positionStrategy, + minWidth: 260, + }) as { + isOpen: boolean; + toggle: () => void; + close: () => void; + menuRef: React.MutableRefObject<HTMLDivElement | null>; + menuStyle: CSSProperties | null; + updatePosition: () => void; + }; + + useEffect(() => { + if (disabled && isOpen) { + close(); + } + }, [disabled, isOpen, close]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + setQuery(''); + setCurrentFolderId('root'); + setPending(false); + const frame = requestAnimationFrame(() => { + updatePosition(); + if (inputRef.current) { + inputRef.current.focus(); + } + }); + return () => cancelAnimationFrame(frame); + }, [isOpen, updatePosition]); + + // Build a flat map for easy lookup + const { nodeMap, parentMap } = useMemo(() => { + const nMap = new Map<DocumentId, FolderTreeNode>(); + const pMap = new Map<DocumentId, DocumentId>(); + + const traverse = (nodes: FolderTreeNode[], parentId: DocumentId | null) => { + nodes.forEach((node) => { + nMap.set(node.id, node); + if (parentId) { + pMap.set(node.id, parentId); + } + if (node.children) { + traverse(node.children, node.id); + } + }); + }; + traverse(folderTree, null); + return { nodeMap: nMap, parentMap: pMap }; + }, [folderTree]); + + const currentFolder = currentFolderId ? nodeMap.get(currentFolderId) : null; + + const isSearching = query.trim().length > 0; + + const displayedItems = useMemo(() => { + if (isSearching) { + const search = query.trim().toLowerCase(); + const results: FolderTreeNode[] = []; + nodeMap.forEach((node) => { + if (node.name.toLowerCase().includes(search)) { + results.push(node); + } + }); + return results; + } + return currentFolderId + ? (nodeMap.get(currentFolderId)?.children || []) + : folderTree; + }, [isSearching, query, currentFolderId, nodeMap, folderTree]); + + const handleTriggerClick = useCallback(() => { + if (disabled) { + return; + } + if (!isOpen) { + onOpenMenu?.(); + } + toggle(); + }, [disabled, isOpen, onOpenMenu, toggle]); + + const handleSelect = useCallback( + async (folderId: DocumentId | null) => { + if (!onSelectFolder) return; + setPending(true); + try { + await onSelectFolder(folderId); + close(); + } catch (error) { + console.error('Failed to move to folder', error); + } finally { + setPending(false); + } + }, + [onSelectFolder, close] + ); + + const handleNavigate = (folderId: DocumentId) => { + setCurrentFolderId(folderId); + setQuery(''); // Clear search on navigation + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const handleUp = () => { + if (!currentFolderId) return; + const parentId = parentMap.get(currentFolderId) || null; + setCurrentFolderId(parentId); + }; + + return ( + <div className={className ? `selection-assignment ${className}` : 'selection-assignment'}> + <button + type="button" + ref={anchorRef} + className={triggerClassName} + onClick={handleTriggerClick} + aria-haspopup="menu" + aria-expanded={isOpen} + disabled={disabled} + > + {triggerContent ? triggerContent : ( + <span className="quick-add__chip-label"> + {label} + </span> + )} + </button> + {isOpen ? ( + <div + className="menu menu--floating selection-assignment__menu" + ref={menuRef} + style={menuStyle || undefined} + role="menu" + data-floating-position + > + <div className="selection-assignment__header"> + {/* Search Bar */} + <div className="selection-assignment__form"> + <input + ref={inputRef} + type="text" + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + disabled={pending} + /> + </div> + + {/* Navigation Header (only if not searching) */} + {!isSearching && ( + <div className="selection-assignment__header-nav"> + <div className="selection-assignment__nav-title"> + {currentFolderId && currentFolderId !== 'root' ? ( + <button + type="button" + className="icon-button" + onClick={handleUp} + aria-label="Go up" + title="Go up" + > + <ArrowLeftIcon size="1em" /> + </button> + ) : null} + <span + className="selection-assignment__folder-name" + > + {currentFolder ? currentFolder.name : rootTitle} + </span> + </div> + + <div className="selection-assignment__nav-actions"> + <button + type="button" + className="icon-button" + onClick={() => handleSelect(currentFolderId)} + disabled={pending} + title="Move here" + aria-label="Move here" + > + <FolderMoveIcon size="1em" /> + </button> + </div> + </div> + )} + </div> + + <div className="selection-assignment__list" role="presentation"> + {displayedItems.length ? ( + displayedItems.map((item) => { + const hasChildren = item.children && item.children.length > 0; + return ( + <div + key={item.id} + className={`menu__item selection-assignment__item${!hasChildren ? ' selection-assignment__item--empty' : ''}`} + role="menuitem" + > + {/* Clickable area to navigate down */} + <button + type="button" + className="selection-assignment__item-content" + onClick={() => hasChildren && handleNavigate(item.id)} + style={{ cursor: hasChildren ? 'pointer' : 'default' }} + > + <FolderIcon className="selection-assignment__icon" aria-hidden="true" /> + <span className="selection-assignment__folder-name"> + {item.name} + {isSearching && parentMap.get(item.id) && ( + <span className="selection-assignment__folder-path"> + (in {nodeMap.get(parentMap.get(item.id)!)?.name}) + </span> + )} + </span> + </button> + + {/* Move Button for this specific folder */} + <div className="selection-assignment__item-actions"> + <button + type="button" + className="icon-button" + onClick={(e) => { + e.stopPropagation(); + handleSelect(item.id); + }} + title={`Move to ${item.name}`} + aria-label={`Move to ${item.name}`} + > + <FolderMoveIcon size="1em" /> + </button> + </div> + </div> + ); + }) + ) : ( + <div className="menu__empty selection-assignment__empty">{emptyMessage}</div> + )} + </div> + </div> + ) : null} + </div> + ); +}; + +export default SelectionFolderMenu; diff --git a/frontend/src/documents/features/selection/SelectionSummary.tsx b/frontend/src/documents/features/selection/SelectionSummary.tsx new file mode 100644 index 0000000..40902f7 --- /dev/null +++ b/frontend/src/documents/features/selection/SelectionSummary.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { FileIcon, FolderOutlineIcon } from '../../../components/icons'; + +interface SelectionSummaryProps { + documentCount?: number; + folderCount?: number; + totalCount?: number; +} + +const SelectionSummary: React.FC<SelectionSummaryProps> = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => { + const docCount = Number(documentCount) || 0; + const folderCountNumber = Number(folderCount) || 0; + const aggregateCount = docCount + folderCountNumber; + const resolvedTotal = Number(totalCount) || aggregateCount; + + if (!docCount && !folderCountNumber && !resolvedTotal) { + return null; + } + + const tokens = []; + + if (docCount) { + tokens.push({ + key: 'documents', + count: docCount, + icon: <FileIcon className="selection-summary__icon" size={16} />, + }); + } + + if (folderCountNumber) { + tokens.push({ + key: 'folders', + count: folderCountNumber, + icon: <FolderOutlineIcon className="selection-summary__icon" size={16} />, + }); + } + + if (!tokens.length) { + const count = resolvedTotal; + return ( + <span className="selection-summary selection-summary--text"> + {`${count} item${count === 1 ? '' : 's'}`} + </span> + ); + } + + return ( + <span className="selection-summary"> + {tokens.map((token, index) => ( + <React.Fragment key={token.key}> + {index > 0 ? <span className="selection-summary__separator">·</span> : null} + <span className="selection-summary__token"> + <span className="selection-summary__count">{token.count}</span> + {token.icon} + </span> + </React.Fragment> + ))} + </span> + ); +}; + +export default SelectionSummary; diff --git a/frontend/src/documents/features/selection/useDocumentsSelection.ts b/frontend/src/documents/features/selection/useDocumentsSelection.ts new file mode 100644 index 0000000..26af9f5 --- /dev/null +++ b/frontend/src/documents/features/selection/useDocumentsSelection.ts @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { createDocumentEntryKey, createFolderEntryKey, isFolderEntry } from '../../../app/entryKey'; +import type { DocumentId, FolderId } from '../../../types/identifiers'; + +interface FolderEntry { + id: FolderId; + [key: string]: unknown; +} + +interface DocumentEntry { + id: DocumentId; + [key: string]: unknown; +} + +interface NavigableRow { + key: string; + type: 'folder' | 'document'; + id: FolderId | DocumentId; +} + +interface UseDocumentsSelectionOptions { + showingSearchResults?: boolean; + currentSubfolders?: FolderEntry[]; + visibleDocuments?: DocumentEntry[]; + configureSelectionEnvironment: (config: { visibleEntryKeySet: Set<string>; navigableEntryKeys: string[] }) => void; + visibleEntryKeySet: Set<string>; + selectedEntries: string[]; + selectionAnchorRef: { current: string | null }; + promoteSelectionOrderRaw: (id: DocumentId) => void; + setFocusedDocumentId: (id: DocumentId | null) => void; + setActiveViewerId: (id: DocumentId | null) => void; + clearSelection: () => void; + focusedDocumentId: DocumentId | null; + setFocusedEntryKey: Dispatch<SetStateAction<string | null>>; + focusedEntryKey: string | null; +} + +const useDocumentsSelection = ({ + showingSearchResults, + currentSubfolders = [], + visibleDocuments = [], + configureSelectionEnvironment, + visibleEntryKeySet, + selectedEntries, + selectionAnchorRef, + promoteSelectionOrderRaw, + setFocusedDocumentId, + setActiveViewerId, + clearSelection, + focusedDocumentId, + setFocusedEntryKey, + focusedEntryKey, +}: UseDocumentsSelectionOptions) => { + const navigableRows = useMemo<NavigableRow[]>(() => { + const entries: NavigableRow[] = []; + if (!showingSearchResults) { + currentSubfolders.forEach((folder) => { + const key = createFolderEntryKey(folder.id); + if (key) { + entries.push({ key, type: 'folder', id: folder.id }); + } + }); + } + visibleDocuments.forEach((doc) => { + const key = createDocumentEntryKey(doc.id); + if (key) { + entries.push({ key, type: 'document', id: doc.id }); + } + }); + return entries; + }, [showingSearchResults, currentSubfolders, visibleDocuments]); + + const navigableRowKeys = useMemo( + () => navigableRows.map((entry) => entry.key), + [navigableRows], + ); + + useEffect(() => { + configureSelectionEnvironment({ + visibleEntryKeySet, + navigableEntryKeys: navigableRowKeys, + }); + }, [configureSelectionEnvironment, visibleEntryKeySet, navigableRowKeys]); + + const promoteSelectionOrder = useCallback( + (docId: DocumentId | null) => { + if (!docId) return; + promoteSelectionOrderRaw(docId); + const rowKey = createDocumentEntryKey(docId); + if (rowKey) { + selectionAnchorRef.current = rowKey; + } + setFocusedDocumentId(docId); + setActiveViewerId(docId); + }, + [promoteSelectionOrderRaw, selectionAnchorRef, setFocusedDocumentId, setActiveViewerId], + ); + + const clearDocumentSelection = useCallback(() => { + clearSelection(); + }, [clearSelection]); + + const prevFocusedDocIdRef = useRef<DocumentId | null>(focusedDocumentId); + useEffect(() => { + const previous = prevFocusedDocIdRef.current; + if (previous === focusedDocumentId) { + return; + } + prevFocusedDocIdRef.current = focusedDocumentId; + if (focusedDocumentId) { + setFocusedEntryKey(createDocumentEntryKey(focusedDocumentId)); + } else { + setFocusedEntryKey((current) => (current && isFolderEntry(current) ? current : null)); + } + }, [focusedDocumentId, setFocusedEntryKey]); + + useEffect(() => { + if (!navigableRowKeys.length) { + if (focusedEntryKey) { + setFocusedEntryKey(null); + } + return; + } + + if (focusedEntryKey && navigableRowKeys.includes(focusedEntryKey)) { + return; + } + + const docKey = focusedDocumentId ? createDocumentEntryKey(focusedDocumentId) : null; + if (docKey && navigableRowKeys.includes(docKey)) { + setFocusedEntryKey(docKey); + return; + } + + const selectedKey = selectedEntries.find((key) => navigableRowKeys.includes(key)); + if (selectedKey) { + setFocusedEntryKey(selectedKey); + return; + } + + if (focusedEntryKey) { + setFocusedEntryKey(null); + } + }, [focusedEntryKey, focusedDocumentId, navigableRowKeys, selectedEntries, setFocusedEntryKey]); + + return { + navigableRows, + navigableRowKeys, + promoteSelectionOrder, + clearDocumentSelection, + }; +}; + +export default useDocumentsSelection; diff --git a/frontend/src/documents/features/selection/useEntryPointer.ts b/frontend/src/documents/features/selection/useEntryPointer.ts new file mode 100644 index 0000000..b43f5ab --- /dev/null +++ b/frontend/src/documents/features/selection/useEntryPointer.ts @@ -0,0 +1,74 @@ +import { useCallback } from 'react'; +import { createDocumentEntryKey, createFolderEntryKey } from '../../../app/entryKey'; + +type PointerEventLike = MouseEvent | PointerEvent; + +export const isPointerModifierEvent = (event?: PointerEventLike | null): boolean => + Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey)); + +export const isPrimaryPointerEvent = (event?: PointerEventLike | null): boolean => { + if (!event) { + return true; + } + if (event.button !== 0) { + return false; + } + const type = event?.type?.toLowerCase?.() ?? ''; + return type === 'click' || type === 'pointerdown' || type === 'pointerup'; +}; + +type EntryType = 'document' | 'folder'; + +interface WorkspaceEntry { + id: string; + key?: string; + type: EntryType; + [key: string]: unknown; +} + +interface UseEntryPointerOptions { + onSelectEntry?: (entry: WorkspaceEntry, event?: PointerEventLike | null, metadata?: EntryPointerMetadata) => void; + onDocumentActivate?: (id: string, metadata?: EntryPointerMetadata) => void; +} + +interface EntryPointerMetadata { + modifierClick: boolean; + primaryClick: boolean; + rowKey: string; + type: EntryType; + id: string; +} + +export const useEntryPointer = ({ + onSelectEntry, + onDocumentActivate, +}: UseEntryPointerOptions) => + useCallback( + (entry?: WorkspaceEntry | null, event?: PointerEventLike | null) => { + if (!entry || !entry.id) { + return; + } + + const { type, id } = entry; + if (type !== 'document' && type !== 'folder') { + return; + } + + const rowKey = entry.key + || (type === 'document' ? createDocumentEntryKey(id) : createFolderEntryKey(id)); + if (!rowKey) { + return; + } + + const modifierClick = isPointerModifierEvent(event); + const primaryClick = isPrimaryPointerEvent(event); + const metadata: EntryPointerMetadata = { modifierClick, primaryClick, rowKey, type, id }; + + onSelectEntry?.(entry, event, metadata); + + if (type === 'document' && !modifierClick && primaryClick) { + onDocumentActivate?.(id, metadata); + } + }, + [onSelectEntry, onDocumentActivate], + ); diff --git a/frontend/src/documents/features/selection/useWorkspaceSelectionSync.ts b/frontend/src/documents/features/selection/useWorkspaceSelectionSync.ts new file mode 100644 index 0000000..5be3051 --- /dev/null +++ b/frontend/src/documents/features/selection/useWorkspaceSelectionSync.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import type { MutableRefObject } from 'react'; +import type { Identifier } from '../../../types/identifiers'; + +interface UseWorkspaceSelectionSyncArgs { + showingSearchResults: boolean; + searchQuery: string; + setSelectedEntries: (entries: Array<string>) => void; + setSelectionOrder: (order: Array<string>) => void; + selectionOrderRef: MutableRefObject<Array<string>>; + selectionAnchorRef: MutableRefObject<Identifier | string | null>; + setFocusedDocumentId: (id: Identifier | null) => void; + selectedDocumentIds: Identifier[]; + activeViewerId: Identifier | null; + setActiveViewerId: (id: Identifier | null) => void; + selectionInitializedRef: MutableRefObject<boolean>; +} + +const useWorkspaceSelectionSync = ({ + showingSearchResults, + searchQuery, + setSelectedEntries, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + setFocusedDocumentId, + selectedDocumentIds, + activeViewerId, + setActiveViewerId, + selectionInitializedRef, +}: UseWorkspaceSelectionSyncArgs) => { + useEffect(() => { + if (!showingSearchResults) { + return; + } + setSelectedEntries([]); + setSelectionOrder([]); + selectionOrderRef.current = []; + selectionAnchorRef.current = null; + setFocusedDocumentId(null); + }, [ + showingSearchResults, + searchQuery, + setSelectedEntries, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + setFocusedDocumentId, + ]); + + useEffect(() => { + if (!selectedDocumentIds.length) { + return; + } + if (!selectedDocumentIds.includes(activeViewerId as Identifier)) { + setActiveViewerId(selectedDocumentIds[selectedDocumentIds.length - 1]); + } + selectionInitializedRef.current = true; + }, [selectedDocumentIds, activeViewerId, selectionInitializedRef, setActiveViewerId]); +}; + +export default useWorkspaceSelectionSync; diff --git a/frontend/src/documents/features/tagging/tagTransfer.ts b/frontend/src/documents/features/tagging/tagTransfer.ts new file mode 100644 index 0000000..c132b6c --- /dev/null +++ b/frontend/src/documents/features/tagging/tagTransfer.ts @@ -0,0 +1,224 @@ +import type { DocumentId, TagId } from '../../../types/identifiers'; +import { TAG_MIME_TYPES, TAG_TEXT_MIME_TYPE } from '../../../constants/documents'; + +interface TagPayload { + id: TagId; + label: string; + sourceDocId: DocumentId | null; +} + +interface TagLike { + id?: TagId; + label?: string | null; +} + +const serializePayload = (payload: TagPayload): string | null => { + try { + return JSON.stringify(payload); + } catch (error) { + console.warn('[tagTransfer] Failed to serialize payload', error); + return null; + } +}; + +const createTagTransferPayload = ( + tag?: TagLike | null, + sourceDocId: DocumentId | null = null, +): TagPayload | null => { + if (!tag || tag.id == null) { + return null; + } + + return { + id: tag.id, + label: tag.label || '', + sourceDocId: sourceDocId ?? null, + }; +}; + +// Shared state to track dragged tag ID across components (Sidebar <-> Workspace) +// This is necessary because dataTransfer payload is inaccessible during dragOver. +interface ActiveDragState { + tagId: TagId | null; + sourceDocId: DocumentId | null; +} + +let activeDragState: ActiveDragState = { tagId: null, sourceDocId: null }; +interface ActionResult { + type: 'attach' | 'detach'; + success: boolean; +} + + +let pendingActions = 0; +let actionResults: ActionResult[] = []; +let toastListener: ((message: string, type: 'success' | 'error' | 'info') => void) | null = null; + +const listeners = new Set<(state: ActiveDragState) => void>(); + +export const getActiveDragState = (): ActiveDragState => activeDragState; + +export const subscribeToToast = (callback: (message: string, type: 'success' | 'error' | 'info') => void): () => void => { + toastListener = callback; + return () => { + toastListener = null; + }; +}; + +export const subscribeToTagDrag = (callback: (state: ActiveDragState) => void): () => void => { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; +}; + +const processResults = () => { + if (pendingActions > 0) return; + if (actionResults.length === 0) return; + + const successes = actionResults.filter(r => r.success); + const attached = successes.find(r => r.type === 'attach'); + const detached = successes.find(r => r.type === 'detach'); + + try { + if (attached && detached) { + toastListener?.('Tag moved.', 'success'); + } else if (attached) { + toastListener?.('Tag assigned.', 'success'); + } else if (detached) { + toastListener?.('Tag removed.', 'success'); + } else if (actionResults.some(r => !r.success)) { + // If we only had failures, or partial failures + toastListener?.('Action failed.', 'error'); + } + } finally { + actionResults = []; + } +}; + +export const beginAction = (): void => { + pendingActions++; +}; + +export const finishAction = (result: ActionResult): void => { + actionResults.push(result); + pendingActions--; + // Use timeout to allow batching if multiple actions finish closely or sequence gaps + setTimeout(processResults, 50); +}; + +const notifyListeners = () => { + listeners.forEach((cb) => cb(activeDragState)); +}; + +export const clearTagTransferData = (): void => { + activeDragState = { tagId: null, sourceDocId: null }; + notifyListeners(); +}; + +export const writeTagTransferData = ( + dataTransfer: DataTransfer | null, + tag: TagLike, + sourceDocId: DocumentId | null = null, +): void => { + // Track globally for cursor logic + activeDragState = { + tagId: tag.id || null, + sourceDocId: sourceDocId || null, + }; + notifyListeners(); + + if (!dataTransfer) { + return; + } + + const payload = createTagTransferPayload(tag, sourceDocId); + if (!payload) { + return; + } + + const serialized = serializePayload(payload); + if (!serialized) { + return; + } + + try { + TAG_MIME_TYPES.forEach((mime) => { + dataTransfer.setData(mime, serialized); + }); + if (payload.label) { + dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label); + } + } catch (error) { + console.warn('[tagTransfer] Failed to write drag data', error); + } +}; + +const readTagTransferData = (dataTransfer?: DataTransfer | null): string | null => { + if (!dataTransfer) { + return null; + } + + for (let index = 0; index < TAG_MIME_TYPES.length; index += 1) { + const type = TAG_MIME_TYPES[index]; + try { + const raw = dataTransfer.getData(type); + if (raw) { + return raw; + } + } catch (error) { + console.warn('[tagTransfer] Failed to read drag data for type', type, error); + } + } + return null; +}; + +type DragEventLike = DragEvent | DataTransfer | { + dataTransfer?: DataTransfer | null; + type?: string; + preventDefault?: () => void; + stopPropagation?: () => void; +}; + +export const parseTagTransferPayload = (input: DataTransfer | DragEventLike | null): TagPayload | null => { + let dataTransfer: DataTransfer | null = null; + if (input instanceof DataTransfer) { + dataTransfer = input; + } else if (input && Object(input) === input && 'dataTransfer' in (input as Record<string, unknown>)) { + const candidate = (input as { dataTransfer?: DataTransfer | null }).dataTransfer; + if (candidate) { + dataTransfer = candidate; + } + } + const raw = readTagTransferData(dataTransfer || null); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as TagPayload; + } catch (error) { + console.warn('[tagTransfer] Failed to parse drag payload', error); + } + + return null; +}; + +export const isTagTransferEvent = (event?: DragEventLike | null): boolean => { + if (!event) { + return false; + } + let types: DOMStringList | ReadonlyArray<string> | undefined; + if (event instanceof DataTransfer) { + types = event.types; + } else if (Object(event) === event && 'dataTransfer' in (event as Record<string, unknown>)) { + const payload = (event as { dataTransfer?: DataTransfer | null }).dataTransfer; + types = payload?.types; + } + if (!types) { + return false; + } + const typeList = Array.isArray(types) ? [...types] : Array.from(types); + return TAG_MIME_TYPES.some((type) => typeList.includes(type)); +}; + diff --git a/frontend/src/documents/features/upload/useDocumentDragHandlers.ts b/frontend/src/documents/features/upload/useDocumentDragHandlers.ts new file mode 100644 index 0000000..3427c9c --- /dev/null +++ b/frontend/src/documents/features/upload/useDocumentDragHandlers.ts @@ -0,0 +1,413 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { DragEvent } from 'react'; +import { createDocumentEntryKey, createFolderEntryKey } from '../../../app/entryKey'; +import type { FolderId, Identifier } from '../../../types/identifiers'; + +type FolderIdentifier = FolderId | 'root'; +type FolderInput = FolderIdentifier | number; + +import type { Document } from '../../../types/documents'; + +type ApplySelectionFn = ( + keys: string[], + options?: { anchor: string | null; interactedKeys?: string[] }, +) => void; + +type HandleEntrySelectionFn = ( + key: string, + event: { preventDefault?: () => void }, +) => void; + +interface UseDocumentDragHandlersOptions { + selectedEntries: string[]; + selectedDocumentIds: Identifier[]; + selectedFolderIds: FolderInput[]; + applySelection: ApplySelectionFn; + handleEntrySelection: HandleEntrySelectionFn; + documentLookup: Map<Identifier, Document>; + setDraggedDocumentIds: (ids: Identifier[] | []) => void; + setDraggedFolderId: (id: FolderIdentifier | null) => void; + documentsViewMode: string; +} + +const useDocumentDragHandlers = ({ + selectedEntries, + selectedDocumentIds, + selectedFolderIds, + applySelection, + handleEntrySelection, + documentLookup, + setDraggedDocumentIds, + setDraggedFolderId, + documentsViewMode: _documentsViewMode, +}: UseDocumentDragHandlersOptions) => { + const dragPreviewRef = useRef<HTMLDivElement | null>(null); + const normalizedFolderIds = useMemo( + () => selectedFolderIds.map((id) => (id === 'root' ? 'root' : String(id))) as FolderIdentifier[], + [selectedFolderIds], + ); + + const destroyDragPreview = useCallback(() => { + const node = dragPreviewRef.current; + if (node && node.parentNode) { + node.parentNode.removeChild(node); + } + dragPreviewRef.current = null; + }, []); + + useEffect(() => destroyDragPreview, [destroyDragPreview]); + + const createDragPreview = useCallback( + ({ documents = [], folders = [], prioritizeFolders = false }: { documents?: Document[]; folders?: FolderIdentifier[]; prioritizeFolders?: boolean } = {}) => { + destroyDragPreview(); + + const docEntries = (documents || []).filter(Boolean); + const folderEntries = (folders || []).filter(Boolean); + const totalCount = docEntries.length + folderEntries.length; + if (!totalCount) { + return null; + } + + const maxVisible = 4; + const size = 64; + const canvasSize = Math.round(size * 1.6); + + const visibleItems: Array<{ type: 'document' | 'folder'; payload: any }> = []; + + let takeDocs = 0; + let takeFolders = 0; + + if (docEntries.length > 0 && folderEntries.length > 0) { + if (prioritizeFolders) { + // Folders on top (added last) + takeDocs = Math.min(docEntries.length, maxVisible - 1); + takeFolders = Math.min(folderEntries.length, maxVisible - takeDocs); + } else { + // Docs on top (added last) + takeFolders = Math.min(folderEntries.length, maxVisible - 1); + takeDocs = Math.min(docEntries.length, maxVisible - takeFolders); + } + } else { + takeDocs = Math.min(docEntries.length, maxVisible); + takeFolders = Math.min(folderEntries.length, maxVisible - takeDocs); + } + + if (prioritizeFolders) { + // Docs at bottom + docEntries.slice(0, takeDocs).forEach((doc) => { + visibleItems.push({ type: 'document', payload: doc }); + }); + // Folders at top + folderEntries.slice(0, takeFolders).forEach((folderId) => { + visibleItems.push({ type: 'folder', payload: folderId }); + }); + } else { + // Folders at bottom + folderEntries.slice(0, takeFolders).forEach((folderId) => { + visibleItems.push({ type: 'folder', payload: folderId }); + }); + // Docs at top + docEntries.slice(0, takeDocs).forEach((doc) => { + visibleItems.push({ type: 'document', payload: doc }); + }); + } + + const wrapper = document.createElement('div'); + wrapper.className = 'document-drag-preview'; + wrapper.style.setProperty('--drag-preview-size', `${canvasSize}px`); + wrapper.style.width = `${canvasSize}px`; + wrapper.style.height = `${canvasSize}px`; + + visibleItems.forEach((item, index) => { + const layer = document.createElement('div'); + layer.className = 'document-drag-preview__item'; + layer.style.setProperty('--index', String(index)); + const rotationMagnitude = Math.random() * 8 + 2; + const rotation = (index % 2 === 0 ? 1 : -1) * rotationMagnitude; + layer.style.setProperty('--rotation-deg', `${rotation}deg`); + + if (item.type === 'document') { + const doc = item.payload; + const rowEl = doc?.id + ? document.getElementById(`document-${doc.id}`) + : null; + const wrapperEl = rowEl instanceof HTMLElement + ? rowEl.querySelector<HTMLElement>('.document-thumbnail-wrapper') + : null; + const thumbnailEl = rowEl instanceof HTMLElement + ? rowEl.querySelector<HTMLImageElement>('.document-thumbnail') + : null; + const placeholderEl = rowEl instanceof HTMLElement + ? rowEl.querySelector<HTMLElement>('.thumb-placeholder') + : null; + const aspectAttr = wrapperEl?.dataset?.thumbnailAspect; + const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null; + + let thumbWidth = size; + let thumbHeight = size; + if (aspectRatio > 0) { + if (aspectRatio >= 1) { + thumbWidth = size; + thumbHeight = Math.max(size / aspectRatio, size * 0.5); + } else { + thumbHeight = size; + thumbWidth = Math.max(size * aspectRatio, size * 0.5); + } + } + layer.style.width = `${Math.round(thumbWidth)}px`; + layer.style.height = `${Math.round(thumbHeight)}px`; + + const thumbSrc = thumbnailEl?.currentSrc || thumbnailEl?.src || null; + if (thumbSrc) { + layer.classList.add('document-drag-preview__item--image'); + layer.style.backgroundImage = `url("${thumbSrc}")`; + } else if (placeholderEl instanceof HTMLElement) { + const clone = placeholderEl.cloneNode(true) as HTMLElement; + clone.style.pointerEvents = 'none'; + layer.appendChild(clone); + } else { + layer.textContent = doc?.title || 'Document'; + } + } else { + const payload = item.payload; + const folderId = payload as FolderIdentifier; + const rowEl = folderId + ? document.getElementById(`folder-${folderId}`) + : null; + const iconEl = rowEl instanceof HTMLElement + ? rowEl.querySelector('.thumb-icon, .folder-card__icon') + : null; + layer.style.width = `${size}px`; + layer.style.height = `${size}px`; + layer.classList.add('document-drag-preview__item--folder'); + + let content: HTMLElement | SVGElement | null = null; + if (iconEl instanceof HTMLElement) { + const cloneSource = iconEl.classList.contains('folder-card__icon') + ? iconEl.querySelector('svg') || iconEl + : iconEl; + const clone = cloneSource.cloneNode(true); + if (clone instanceof HTMLElement || clone instanceof SVGElement) { + content = clone as HTMLElement | SVGElement; + content.classList.add('document-drag-preview__folder-thumb'); + const svg = content.nodeName.toLowerCase() === 'svg' + ? content + : content.querySelector('svg'); + if (svg) { + svg.setAttribute('width', '48'); + svg.setAttribute('height', '48'); + } + } + } + + if (!content) { + content = document.createElement('div'); + content.className = 'document-drag-preview__folder-placeholder'; + content.textContent = 'Folder'; + } + + layer.appendChild(content); + } + + wrapper.appendChild(layer); + }); + + if (totalCount > 1) { + const badge = document.createElement('div'); + badge.className = 'document-drag-preview__count'; + badge.textContent = `${totalCount}`; + wrapper.appendChild(badge); + } + + document.body.appendChild(wrapper); + dragPreviewRef.current = wrapper; + return wrapper; + }, + [destroyDragPreview], + ); + + const handleDocumentDragStart = useCallback( + (event: DragEvent<HTMLElement>, documentOrId: Document | Identifier | null) => { + const documentId: Identifier | null = Object(documentOrId) === documentOrId + ? (documentOrId as Document)?.id ?? null + : (documentOrId as Identifier | null); + if (!documentId) { + return; + } + + const documentKey = createDocumentEntryKey(documentId); + if (!documentKey) { + return; + } + + const isAlreadySelected = selectedDocumentIds.includes(documentId); + const selection: Identifier[] = isAlreadySelected + ? [...selectedDocumentIds] + : [documentId]; + + // If the document is part of the selection, we also want to include any selected folders + const folderSelection: FolderIdentifier[] = isAlreadySelected + ? normalizedFolderIds + : []; + + if (!isAlreadySelected) { + applySelection([documentKey], { + anchor: documentKey, + interactedKeys: [documentKey], + }); + } + + const previewDocs = selection + .map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null) + .filter(Boolean); + const previewNode = createDragPreview({ + documents: previewDocs, + folders: folderSelection, + prioritizeFolders: false, + }); + + setDraggedDocumentIds(selection); + if (folderSelection.length) { + setDraggedFolderId(folderSelection[0] || null); + } + event.dataTransfer.effectAllowed = 'move'; + try { + event.dataTransfer.setData( + 'application/x-papercrate-doc-list', + JSON.stringify(selection), + ); + if (folderSelection.length) { + event.dataTransfer.setData( + 'application/x-papercrate-folder-list', + JSON.stringify(folderSelection), + ); + if (folderSelection.length === 1) { + event.dataTransfer.setData('application/x-papercrate-folder', folderSelection[0]); + } + } + } catch (error) { + console.warn('[documents] Failed to populate drag payload', error); + } + if (previewNode) { + const width = previewNode.offsetWidth || 96; + const height = previewNode.offsetHeight || 96; + event.dataTransfer.setDragImage(previewNode, width / 2, height / 2); + } + event.currentTarget.classList.add('dragging'); + }, + [ + selectedDocumentIds, + applySelection, + documentLookup, + createDragPreview, + setDraggedFolderId, + setDraggedDocumentIds, + normalizedFolderIds, + ], + ); + + const handleDocumentDragEnd = useCallback( + (event: DragEvent<HTMLElement>) => { + setDraggedDocumentIds([]); + event.currentTarget.classList.remove('dragging'); + destroyDragPreview(); + setDraggedFolderId(null); + }, + [destroyDragPreview, setDraggedFolderId, setDraggedDocumentIds], + ); + + const handleFolderDragStart = useCallback( + (event: DragEvent<HTMLElement>, folderId: FolderInput) => { + const normalizedFolderId: FolderIdentifier = folderId === 'root' ? 'root' : String(folderId); + if (normalizedFolderId === 'root') { + return; + } + event.stopPropagation(); + const folderKey = createFolderEntryKey(normalizedFolderId); + const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false; + + let effectiveFolderSelection: FolderIdentifier[] = normalizedFolderIds; + let effectiveDocumentSelection: Identifier[] = selectedDocumentIds; + + if (!isAlreadySelected && folderKey) { + effectiveFolderSelection = [normalizedFolderId]; + effectiveDocumentSelection = []; + handleEntrySelection(folderKey, { preventDefault: () => { } }); + } + + const uniqueFolders = effectiveFolderSelection.length + ? Array.from(new Set(effectiveFolderSelection.filter(Boolean))) + : [normalizedFolderId]; + + setDraggedFolderId(normalizedFolderId); + if (effectiveDocumentSelection.length) { + setDraggedDocumentIds(effectiveDocumentSelection); + } + + event.dataTransfer.effectAllowed = 'move'; + try { + event.dataTransfer.setData( + 'application/x-papercrate-folder-list', + JSON.stringify(uniqueFolders), + ); + if (uniqueFolders.length === 1) { + event.dataTransfer.setData('application/x-papercrate-folder', uniqueFolders[0]); + } + if (effectiveDocumentSelection.length) { + event.dataTransfer.setData( + 'application/x-papercrate-doc-list', + JSON.stringify(effectiveDocumentSelection), + ); + } + } catch (error) { + console.warn('[documents] Failed to populate folder drag payload', error); + } + + const previewNode = createDragPreview({ + documents: effectiveDocumentSelection + .map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null) + .filter(Boolean), + folders: uniqueFolders, + prioritizeFolders: true, + }); + event.currentTarget.classList.add('dragging'); + + if (previewNode) { + const width = previewNode.offsetWidth || 96; + const height = previewNode.offsetHeight || 96; + event.dataTransfer.setDragImage(previewNode, width / 2, height / 2); + } + }, + [ + normalizedFolderIds, + selectedEntries, + selectedDocumentIds, + handleEntrySelection, + setDraggedFolderId, + setDraggedDocumentIds, + documentLookup, + createDragPreview, + ], + ); + + const handleFolderDragEnd = useCallback( + (event?: DragEvent<HTMLElement>) => { + if (event?.currentTarget) { + event.currentTarget.classList.remove('dragging'); + } + setDraggedFolderId(null); + setDraggedDocumentIds([]); + destroyDragPreview(); + }, + [setDraggedFolderId, setDraggedDocumentIds, destroyDragPreview], + ); + + return { + handleDocumentDragStart, + handleDocumentDragEnd, + handleFolderDragStart, + handleFolderDragEnd, + }; +}; + +export default useDocumentDragHandlers; diff --git a/frontend/src/documents/features/upload/useDocumentUploads.ts b/frontend/src/documents/features/upload/useDocumentUploads.ts new file mode 100644 index 0000000..38377f9 --- /dev/null +++ b/frontend/src/documents/features/upload/useDocumentUploads.ts @@ -0,0 +1,514 @@ +import { useCallback, useRef, useState } from 'react'; +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import useFileDrop from './useFileDrop'; +import { useStatusToast } from '../../../lib/context/StatusToastContext'; +import { DEFAULT_FOLDER_NAME, hasFiles } from '../../../app/workspaceUtils'; +import { fetchDocument, uploadDocument, resolveFolderPath, listFolderContents } from '../../../lib/api/apiClient'; +import type { Identifier } from '../../../types/identifiers'; + +type FolderId = Identifier | 'root' | null; + +type FileEntry = { + file: File; + segments: string[]; +}; + +type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error'; + +type UploadQueueItem = { + id: string; + name: string; + size: number | null; + folderId: FolderId; + status: UploadStatus; + error: string | null; + code: number | null; + document: unknown; + conflictDocumentId: Identifier | null; +}; + +type NotifyApiError = (error: unknown, fallbackMessage?: string) => void; + +type DropOverlayState = { + active: boolean; + folderName: string; +}; + +type FileSystemEntryLike = FileSystemEntry; + +type ExtendedDataTransferItem = DataTransferItem & { + webkitGetAsEntry?: () => FileSystemEntry | null; +}; + +interface FileSystemDirectoryReaderLike { + readEntries: ( + successCallback: (entries: FileSystemEntryLike[]) => void, + errorCallback: (error: DOMException) => void, + ) => void; +} + +interface FileSystemFileEntryLike extends FileSystemEntry { + isFile: true; + isDirectory: false; + name: string; + file: ( + successCallback: (file: File) => void, + errorCallback: (error: DOMException) => void, + ) => void; +} + +interface FileSystemDirectoryEntryLike extends FileSystemEntry { + isFile: false; + isDirectory: true; + name: string; + createReader: () => FileSystemDirectoryReaderLike; +} +const isFileEntry = (entry: FileSystemEntryLike): entry is FileSystemFileEntryLike => { + return entry.isFile && !entry.isDirectory; +}; + +const isDirectoryEntry = (entry: FileSystemEntryLike): entry is FileSystemDirectoryEntryLike => { + return entry.isDirectory && !entry.isFile && 'createReader' in entry; +}; + +const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => { + if (!filesInput) { + return []; + } + const files = Array.isArray(filesInput) ? filesInput : Array.from(filesInput); + return files + .filter(Boolean) + .map((file) => { + const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? ''; + const segments = relativePath + ? relativePath + .split('/') + .slice(0, -1) + .filter(Boolean) + : []; + return { file, segments }; + }); +}; + +interface UseDocumentUploadsArgs { + selectedFolder?: FolderId; + currentFolderName?: string | null; + refreshCurrentFolder: () => Promise<void>; + shellRef: MutableRefObject<HTMLElement | null>; + notifyApiError?: NotifyApiError; +} + +interface UseDocumentUploadsResult { + dropOverlayState: DropOverlayState; + setDropOverlayState: Dispatch<SetStateAction<DropOverlayState>>; + dragCounterRef: MutableRefObject<number>; + handleFileDrop: (dataTransfer: DataTransfer, targetFolderId?: FolderId) => Promise<void>; + handleFileSelection: (files?: FileList | null, targetFolderId?: FolderId) => Promise<void>; + uploadFile: (file: File, targetFolderId: FolderId) => Promise<{ + document: unknown; + duplicate: boolean; + statusCode: number | null; + conflictDocumentId: Identifier | null; + }>; + extractFilesFromDataTransfer: (dataTransfer: DataTransfer) => Promise<FileEntry[]>; + resetUploadsState: () => void; + uploadQueue: UploadQueueItem[]; + clearUploadQueue: () => void; +} + +import useNotifyApiError from '../../../hooks/useNotifyApiError'; + +const useDocumentUploads = ({ + selectedFolder, + currentFolderName, + refreshCurrentFolder, + shellRef, +}: UseDocumentUploadsArgs): UseDocumentUploadsResult => { + const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({ + active: false, + folderName: currentFolderName || DEFAULT_FOLDER_NAME, + }); + const dragCounterRef = useRef(0); + const folderPathCacheRef = useRef<Map<string, FolderId>>(new Map()); + const queueIdRef = useRef(0); + const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]); + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + + const uploadFile = useCallback( + async (file: File, targetFolderId: FolderId) => { + if (!file || file.size === 0) { + return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null }; + } + + const formData = new FormData(); + formData.append('file', file, file.name); + if (targetFolderId != null && targetFolderId !== 'root') { + formData.append('folder_id', String(targetFolderId)); + } + + try { + const { reused, document, status } = await uploadDocument(formData); + const duplicate = reused || status === 200; + return { + document: document ?? null, + duplicate, + statusCode: status ?? (duplicate ? 200 : 201), + conflictDocumentId: null, + }; + } catch (error: any) { + if (error.response?.status === 409) { + const conflictId = error.response?.data?.details?.conflict_document_id ?? null; + let conflictDocument = null; + if (conflictId) { + try { + conflictDocument = await fetchDocument(conflictId); + } catch (fetchError) { + console.warn('[Uploads] failed to fetch conflict document', fetchError); + } + } + return { + document: conflictDocument, + duplicate: true, + statusCode: 409, + conflictDocumentId: conflictId, + }; + } + const message = error.response?.data?.error || `Failed to upload ${file.name}.`; + notifyApiError?.(error, message); + showToast(message, 'error'); + const wrapped = Object.assign(new Error(message), { response: error.response }); + throw wrapped; + } + }, + [notifyApiError, showToast], + ); + + const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => { + const baseId = Date.now(); + const items = entries.map(({ file }) => { + queueIdRef.current += 1; + return { + id: `upload-${baseId}-${queueIdRef.current}`, + name: file?.name || 'Unnamed file', + size: file?.size ?? null, + folderId: targetFolderId ?? selectedFolder ?? 'root', + status: 'pending' as UploadStatus, + error: null, + code: null, + document: null, + conflictDocumentId: null, + } satisfies UploadQueueItem; + }); + if (items.length) { + setUploadQueue((current) => [...current, ...items]); + } + return items; + }, [selectedFolder]); + + const updateQueueItem = useCallback((id: string, patch: Partial<UploadQueueItem>) => { + if (!id) { + return; + } + setUploadQueue((current) => + current.map((item) => (item.id === id ? { ...item, ...patch } : item)), + ); + }, []); + + const ensureFolderPathOnServer = useCallback( + async (baseFolderId: FolderId, segments: string[]): Promise<FolderId> => { + const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean); + if (trimmedSegments.length === 0) { + return baseFolderId ?? null; + } + + const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`; + const cache = folderPathCacheRef.current; + if (cache.has(cacheKey)) { + return cache.get(cacheKey) ?? null; + } + + const payload = { + parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null, + segments: trimmedSegments, + }; + + const { folder } = await resolveFolderPath(payload); + const resolvedId = (folder?.id ?? null) as FolderId; + cache.set(cacheKey, resolvedId); + return resolvedId; + }, + [], + ); + + const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => { + if (!dataTransfer) { + throw new Error('No drop payload found.'); + } + + const items = Array.from(dataTransfer.items || []) as ExtendedDataTransferItem[]; + console.info('[Uploads] drop start', { + items: items.length, + files: (dataTransfer.files || []).length, + }); + + const results: FileEntry[] = []; + const seenKeys = new Set(); + + const pushFile = (file?: File | null, ancestors: string[] = []) => { + if (!file) return; + const segments = (ancestors || []).filter(Boolean); + const key = `${segments.join('/')}/${file.name}:${file.size}`; + if (seenKeys.has(key)) { + return; + } + seenKeys.add(key); + results.push({ file, segments }); + }; + + const readAllEntries = async (reader: FileSystemDirectoryReaderLike) => { + const entries: FileSystemEntryLike[] = []; + let batch: FileSystemEntryLike[] = []; + do { + batch = await new Promise<FileSystemEntryLike[]>((resolve, reject) => reader.readEntries(resolve, reject)); + if (batch.length) { + entries.push(...batch); + } + } while (batch.length); + return entries; + }; + + const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => { + if (!entry) return; + if (isFileEntry(entry)) { + const file = await new Promise<File>((resolve, reject) => { + try { + entry.file(resolve, reject); + } catch (error) { + console.warn('[Uploads] entry.file failed', error); + reject(error as Error); + } + }); + pushFile(file, ancestors); + return; + } + if (isDirectoryEntry(entry)) { + const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; + const reader = entry.createReader(); + const entries = await readAllEntries(reader); + for (const child of entries) { + await walkEntry(child, nextAncestors); + } + } + }; + + await Promise.all( + items.map(async (item, index) => { + if (item.kind !== 'file') return; + + const fileFromItem = item.getAsFile?.() ?? null; + if (fileFromItem) { + const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? ''; + const segments = relativePath + ? relativePath + .split('/') + .slice(0, -1) + .filter(Boolean) + : []; + pushFile(fileFromItem, segments); + } + + if ((item as ExtendedDataTransferItem).webkitGetAsEntry) { + try { + const entry = (item as ExtendedDataTransferItem).webkitGetAsEntry?.(); + if (entry) { + await walkEntry(entry, []); + return; + } + } catch (error) { + console.warn('[Uploads] webkitGetAsEntry failed', error); + } + } + + if (!fileFromItem) { + console.info('[Uploads] item missing file handle', index); + } + }), + ); + + Array.from(dataTransfer.files || []).forEach((file) => { + if (!file) return; + const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? ''; + const segments = relativePath + ? relativePath + .split('/') + .slice(0, -1) + .filter(Boolean) + : []; + pushFile(file, segments); + }); + + if (!results.length) { + throw new Error('No files detected in drop payload.'); + } + + console.info('[Uploads] prepared files', results.length); + + return results; + }, []); + + const uploadFileEntries = useCallback( + async (entries, targetFolderId) => { + if (!entries || !entries.length) { + console.warn('[Uploads] No files to upload.'); + return; + } + + const queueItems = appendQueueItems(entries, targetFolderId); + + try { + folderPathCacheRef.current.clear(); + + const baseFolderId = + targetFolderId && targetFolderId !== 'root' ? targetFolderId : null; + + for (let index = 0; index < entries.length; index += 1) { + const { file, segments } = entries[index]; + const queueItem = queueItems[index]; + if (queueItem) { + const patch = { status: 'uploading', error: null, code: null }; + updateQueueItem(queueItem.id, patch); + Object.assign(queueItem, patch); + } + const destinationId = segments.length + ? await ensureFolderPathOnServer(baseFolderId, segments) + : baseFolderId; + + const uploadTarget = + destinationId ?? + (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'); + + try { + const { duplicate, statusCode, document, conflictDocumentId } = await uploadFile( + file, + uploadTarget, + ); + if (queueItem) { + const patch = { + status: duplicate ? 'duplicate' : 'success', + code: statusCode ?? null, + document: document || queueItem.document, + conflictDocumentId: conflictDocumentId ?? queueItem.conflictDocumentId, + }; + updateQueueItem(queueItem.id, patch); + Object.assign(queueItem, patch); + } + } catch (error: any) { + if (queueItem) { + const patch = { + status: 'error', + error: error.response?.data?.error || error.message || 'Upload failed.', + code: error.response?.status ?? null, + }; + updateQueueItem(queueItem.id, patch); + Object.assign(queueItem, patch); + } + continue; + } + } + + await refreshCurrentFolder(); + + if ( + targetFolderId && + targetFolderId !== 'root' && + targetFolderId !== selectedFolder + ) { + await listFolderContents(targetFolderId); + } + } catch (error: any) { + const message = error.message || 'Failed to upload files.'; + queueItems.forEach((item) => { + if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') { + return; + } + const patch = { + status: 'error', + error: message, + code: error.response?.status ?? null, + }; + updateQueueItem(item.id, patch); + Object.assign(item, patch); + }); + console.error('[Uploads] batch failed', error); + } + }, + [ + ensureFolderPathOnServer, + uploadFile, + refreshCurrentFolder, + selectedFolder, + appendQueueItems, + updateQueueItem, + ], + ); + + const handleFileDrop = useCallback( + async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => { + let extracted: FileEntry[]; + try { + extracted = await extractFilesFromDataTransfer(dataTransfer); + } catch (error) { + console.error('[Uploads] Failed to process dropped files.', error); + return; + } + + await uploadFileEntries(extracted, targetFolderId); + }, + [extractFilesFromDataTransfer, uploadFileEntries], + ); + + const handleFileSelection = useCallback( + async (files?: FileList | null, targetFolderId?: FolderId) => { + const entries = mapFilesToEntries(files); + await uploadFileEntries(entries, targetFolderId); + }, + [uploadFileEntries], + ); + + useFileDrop({ + shellRef, + currentFolderName, + selectedFolder, + handleFileDrop, + hasFiles, + defaultFolderName: DEFAULT_FOLDER_NAME, + dragCounterRef, + setDropOverlayState, + }); + + const resetUploadsState = useCallback(() => { + dragCounterRef.current = 0; + setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); + setUploadQueue([]); + }, []); + + const clearUploadQueue = useCallback(() => { + setUploadQueue([]); + }, []); + + return { + dropOverlayState, + setDropOverlayState, + dragCounterRef, + handleFileDrop, + handleFileSelection, + uploadFile, + extractFilesFromDataTransfer, + resetUploadsState, + uploadQueue, + clearUploadQueue, + } satisfies UseDocumentUploadsResult; +}; + +export default useDocumentUploads; diff --git a/frontend/src/documents/features/upload/useFileDrop.ts b/frontend/src/documents/features/upload/useFileDrop.ts new file mode 100644 index 0000000..2a2afb0 --- /dev/null +++ b/frontend/src/documents/features/upload/useFileDrop.ts @@ -0,0 +1,94 @@ +import { MutableRefObject, useEffect } from 'react'; + +type FolderId = string | 'root' | null; + +interface DropOverlayState { + active: boolean; + folderName: string | null; +} + +interface UseFileDropOptions { + shellRef: MutableRefObject<HTMLElement | null>; + currentFolderName: string | null; + selectedFolder: FolderId; + handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void>; + hasFiles: (event: DragEvent) => boolean; + defaultFolderName: string; + dragCounterRef: MutableRefObject<number>; + setDropOverlayState: (updater: ((prev: DropOverlayState) => DropOverlayState) | DropOverlayState) => void; +} + +const useFileDrop = ({ + shellRef, + currentFolderName, + selectedFolder, + handleFileDrop, + hasFiles, + defaultFolderName, + dragCounterRef, + setDropOverlayState, +}: UseFileDropOptions) => { + + useEffect(() => { + const handleDragEnter = (event: DragEvent) => { + if (!hasFiles(event)) return; + event.preventDefault(); + dragCounterRef.current += 1; + setDropOverlayState({ active: true, folderName: currentFolderName }); + }; + + const handleDragOver = (event: DragEvent) => { + if (!hasFiles(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }; + + const handleDragLeave = (event: DragEvent) => { + if (!hasFiles(event)) return; + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) { + setDropOverlayState((prev) => ({ ...prev, active: false })); + } + }; + + const handleDrop = async (event: DragEvent) => { + if (!hasFiles(event)) return; + event.preventDefault(); + dragCounterRef.current = 0; + setDropOverlayState((prev) => ({ ...prev, active: false })); + await handleFileDrop(event.dataTransfer, selectedFolder); + }; + + const dropTarget = shellRef.current; + if (!dropTarget) { + return undefined; + } + + dropTarget.addEventListener('dragenter', handleDragEnter); + dropTarget.addEventListener('dragover', handleDragOver); + dropTarget.addEventListener('dragleave', handleDragLeave); + dropTarget.addEventListener('drop', handleDrop); + + return () => { + dropTarget.removeEventListener('dragenter', handleDragEnter); + dropTarget.removeEventListener('dragover', handleDragOver); + dropTarget.removeEventListener('dragleave', handleDragLeave); + dropTarget.removeEventListener('drop', handleDrop); + dragCounterRef.current = 0; + setDropOverlayState((prev) => ({ ...prev, active: false })); + }; + }, [ + handleFileDrop, + currentFolderName, + defaultFolderName, + selectedFolder, + hasFiles, + shellRef, + dragCounterRef, + setDropOverlayState, + ]); + + return null; +}; + +export default useFileDrop; diff --git a/frontend/src/documents/interactions/useTagInteractions.ts b/frontend/src/documents/interactions/useTagInteractions.ts new file mode 100644 index 0000000..5ae7402 --- /dev/null +++ b/frontend/src/documents/interactions/useTagInteractions.ts @@ -0,0 +1,261 @@ +import { + useCallback, + useEffect, + useRef, +} from 'react'; +import React from 'react'; +import { + isTagTransferEvent, + parseTagTransferPayload, + writeTagTransferData, + getActiveDragState, + clearTagTransferData, + beginAction, + finishAction, +} from '../../documents/features/tagging/tagTransfer'; +import type { Identifier } from '../../types/identifiers'; +import type { Document, Tag } from '../../types/documents'; + +const preventAll = (event?: React.SyntheticEvent | Event | null) => { + if (!event) return; + if (typeof event.preventDefault === 'function') event.preventDefault(); + if (typeof event.stopPropagation === 'function') event.stopPropagation(); +}; + +const createDragPreview = (node: EventTarget | null, clientX: number, clientY: number) => { + if (!(node instanceof HTMLElement)) { + return null; + } + const rect = node.getBoundingClientRect(); + const offsetX = Math.min(Math.max(clientX - rect.left, 0), rect.width); + const offsetY = Math.min(Math.max(clientY - rect.top, 0), rect.height); + const clone = node.cloneNode(true) as HTMLElement; + clone.style.position = 'absolute'; + clone.style.top = '-9999px'; + clone.style.left = '-9999px'; + clone.style.pointerEvents = 'none'; + clone.style.opacity = '1'; + clone.style.transform = 'none'; + document.body.appendChild(clone); + return { clone, offsetX, offsetY }; +}; + +const cleanupPreview = (previewNode: HTMLElement | null) => { + if (previewNode && previewNode.parentNode) { + previewNode.parentNode.removeChild(previewNode); + } +}; + +interface UseTagInteractionsArgs { + onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => Promise<boolean> | void; + onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => Promise<boolean> | void; + onTagClick?: (tagId: Identifier) => void; +} + +interface DraggingTagState { + element: HTMLElement | null; + previewClone?: HTMLElement; +} + +export interface TagInteractionHandlers { + onTagDragEnter: (event: React.DragEvent<HTMLDivElement>, docId: Identifier) => void; + onTagDragOver: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void; + onTagDragLeave: (event: React.DragEvent<HTMLDivElement>, docId: Identifier) => void; + onTagDrop: (event: React.DragEvent<HTMLDivElement>, doc: Document) => void; + onTagDragStart: (event: React.DragEvent<HTMLElement>, doc: Document, tag: Tag) => void; + onTagDragEnd: (event: React.DragEvent<HTMLElement>) => void; + onTagClick?: (tagId: Identifier) => void; +} + +export const useTagInteractions = ({ + onAssignTagToDocument, + onRemoveTagFromDocument, + onTagClick, +}: UseTagInteractionsArgs): TagInteractionHandlers => { + const draggingTagRef = useRef<DraggingTagState | null>(null); + + const isTagTransfer = useCallback((event: React.DragEvent) => isTagTransferEvent(event), []); + + const onTagDragEnter = useCallback( + (event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => { + if (!isTagTransfer(event)) return; + preventAll(event); + event.currentTarget.classList.add('is-tag-target'); + }, + [isTagTransfer], + ); + + const onTagDragOver = useCallback( + (event: React.DragEvent<HTMLDivElement>, doc: Document) => { + if (!doc || !doc.id) return; + if (!isTagTransfer(event)) return; + preventAll(event); + + // Use shared state for all logic (Single Source of Truth) + const { tagId: draggedTagId, sourceDocId: draggedSourceId } = getActiveDragState(); + + const isAssigned = doc.tags?.some((t) => t === draggedTagId); + + if (event.dataTransfer) { + const isSource = draggedSourceId === doc.id; + + // Otherwise: separate document. + if (isSource || isAssigned) { + event.dataTransfer.dropEffect = 'none'; + event.currentTarget.classList.remove('is-tag-target'); + return; + } + + const isFromDocument = !!draggedSourceId; + if (isFromDocument) { + // Default to Move (transfer), allow Copy with Alt key + event.dataTransfer.dropEffect = event.altKey ? 'copy' : 'move'; + } else { + // Sidebar or external source: Copy only + event.dataTransfer.dropEffect = 'copy'; + } + + event.currentTarget.classList.add('is-tag-target'); + } + }, + [isTagTransfer], + ); + + const onTagDragLeave = useCallback( + (event: React.DragEvent<HTMLDivElement>, _docId: Identifier) => { + if (!isTagTransfer(event)) return; + // Ignore if leaving to a child element + if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget as Node)) { + return; + } + event.currentTarget.classList.remove('is-tag-target'); + }, + [isTagTransfer], + ); + + const onTagDrop = useCallback( + (event: React.DragEvent<HTMLElement>, doc: Document) => { + if (!event?.dataTransfer || !doc?.id) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const isTagTransfer = isTagTransferEvent(event); + if (!isTagTransfer) { + return; + } + + const payload = parseTagTransferPayload(event); + const element = event.currentTarget as HTMLElement; + element.classList.remove('is-tag-target'); + + if (!payload || !payload.id) { + return; + } + + setTimeout(async () => { + // Double-check assignment (even though cursor logic tries to prevent it) + const isAssigned = doc.tags?.some((t) => t === payload.id); + if (isAssigned) return; + + if (onAssignTagToDocument && doc.id) { + // Queue Result Logic + beginAction(); + try { + await onAssignTagToDocument(doc.id, payload.id); + finishAction({ type: 'attach', success: true }); + } catch { + finishAction({ type: 'attach', success: false }); + } + } + }, 0); + }, + [onAssignTagToDocument], + ); + + const onTagDragStart = useCallback( + (event: React.DragEvent<HTMLElement>, doc: Document, tag: Tag) => { + if (!event?.dataTransfer || !doc?.id || !tag?.id) { + return; + } + event.stopPropagation(); + event.dataTransfer.effectAllowed = 'copyMove'; + writeTagTransferData(event.dataTransfer, tag, doc.id); + + const pointerX = event.clientX; + const pointerY = event.clientY; + const { clone, offsetX, offsetY } = createDragPreview(event.currentTarget, pointerX, pointerY) || {}; + if (clone) { + event.dataTransfer.setDragImage(clone, offsetX || 0, offsetY || 0); + } + + const element = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; + if (element) { + element.classList.add('is-drag-hidden'); + } + + draggingTagRef.current = { + element, + previewClone: clone, + }; + }, + [], + ); + + const onTagDragEnd = useCallback( + (event: React.DragEvent<HTMLElement>) => { + event.stopPropagation(); + const { sourceDocId, tagId } = getActiveDragState(); + const dropEffect = event?.dataTransfer?.dropEffect; + + clearTagTransferData(); + + setTimeout(async () => { + const state = draggingTagRef.current; + if (state) { + const element = state.element; + if (element) { + element.classList.remove('is-drag-hidden'); + } + cleanupPreview(state.previewClone); + + // Remove if move operation completed + if (dropEffect === 'move') { + if (onRemoveTagFromDocument && sourceDocId && tagId) { + beginAction(); + try { + await onRemoveTagFromDocument(sourceDocId, tagId); + finishAction({ type: 'detach', success: true }); + } catch { + finishAction({ type: 'detach', success: false }); + } + } + } + } + draggingTagRef.current = null; + }, 0); + }, + [onRemoveTagFromDocument], + ); + + useEffect(() => { + return () => { + if (draggingTagRef.current && draggingTagRef.current.previewClone) { + cleanupPreview(draggingTagRef.current.previewClone); + } + draggingTagRef.current = null; + }; + }, []); + + return { + onTagDragEnter, + onTagDragOver, + onTagDragLeave, + onTagDrop, + onTagDragStart, + onTagDragEnd, + onTagClick, + }; +}; diff --git a/frontend/src/documents/logic/breadcrumbs.ts b/frontend/src/documents/logic/breadcrumbs.ts new file mode 100644 index 0000000..d6f1225 --- /dev/null +++ b/frontend/src/documents/logic/breadcrumbs.ts @@ -0,0 +1,38 @@ +import type { FolderNode } from '../../types/documents'; +import type { FolderNodeId } from '../../types/identifiers'; + +export const resolveBreadcrumbs = ( + startFolderId: FolderNodeId, + folderNodes: Map<FolderNodeId, FolderNode> +): FolderNode[] => { + const chain: FolderNode[] = []; + const seen = new Set<FolderNodeId>(); + let currentId: FolderNodeId | null = startFolderId; + let guard = 0; + + while (currentId && !seen.has(currentId) && guard < 64) { + guard += 1; + seen.add(currentId); + + const node = folderNodes.get(currentId); + if (node) { + chain.push(node); + currentId = node.parentId as FolderNodeId; + } else { + break; + } + } + + const ordered: FolderNode[] = []; + const seenOrdered = new Set<FolderNodeId>(); + + for (let i = chain.length - 1; i >= 0; i--) { + const crumb = chain[i]; + if (!seenOrdered.has(crumb.id)) { + seenOrdered.add(crumb.id); + ordered.push(crumb); + } + } + + return ordered; +}; diff --git a/frontend/src/documents/logic/useDocumentItemLogic.ts b/frontend/src/documents/logic/useDocumentItemLogic.ts new file mode 100644 index 0000000..d9d6b19 --- /dev/null +++ b/frontend/src/documents/logic/useDocumentItemLogic.ts @@ -0,0 +1,100 @@ +import React, { type DragEvent } from 'react'; +import { createDocumentEntryKey } from '../../app/entryKey'; +import { useDocumentOpen } from '../../lib/context/DocumentOpenContext'; +import type { Document } from '../../types/documents'; +import { useDocumentsCommandContext } from '../context/DocumentsCommandContext'; +import { useDocumentsViewStateContext } from '../context/DocumentsViewStateContext'; +import type { DocumentViewLogic } from './useDocumentViewLogic'; +import { TagInteractionHandlers } from '../interactions/useTagInteractions'; + +interface UseDocumentItemLogicArgs { + doc: Document; + tagHandlers?: TagInteractionHandlers; + viewLogic: DocumentViewLogic; // Retained from original props +} + +export const useDocumentItemLogic = ({ doc, tagHandlers, viewLogic }: UseDocumentItemLogicArgs) => { + const { + draggingDocumentIdsSet + } = useDocumentsViewStateContext(); + + const { + document: { + onDrag: { start: onDocumentDragStart, end: onDocumentDragEnd }, + onRename: onDocumentRename + }, + onEntryPointer + } = useDocumentsCommandContext(); + + const { + selectedDocumentIdsSet, + totalSelectionCount, + documentRename: { + editingId: editingDocumentId, + draftValue: documentDraft, + setDraftValue: setDocumentDraft, + beginEditing: beginDocumentEditing, + cancelEditing: cancelDocumentEditing, + submitEditing: submitDocumentEditing, + savingId: savingDocumentId, + attachInputRef: attachDocumentInputRef, + }, + handleEntrySelection, + } = viewLogic; + + const isSelected = selectedDocumentIdsSet?.has(doc.id); + const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id); + const isEditingDoc = editingDocumentId === doc.id; + const documentDraftValue = isEditingDoc ? documentDraft : doc.title; + const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : ''; + const isDocumentSaving = savingDocumentId === doc.id; + const canSubmitDocument = + isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title; + const allowInlineDocumentEdit = onDocumentRename && isSelected && totalSelectionCount === 1; + + const { openDocument } = useDocumentOpen(); + + const handlers = { + onClick: (event: React.MouseEvent) => { + if (onEntryPointer) { + onEntryPointer({ type: 'document', id: doc.id, key: createDocumentEntryKey(doc.id), document: doc }, event); + } else { + const key = createDocumentEntryKey(doc.id); + handleEntrySelection(key, event); + } + }, + onDoubleClick: (event: React.MouseEvent) => { + const isPreview = event && (event.altKey || event.button === 1); + openDocument(doc, isPreview ? 'preview' : 'inspect'); + }, + onDragStart: (event: DragEvent<HTMLElement>) => onDocumentDragStart?.(event, doc), + onDragEnd: (event: DragEvent<HTMLElement>) => onDocumentDragEnd?.(event), + onDragOver: (event: DragEvent<HTMLElement>) => tagHandlers?.onTagDragOver(event, doc), + onDragLeave: (event: DragEvent<HTMLElement>) => tagHandlers?.onTagDragLeave(event, doc.id), + onDrop: (event: DragEvent<HTMLElement>) => { tagHandlers?.onTagDrop(event, doc); }, + tagHandlers, + onRenameChange: setDocumentDraft, + onRenameSubmit: () => submitDocumentEditing(doc), + onRenameCancel: (event?: React.SyntheticEvent) => cancelDocumentEditing(event), + onRenameBegin: (event: React.SyntheticEvent) => { + if (!allowInlineDocumentEdit) return; + event.preventDefault(); + event.stopPropagation(); + beginDocumentEditing(doc); + }, + }; + + return { + isSelected, + isDraggingDoc, + isEditingDoc, + documentDraftValue, + isDocumentSaving, + canSubmitDocument, + allowInlineDocumentEdit, + attachDocumentInputRef, + handlers, + }; +}; + + diff --git a/frontend/src/documents/logic/useDocumentViewLogic.ts b/frontend/src/documents/logic/useDocumentViewLogic.ts new file mode 100644 index 0000000..8fecc7b --- /dev/null +++ b/frontend/src/documents/logic/useDocumentViewLogic.ts @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; +import useInlineRename from '../features/renaming/useInlineRename'; +import type { Document, Folder } from '../../types/documents'; + +interface UseDocumentViewLogicProps { + onDocumentRename?: (id: string, name: string) => Promise<boolean> | boolean; + onFolderRename?: (id: string, name: string) => Promise<boolean> | boolean; +} + +export const useDocumentViewLogic = ({ + onDocumentRename, + onFolderRename, +}: UseDocumentViewLogicProps) => { + const { + selectedDocumentIds, + selectedFolderIds, + clearSelection, + handleEntrySelection, + } = useWorkspaceSelectionContext(); + + const selectedDocumentIdsSet = useMemo(() => new Set(selectedDocumentIds), [selectedDocumentIds]); + const selectedFolderIdsSet = useMemo( + () => new Set(selectedFolderIds || []), + [selectedFolderIds], + ); + + const documentSelectionCount = selectedDocumentIdsSet.size; + const folderSelectionCount = selectedFolderIdsSet.size; + const totalSelectionCount = documentSelectionCount + folderSelectionCount; + + const documentRename = useInlineRename<Document>(onDocumentRename, { + getCurrentValue: (doc: Document) => doc?.title ?? '', + getEntityId: (doc: Document) => doc?.id ?? null, + }); + + const folderRename = useInlineRename<Folder>(onFolderRename, { + getCurrentValue: (folder: Folder) => folder?.name ?? '', + getEntityId: (folder: Folder) => folder?.id ?? null, + }); + + return { + selectedDocumentIdsSet, + selectedFolderIdsSet, + clearSelection, + handleEntrySelection, + totalSelectionCount, + documentRename, + folderRename, + }; +}; + +export type DocumentViewLogic = ReturnType<typeof useDocumentViewLogic>; diff --git a/frontend/src/documents/logic/useDocumentsNavigation.ts b/frontend/src/documents/logic/useDocumentsNavigation.ts new file mode 100644 index 0000000..55cdeb1 --- /dev/null +++ b/frontend/src/documents/logic/useDocumentsNavigation.ts @@ -0,0 +1,232 @@ +import React, { useCallback, useMemo } from 'react'; +import type { DocumentsListEntry } from '../../types/documents'; +import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; +import { useDocumentOpen } from '../../lib/context/DocumentOpenContext'; + +interface UseDocumentsNavigationProps { + entries: DocumentsListEntry[]; + onFolderSelect?: (folderId: string) => void; + viewMode?: string; + scrollRef?: React.RefObject<HTMLElement | null>; +} + +export const useDocumentsNavigation = ({ + entries, + onFolderSelect, + viewMode, + scrollRef, +}: UseDocumentsNavigationProps) => { + const { + selectedEntries, + focusedEntryKey, + setFocusedEntryKey, + handleEntrySelection, + applySelection, + } = useWorkspaceSelectionContext(); + + const { openDocument } = useDocumentOpen(); + + const navigableRows = useMemo( + () => entries.map((entry) => ({ key: entry.key, type: entry.type, id: entry.id })), + [entries], + ); + const navigableEntryKeys = useMemo(() => navigableRows.map((row) => row.key), [navigableRows]); + + const getEntryByKey = useCallback( + (entryKey: string) => entries.find((entry) => entry.key === entryKey) || null, + [entries], + ); + + const getGridColumns = useCallback(() => { + if (!scrollRef?.current) return 1; + const grid = scrollRef.current.querySelector('.documents-grid'); + if (!grid) return 1; + const style = window.getComputedStyle(grid); + const templateColumns = style.gridTemplateColumns; + if (!templateColumns) return 1; + return templateColumns.split(' ').length; + }, [scrollRef]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Only handle events that target the container directly + if (event.target !== event.currentTarget) { + return; + } + + const { key, shiftKey } = event; + const triggers = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' ', 'Space', 'Spacebar']; + if (!triggers.includes(key)) { + return; + } + + if (!navigableRows.length) { + return; + } + + // Allow default scrolling for Home/End if not preventing default + if (key !== 'Home' && key !== 'End') { + event.preventDefault(); + } else { + // Prevent default only if we are handling selection move, otherwise let browser scroll + event.preventDefault(); + } + + let activeKey = + focusedEntryKey && navigableEntryKeys.includes(focusedEntryKey) + ? focusedEntryKey + : null; + + if (!activeKey) { + if (selectedEntries.length) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableEntryKeys.includes(candidate)) { + activeKey = candidate; + break; + } + } + } + + if (!activeKey) { + activeKey = (key === 'ArrowUp' || key === 'ArrowLeft') + ? navigableEntryKeys[navigableEntryKeys.length - 1] + : navigableEntryKeys[0]; + } + } + + const currentIndex = navigableEntryKeys.indexOf(activeKey); + const activeRow = currentIndex === -1 ? null : navigableRows[currentIndex]; + + if (key === 'Enter' || key === ' ' || key === 'Space' || key === 'Spacebar') { + if (activeRow) { + event.preventDefault(); + // Do not call handleEntrySelection here, as it resets selection if multiple items are selected. + // Space/Enter should just trigger the action (Preview/Open) on the focused item + // without modifying the selection state. + + if (activeRow.type === 'folder') { + onFolderSelect?.(activeRow.id as string); + } else { + const entry = getEntryByKey(activeRow.key); + if (entry && entry.type === 'document') { + const isPreview = key === ' ' || key === 'Space' || key === 'Spacebar'; + openDocument(entry.document, isPreview ? 'preview' : 'inspect'); + } + } + } + return; + } + + let nextIndex = currentIndex; + const isGrid = viewMode === 'grid'; + const columns = isGrid ? getGridColumns() : 1; + + if (key === 'ArrowDown') { + if (isGrid) { + if (currentIndex + columns < navigableRows.length) { + nextIndex = currentIndex + columns; + } + } else { + nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableRows.length - 1); + } + } else if (key === 'ArrowUp') { + if (isGrid) { + if (currentIndex - columns >= 0) { + nextIndex = currentIndex - columns; + } + } else { + nextIndex = currentIndex === -1 ? navigableRows.length - 1 : Math.max(currentIndex - 1, 0); + } + } else if (key === 'ArrowLeft') { + nextIndex = Math.max(currentIndex - 1, 0); + } else if (key === 'ArrowRight') { + nextIndex = Math.min(currentIndex + 1, navigableRows.length - 1); + } else if (key === 'Home') { + nextIndex = 0; + } else if (key === 'End') { + nextIndex = navigableRows.length - 1; + } + + if (nextIndex === -1 || nextIndex >= navigableRows.length || nextIndex === currentIndex) { + return; + } + + const targetRow = navigableRows[nextIndex]; + if (!targetRow) { + return; + } + + setFocusedEntryKey(targetRow.key); + + if (shiftKey) { + // Additive selection for Shift+Arrow (Finder style) + const newSelection = Array.from(new Set([...selectedEntries, targetRow.key])); + applySelection(newSelection, { anchor: targetRow.key, interactedKeys: [targetRow.key] }); + } else { + handleEntrySelection(targetRow.key, { + shiftKey, + preventDefault: () => { }, + }); + } + }, + [ + focusedEntryKey, + getEntryByKey, + navigableEntryKeys, + navigableRows, + onFolderSelect, + selectedEntries, + openDocument, + handleEntrySelection, + setFocusedEntryKey, + viewMode, + getGridColumns, + applySelection, + ], + ); + + const handleFocus = useCallback(() => { + let resolvedKey = null; + + if (focusedEntryKey && navigableEntryKeys.includes(focusedEntryKey)) { + resolvedKey = focusedEntryKey; + } + + if (!resolvedKey) { + for (let index = selectedEntries.length - 1; index >= 0; index -= 1) { + const candidate = selectedEntries[index]; + if (navigableEntryKeys.includes(candidate)) { + resolvedKey = candidate; + break; + } + } + } + + if (!resolvedKey) { + if (!selectedEntries.length) { + return; + } + if (navigableRows.length) { + resolvedKey = navigableRows[0].key; + } + } + + if (!resolvedKey) { + return; + } + + setFocusedEntryKey(resolvedKey); + }, [ + focusedEntryKey, + navigableEntryKeys, + navigableRows, + setFocusedEntryKey, + selectedEntries, + ]); + + return { + handleKeyDown, + handleFocus, + }; +}; diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx new file mode 100644 index 0000000..b487630 --- /dev/null +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -0,0 +1,322 @@ +import React, { + useMemo, + useState, + useRef, + useEffect, + useCallback, +} from 'react'; +import TagRemovalZone from '../components/TagRemovalZone'; +import { DocumentsList, DocumentsGrid } from '../DocumentsView'; +import type { ReactNode } from 'react'; +import type { + DocumentsListEntry, +} from '../../types/documents'; + +const EntryType = { + folder: 'folder' as const, + document: 'document' as const, +}; + +import DesktopWorkspace from '../../desktop/components/DesktopWorkspace'; +import { + WorkspaceSelectionProvider, + useWorkspaceSelectionContext, +} from '../../app/WorkspaceSelectionContext'; +import DocumentsPanelHeader, { + DocumentsPanelHeaderConfig, +} from './DocumentsPanelHeader'; +import { SelectionFloatingPanel } from '../features/selection/SelectionFloatingActions'; +import { createDocumentsTableHeaderActions } from './DocumentsToolbar'; +import { useDocumentsFilter } from '../context/DocumentsFilterContext'; +import { + DEFAULT_GRID_ICON_SIZE, + DEFAULT_LIST_ICON_SIZE, + DEFAULT_DESKTOP_CARD_SIZE, +} from '../../constants/documents'; +import { DocumentsAssetContext } from '../context/DocumentsAssetContext'; +import { DocumentsViewStateContext } from '../context/DocumentsViewStateContext'; +import { DocumentsCommandContext } from '../context/DocumentsCommandContext'; +import { useDocumentsContextValues } from './useDocumentsContextValues'; +import { useAppShell } from '../../lib/context/AppShellContext'; +import type { TagInteractionHandlers } from '../interactions/useTagInteractions'; + +export interface DocumentsViewProps { + entries: DocumentsListEntry[]; + tagHandlers?: TagInteractionHandlers; + [key: string]: any; +} + +interface DocumentsPanelProps { + headerLeading?: ReactNode; +} + +const DocumentsPanelInner: React.FC<DocumentsPanelProps> = React.memo((props) => { + const { headerLeading } = props; + const shell = useAppShell(); + const { + search: { documents, searchResultIds, documentsViewMode: viewMode, documentsSortField: sortField, documentsSortDirection: sortDirection, handleDocumentsViewModeChange: onViewModeChange, handleDocumentsSortFieldChange: onSortFieldChange, handleDocumentsSortDirectionToggle: onSortDirectionToggle }, + folderTree: { currentFolderName, breadcrumbs, currentSubfolders: subfolders, folderOptions, moveDocumentsToFolder: onMoveDocumentsToFolder, refreshCurrentFolder: onRefresh, handleBreadcrumbNavigate: onBreadcrumbNavigate }, + selection: { handleDeleteSelection: onDeleteSelection }, + managers: { documentLookup }, + tags: { tags, handleBulkTagAddFromDetail: onBulkTagAdd, handleBulkTagRemoveFromDetail: onBulkTagRemove }, + correspondents: { correspondents, handleBulkCorrespondentAdd: onBulkCorrespondentAdd, handleBulkCorrespondentRemove: onBulkCorrespondentRemove }, + mutations: { handleBulkSelectionReanalyze: onBulkReanalyze }, + } = shell as any; + + const { + assetContextValue, + viewStateContextValue, + commandContextValue, + tagHandlers, + scrollRef, + hasDocumentEntries, + } = useDocumentsContextValues(); + + const { + clearSelection, + } = useWorkspaceSelectionContext(); + const { + isActive: isFilterActive, + includeDescendants, + toggleIncludeDescendants, + } = useDocumentsFilter(); + + const searchDocuments = useMemo( + () => + Array.isArray(searchResultIds) + ? searchResultIds + .map((id: any) => documentLookup?.get?.(id) || null) + .filter((doc: any): doc is Record<string, unknown> => Boolean(doc)) + : null, + [searchResultIds, documentLookup], + ); + + const showingSearchResults = Array.isArray(searchResultIds); + const rows = showingSearchResults && searchDocuments ? searchDocuments : documents; + + const headerTitle = showingSearchResults + ? 'Search results' + : currentFolderName || 'Documents'; + + const headerActions = useMemo( + () => createDocumentsTableHeaderActions({ + viewMode, + onViewModeChange, + onRefresh, + sortField, + onSortFieldChange, + sortDirection, + onSortDirectionToggle, + isFilterActive, + includeDescendants, + onToggleIncludeDescendants: toggleIncludeDescendants, + }), + [ + viewMode, + onViewModeChange, + onRefresh, + sortField, + onSortFieldChange, + sortDirection, + onSortDirectionToggle, + isFilterActive, + includeDescendants, + toggleIncludeDescendants, + ], + ); + + const { tagLookupById, correspondentLookupById } = (shell as any).tags; + + const floatingActions = useMemo(() => ( + <SelectionFloatingPanel + documentLookup={documentLookup} + tags={tags} + tagLookupById={tagLookupById} + correspondents={correspondents} + correspondentLookupById={correspondentLookupById} + onBulkTagAdd={onBulkTagAdd} + onBulkTagRemove={onBulkTagRemove} + onBulkCorrespondentAdd={onBulkCorrespondentAdd} + onBulkCorrespondentRemove={onBulkCorrespondentRemove} + onBulkReanalyze={onBulkReanalyze} + onDeleteSelection={onDeleteSelection} + folderOptions={folderOptions} + onMoveDocumentsToFolder={onMoveDocumentsToFolder} + onClearSelection={clearSelection} + /> + ), [ + documentLookup, + tags, + tagLookupById, + correspondents, + correspondentLookupById, + onBulkTagAdd, + onBulkTagRemove, + onBulkCorrespondentAdd, + onBulkCorrespondentRemove, + onBulkReanalyze, + onDeleteSelection, + folderOptions, + onMoveDocumentsToFolder, + clearSelection, + ]); + + const headerConfig: DocumentsPanelHeaderConfig = useMemo(() => ({ + title: headerTitle, + subtitle: null, + leading: headerLeading, + actions: headerActions, + breadcrumbs, + floatingActions, + }), [ + headerTitle, + headerLeading, + headerActions, + breadcrumbs, + floatingActions, + ]); + + const currentFolderId = useMemo(() => { + if (showingSearchResults) { + return null; + } + const trail = Array.isArray(breadcrumbs) ? breadcrumbs : []; + if (trail.length === 0) { + return 'root'; + } + return trail[trail.length - 1]?.id || 'root'; + }, [breadcrumbs, showingSearchResults]); + + // Context marker logic for clearing selection on nav + const selectionContextRef = useRef<any>(null); + useEffect(() => { + const nextContext = showingSearchResults + ? { type: 'search', marker: searchResultIds } + : { type: 'folder', marker: currentFolderId || 'root' }; + const previous = selectionContextRef.current; + selectionContextRef.current = nextContext; + if (!previous) { + return; + } + const changed = previous.type !== nextContext.type + || previous.marker !== nextContext.marker; + if (changed) { + clearSelection(); + } + }, [showingSearchResults, currentFolderId, searchResultIds, clearSelection]); + + const entries = useMemo(() => { + const list: DocumentsListEntry[] = []; // Explicit type + if (!showingSearchResults) { + (subfolders || []).forEach((folder: any) => { + if (!folder || !folder.id) { + return; + } + list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }); + }); + } + (rows || []).forEach((doc: any) => { + if (!doc || !doc.id) { + return; + } + list.push({ type: EntryType.document, id: doc.id, key: `document:${doc.id}`, document: doc }); + }); + return list; + }, [showingSearchResults, subfolders, rows]); + + const isGridView = viewMode === 'grid'; + const isDeskView = viewMode === 'desk'; + + const viewProps = { + entries, + viewId: currentFolderId, + tagHandlers, + }; + + const [iconSizes] = useState({ + list: DEFAULT_LIST_ICON_SIZE, + grid: DEFAULT_GRID_ICON_SIZE, + desk: DEFAULT_DESKTOP_CARD_SIZE, + }); + + const isSearchLoading = (shell as any).search?.isSearchLoading || false; + + const renderBody = () => { + const hasEntries = entries.length > 0; + const isSearchEmpty = (showingSearchResults || isFilterActive) && !hasDocumentEntries && !isSearchLoading; + + if (isSearchEmpty) { + return ( + <div className="empty-state"> + No documents match the current filters. + </div> + ); + } + + if (!hasEntries) { + return ( + <div className="empty-state"> + No documents to show here yet. Drop files to make this space come alive. + </div> + ); + } + + switch (viewMode) { + case 'desk': + return <DesktopWorkspace {...viewProps} defaultCardSize={iconSizes.desk} />; + case 'grid': + return <DocumentsGrid {...viewProps} iconSize={iconSizes.grid} />; + case 'list': + default: + return <DocumentsList {...viewProps} iconSize={iconSizes.list} />; + } + }; + + const panelVariant = isDeskView ? 'desk' : isGridView ? 'grid' : 'list'; + const shouldHandlePanelInteractions = !isDeskView && entries.length > 0; + + const handleSectionClick = useCallback((event: React.MouseEvent<HTMLElement>) => { + if (!shouldHandlePanelInteractions || event.target !== event.currentTarget) { + return; + } + clearSelection(); + }, [shouldHandlePanelInteractions, clearSelection]); + + return ( + <DocumentsAssetContext.Provider value={assetContextValue}> + <DocumentsViewStateContext.Provider value={viewStateContextValue}> + <DocumentsCommandContext.Provider value={commandContextValue}> + <DocumentsPanelHeader + header={headerConfig} + onBreadcrumbClick={onBreadcrumbNavigate} + /> + <div className="documents-panel-wrapper"> + <TagRemovalZone /> + <section + ref={scrollRef} + className={`documents-panel documents-panel--view-${panelVariant}`} + onClick={handleSectionClick} + > + {renderBody()} + </section> + </div> + </DocumentsCommandContext.Provider> + </DocumentsViewStateContext.Provider> + </DocumentsAssetContext.Provider> + ); +}); + +DocumentsPanelInner.displayName = 'DocumentsPanelInner'; + +const DocumentsPanel: React.FC<DocumentsPanelProps> = (props) => { + const shell = useAppShell(); + const selectionValue = (shell as any).selection?.selectionValue; + + return ( + <WorkspaceSelectionProvider value={selectionValue}> + <DocumentsPanelInner {...props} /> + </WorkspaceSelectionProvider> + ); +}; + +export default DocumentsPanel; diff --git a/frontend/src/documents/panel/DocumentsPanelHeader.tsx b/frontend/src/documents/panel/DocumentsPanelHeader.tsx new file mode 100644 index 0000000..d5f2075 --- /dev/null +++ b/frontend/src/documents/panel/DocumentsPanelHeader.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import PanelHeader from '../../components/PanelHeader'; +import BreadcrumbTrail from '../../components/BreadcrumbTrail'; +import type { Identifier } from '../../types/identifiers'; + +interface DocumentsHeaderBreadcrumb { + id?: Identifier; + name?: string; + label?: string; + title?: string; +} + +export interface DocumentsPanelHeaderConfig { + title?: ReactNode; + subtitle?: ReactNode; + leading?: ReactNode; + actions?: ReactNode; + breadcrumbs?: DocumentsHeaderBreadcrumb[] | null; + floatingActions?: ReactNode; +} + +interface DocumentsPanelHeaderProps { + header?: DocumentsPanelHeaderConfig | null; + onBreadcrumbClick?: (crumb: DocumentsHeaderBreadcrumb) => void; +} + +const DocumentsPanelHeader: React.FC<DocumentsPanelHeaderProps> = ({ + header, + onBreadcrumbClick, +}) => { + if (!header) { + return null; + } + + const breadcrumbEntries = Array.isArray(header.breadcrumbs) + ? header.breadcrumbs.filter(Boolean) + : []; + const lastIndex = breadcrumbEntries.length - 1; + const trailEntries = breadcrumbEntries.length + ? breadcrumbEntries.map((crumb, index) => ({ + id: crumb.id ?? index, + label: crumb.name ?? crumb.label ?? crumb.title ?? '', + onClick: index < lastIndex && onBreadcrumbClick + ? () => onBreadcrumbClick(crumb) + : null, + })) + : [{ id: 'current-location', label: header.title }]; + + const headerTitle = ( + <h2> + <BreadcrumbTrail entries={trailEntries} separator="/" /> + {header.subtitle ? ( + <span className="panel-header__subtitle">{header.subtitle}</span> + ) : null} + </h2> + ); + + return ( + <> + <PanelHeader + leading={header.leading} + title={headerTitle} + titleTag="h2" + actions={header.actions} + /> + {header.floatingActions} + </> + ); +}; + +export default DocumentsPanelHeader; diff --git a/frontend/src/documents/panel/DocumentsToolbar.tsx b/frontend/src/documents/panel/DocumentsToolbar.tsx new file mode 100644 index 0000000..0300834 --- /dev/null +++ b/frontend/src/documents/panel/DocumentsToolbar.tsx @@ -0,0 +1,154 @@ +import type { JSX } from 'react'; +import { + ViewListIcon, + ViewGridIcon, + IconFileStack, + RefreshIcon, + MinusVerticalIcon, + FoldersIcon, + FoldersOffIcon, + SortAscendingLettersIcon, + SortDescendingLettersIcon, +} from '../../components/icons'; +import SortFieldQuickMenu from './SortFieldQuickMenu'; + +type ViewMode = 'list' | 'grid' | 'desk' | (string & {}); +type SortDirection = 'asc' | 'desc' | (string & {}); + +interface DocumentsTableHeaderActionOptions { + viewMode?: ViewMode; + onViewModeChange?: (mode: ViewMode) => void; + onRefresh: () => void; + sortField?: string; + onSortFieldChange?: (field: string) => void; + sortDirection?: SortDirection; + onSortDirectionToggle?: () => void; + isFilterActive?: boolean; + includeDescendants?: boolean; + onToggleIncludeDescendants?: () => void; +} + +export const createDocumentsTableHeaderActions = ({ + viewMode = 'list', + onViewModeChange, + onRefresh, + sortField = 'title', + onSortFieldChange, + sortDirection = 'asc', + onSortDirectionToggle, + isFilterActive = false, + includeDescendants = true, + onToggleIncludeDescendants, +}: DocumentsTableHeaderActionOptions): JSX.Element => { + const isListView = viewMode === 'list'; + const isGridView = viewMode === 'grid'; + const isDeskView = viewMode === 'desk'; + + const sortDirectionIsDesc = sortDirection === 'desc'; + const sortDirectionTitle = sortDirectionIsDesc + ? 'Sorting Z → A. Click to switch to ascending.' + : 'Sorting A → Z. Click to switch to descending.'; + + const includeDescendantsToggle = isFilterActive && onToggleIncludeDescendants + ? ( + <button + type="button" + className="icon-button documents-toolbar__toggle" + onClick={onToggleIncludeDescendants} + aria-pressed={!includeDescendants} + aria-label={includeDescendants ? 'Include subfolders' : 'Limit to current folder'} + title={includeDescendants + ? 'Including subfolders. Click to limit the search to the current folder.' + : 'Limiting to the current folder. Click to include subfolders again.'} + > + {includeDescendants ? <FoldersIcon /> : <FoldersOffIcon />} + </button> + ) + : null; + + const sortControls = onSortFieldChange + ? ( + <div className="documents-actions__sort-group"> + <SortFieldQuickMenu sortField={sortField} onChange={onSortFieldChange} /> + {onSortDirectionToggle ? ( + <button + type="button" + className="icon-button documents-toolbar__toggle documents-sort__direction" + onClick={onSortDirectionToggle} + aria-pressed={sortDirectionIsDesc} + aria-label={sortDirectionIsDesc ? 'Sort descending' : 'Sort ascending'} + title={sortDirectionTitle} + > + {sortDirectionIsDesc ? ( + <SortDescendingLettersIcon size={18} /> + ) : ( + <SortAscendingLettersIcon size={18} /> + )} + </button> + ) : null} + </div> + ) + : null; + + return ( + <> + {includeDescendantsToggle ? ( + <> + {includeDescendantsToggle} + <span className="main-content__actions-divider" aria-hidden="true"> + <MinusVerticalIcon /> + </span> + </> + ) : null} + {sortControls ? ( + <> + {sortControls} + <span className="main-content__actions-divider" aria-hidden="true"> + <MinusVerticalIcon /> + </span> + </> + ) : null} + <div className="view-toggle" role="group" aria-label="Change view"> + <button + type="button" + className={`toggle-button${isListView ? ' active' : ''}`} + onClick={() => onViewModeChange?.('list')} + aria-pressed={isListView} + title="List view" + > + <ViewListIcon className="view-toggle__icon" size={18} /> + </button> + <button + type="button" + className={`toggle-button${isGridView ? ' active' : ''}`} + onClick={() => onViewModeChange?.('grid')} + aria-pressed={isGridView} + title="Icons view" + > + <ViewGridIcon className="view-toggle__icon" size={18} /> + </button> + <button + type="button" + className={`toggle-button${isDeskView ? ' active' : ''}`} + onClick={() => onViewModeChange?.('desk')} + aria-pressed={isDeskView} + title="Desk view" + > + <IconFileStack className="view-toggle__icon" size={18} /> + </button> + </div> + <span className="main-content__actions-divider" aria-hidden="true"> + <MinusVerticalIcon /> + </span> + <button + type="button" + className="icon-button" + onClick={onRefresh} + aria-label="Refresh" + title="Refresh" + > + <RefreshIcon /> + </button> + </> + ); +}; diff --git a/frontend/src/documents/panel/SortFieldQuickMenu.tsx b/frontend/src/documents/panel/SortFieldQuickMenu.tsx new file mode 100644 index 0000000..9e20392 --- /dev/null +++ b/frontend/src/documents/panel/SortFieldQuickMenu.tsx @@ -0,0 +1,57 @@ +import React, { useCallback, useMemo } from 'react'; +import { SORT_LABEL_LOOKUP, SORT_OPTIONS } from '../../constants/documents'; +import QuickAddMenu from '../../components/QuickAddMenu'; + +interface SortFieldQuickMenuProps { + sortField: string; + onChange?: (value: string) => void; +} + +const SortFieldQuickMenu: React.FC<SortFieldQuickMenuProps> = ({ sortField, onChange }) => { + const currentOption = useMemo( + () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], + [sortField], + ); + + const options = useMemo( + () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })), + [], + ); + + const handleSelect = useCallback( + (value: string, option?: { id?: string; original?: { id?: string } }) => { + if (!onChange) { + return; + } + const nextValue = option?.id || option?.original?.id || value; + if (nextValue) { + onChange(nextValue); + } + }, + [onChange], + ); + + const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title'; + + return ( + <QuickAddMenu + className="documents-sort__quickmenu" + options={options} + onSelectOption={handleSelect} + triggerClassName="toggle-button documents-sort__trigger quick-add__trigger" + triggerContent={( + <span className="documents-sort__trigger-content"> + <span className="documents-sort__label">{label}</span> + </span> + )} + triggerAriaLabel={`Sort by ${label}`} + triggerTitle={`Sort by ${label}`} + placeholder="Select sort field" + menuMinWidth={200} + align="start" + positionStrategy="absolute" + /> + ); +}; + +export default SortFieldQuickMenu; diff --git a/frontend/src/documents/panel/useDocumentsContextValues.ts b/frontend/src/documents/panel/useDocumentsContextValues.ts new file mode 100644 index 0000000..5854cc0 --- /dev/null +++ b/frontend/src/documents/panel/useDocumentsContextValues.ts @@ -0,0 +1,201 @@ +import { useMemo, useCallback, useRef, useEffect } from 'react'; +import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer'; +import { useDocumentsFilter } from '../context/DocumentsFilterContext'; +import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import { useTagInteractions } from '../interactions/useTagInteractions'; +import { subscribeToToast } from '../features/tagging/tagTransfer'; +import { useAppShell } from '../../lib/context/AppShellContext'; + +const EntryType = { + folder: 'folder', + document: 'document', +}; + +export const useDocumentsContextValues = () => { + const shell = useAppShell(); + const { + preview: { ensureAssetUrl, getDocumentAsset }, + search: { isSearchLoading, searchQuery, activeTagFilters, activeCorrespondentFilters, searchResultIds, documents }, + folderTree: { selectedFolder }, + mutations: { handleDocumentDragStart, handleDocumentDragEnd, draggedDocumentIds: draggingDocumentIds, handleDocumentTagAttach, handleDocumentTagDetach }, + selection: { handleEntryPointerCore: onEntryPointer }, + correspondents: { activeCorrespondentIds, correspondentLookupById }, + tags: { tagLookupById }, + } = shell as any; + + const { + setFocusedEntryKey, + } = useWorkspaceSelectionContext(); + + const { + toggleTag: toggleTagFilter, + toggleCorrespondent: toggleCorrespondentFilter, + } = useDocumentsFilter(); + + const scrollRef = useRef<HTMLElement | null>(null); + const suppressDocumentClickRef = useRef(false); + + const { showToast } = useStatusToast(); + + // Subscribe to tag operation results + useEffect(() => { + return subscribeToToast((message, type) => { + showToast(message, type); + }); + }, [showToast]); + + // Handlers + const tagHandlers = useTagInteractions({ + onAssignTagToDocument: handleDocumentTagAttach, + onRemoveTagFromDocument: handleDocumentTagDetach, + onTagClick: toggleTagFilter, + }); + + // Derived State + const draggingDocumentIdsSet = useMemo( + () => new Set(draggingDocumentIds || []), + [draggingDocumentIds], + ); + const activeCorrespondentIdSet = useMemo( + () => new Set(activeCorrespondentIds || []), + [activeCorrespondentIds], + ); + const showingSearchResults = Array.isArray(searchResultIds); + const hasDocumentEntries = (documents || []).length > 0 || (showingSearchResults && (searchResultIds || []).length > 0); + + const viewId = useMemo(() => { + if (showingSearchResults) { + const trimmedQuery = (searchQuery || '').trim(); + const tagsKey = [...(activeTagFilters || [])].sort().join(','); + const correspondentsKey = [...(activeCorrespondentFilters || [])].sort().join(','); + return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; + } + const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; + return `folder:${folderKey}`; + }, [ + showingSearchResults, + searchQuery, + activeTagFilters, + activeCorrespondentFilters, + selectedFolder, + ]); + + const handleFolderClick = useCallback( + (folder: any, event: any) => { + if (!folder) { + return; + } + + if (onEntryPointer) { + onEntryPointer( + { type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder }, + event, + ); + } + + if ( + !isPointerModifierEvent(event) + && isPrimaryPointerEvent(event) + && scrollRef.current + ) { + scrollRef.current.focus({ preventScroll: true }); + setFocusedEntryKey(`folder:${folder.id}`); + } + }, + [onEntryPointer, setFocusedEntryKey], + ); + + const handleDocumentDragStartLocal = useCallback( + (event: any, doc: any) => { + suppressDocumentClickRef.current = true; + handleDocumentDragStart?.(event, doc); + }, + [handleDocumentDragStart], + ); + + const handleDocumentDragEndLocal = useCallback( + (event: any) => { + handleDocumentDragEnd?.(event); + requestAnimationFrame(() => { + suppressDocumentClickRef.current = false; + }); + }, + [handleDocumentDragEnd], + ); + + // Context Values Construction + const assetContextValue = useMemo(() => ({ + ensureAssetUrl, + getDocumentAsset, + }), [ensureAssetUrl, getDocumentAsset]); + + const draggedFolderId = (shell as any).folderTree?.draggedFolderId; + + const viewStateContextValue = useMemo(() => ({ + viewId, + scrollRef, + tagLookupById, + correspondentLookupById, + activeCorrespondentIdSet, + draggingDocumentIdsSet, + draggedFolderId, + }), [ + viewId, + scrollRef, + tagLookupById, + activeCorrespondentIdSet, + draggingDocumentIdsSet, + draggedFolderId, + correspondentLookupById, + ]); + + const commandContextValue = useMemo(() => { + const anyShell = shell as any; + return { + folder: { + onClick: handleFolderClick, + onSelect: anyShell.folderTree?.selectFolder, + onRename: anyShell.folderTree?.handleFolderRename, + onDrag: { + start: anyShell.folderTree?.handleFolderDragStart, + end: anyShell.folderTree?.handleFolderDragEnd, + over: anyShell.folderTree?.folderClickHandlers?.onDragOver, + leave: anyShell.folderTree?.folderClickHandlers?.onDragLeave, + drop: anyShell.folderTree?.folderClickHandlers?.onDrop, + }, + }, + document: { + onRename: anyShell.mutations?.handleDocumentTitleUpdate, + onDrag: { + start: handleDocumentDragStartLocal, + end: handleDocumentDragEndLocal, + }, + }, + correspondents: { + onClick: toggleCorrespondentFilter, + }, + onEntryPointer, + } + }, [ + handleFolderClick, + shell, + handleDocumentDragStartLocal, + handleDocumentDragEndLocal, + toggleCorrespondentFilter, + onEntryPointer, + ]); + + return { + assetContextValue, + viewStateContextValue, + commandContextValue, + tagHandlers, + scrollRef, + hasDocumentEntries, + isSearchLoading, + showingSearchResults, + activeTagFilters, + activeCorrespondentFilters, + }; +}; diff --git a/frontend/src/documents/styles/controls.css b/frontend/src/documents/styles/controls.css new file mode 100644 index 0000000..602942a --- /dev/null +++ b/frontend/src/documents/styles/controls.css @@ -0,0 +1,106 @@ +.view-toggle { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.toggle-button { + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + padding: 0.3rem 0.6rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.toggle-button:hover, +.toggle-button:focus-visible { + background: var(--surface-subtle); + color: var(--fg); + outline: none; +} + +.toggle-button.active { + background: var(--accent); + color: var(--on-accent); + border-color: var(--accent); +} + +.documents-actions__sort-group { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.documents-sort { + display: inline-flex; + align-items: center; + position: relative; +} + +.documents-sort__quickmenu { + --quick-add-menu-min-width: 200px; +} + +.documents-sort__trigger { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + white-space: nowrap; + padding: 0.25rem 0.5rem; + min-height: 2.1rem; +} + +.documents-sort__label { + display: inline-flex; + align-items: center; + line-height: 1.1; +} + +.documents-sort__trigger-content { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.documents-sort__quickmenu .menu__item, +.documents-sort__quickmenu .menu__item.active { + font-weight: 400; +} + +.documents-toolbar__toggle { + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.3rem; + background: transparent; + color: var(--muted); + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.documents-toolbar__toggle:hover:not([disabled]) { + color: var(--fg); + border-color: var(--border); +} + +.documents-toolbar__toggle[aria-pressed='true'] { + border-color: var(--accent); + color: var(--accent); + background: var(--surface-subtle); +} + +.documents-sort__direction { + padding: 0.3rem 0.45rem; +} + +.documents-sort__direction[aria-pressed='true'] { + border-color: var(--border); + color: var(--muted); + background: transparent; +} + +.documents-sort__direction svg { + width: 1.1rem; + height: 1.1rem; +} diff --git a/frontend/src/documents/styles/drag-preview.css b/frontend/src/documents/styles/drag-preview.css new file mode 100644 index 0000000..ada971f --- /dev/null +++ b/frontend/src/documents/styles/drag-preview.css @@ -0,0 +1,76 @@ +.document-drag-preview { + position: fixed; + pointer-events: none; + top: -9999px; + left: -9999px; + width: var(--drag-preview-size, 96px); + height: var(--drag-preview-size, 96px); + z-index: 9999; +} + +.document-drag-preview__item { + position: absolute; + top: 50%; + left: 50%; + width: 64px; + height: 64px; + border-radius: 6px; + box-shadow: 0 6px 12px var(--shadow-pop); + overflow: hidden; + background-color: var(--overlay-dim); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + transform: translate(-50%, -50%) rotate(var(--rotation-deg, 0deg)); + transform-origin: center; +} + +.document-drag-preview__item--folder { + background: transparent; + box-shadow: none; + border-radius: 0; +} + +.document-drag-preview__item--image { + background-color: #000; + background-repeat: no-repeat; + background-size: contain; + background-position: center; +} + +.document-drag-preview__item .document-thumbnail, +.document-drag-preview__item img { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; +} + +.document-drag-preview__item .thumb-placeholder, +.document-drag-preview__item .thumb-placeholder * { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.document-drag-preview__folder-thumb { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.document-drag-preview__folder-thumb svg { + width: 48px; + height: 48px; + color: var(--accent-strong, var(--accent)); +} diff --git a/frontend/src/documents/styles/listing.css b/frontend/src/documents/styles/listing.css new file mode 100644 index 0000000..cab7372 --- /dev/null +++ b/frontend/src/documents/styles/listing.css @@ -0,0 +1,816 @@ +.documents-panel { + padding: 0 0.75rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + --documents-list-row-height: calc(48px + 0.2rem); +} + +.documents-panel:focus-visible { + outline: 2px solid var(--selection-ring); + outline-offset: 2px; + border-radius: 0.25rem; +} + +.tags-panel, +.correspondents-panel { + padding: 1.25rem; + display: flex; + flex-direction: column; + min-height: 0; +} + +.documents-panel tr.folder.is-drop-target { + outline: 2px dashed var(--accent-strong, var(--accent)); + outline-offset: -2px; +} + +.documents-panel--view-desk { + padding: 0; +} + +.documents-grid .folder-card.is-drop-target { + outline: 2px dashed var(--accent-strong, var(--accent)); + outline-offset: 2px; +} + +.documents-panel .panel-section__header { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; +} + +.documents-panel .panel-section__header .header-actions { + display: flex; + gap: 0.5rem; +} + +.documents-panel__title { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 0.9rem; + font-weight: 600; + min-width: 0; + white-space: nowrap; + overflow: hidden; +} + +.documents-panel__breadcrumbs { + flex: 1 1 auto; + overflow: hidden; +} + +.panel-floating-region { + position: sticky; + pointer-events: none; + z-index: 20000; + height: 0; + width: 100%; + display: flex; + justify-content: center; +} + +.panel-floating { + background: color-mix(in oklch, var(--surface) 70%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid color-mix(in oklch, var(--border) 95%, transparent); + padding: 0.45rem 0.85rem; + border-radius: 1rem; + font-size: 0.95rem; + font-weight: 400; + color: var(--fg); + box-shadow: 0 6px 24px color-mix(in oklch, var(--shadow-soft) 55%, transparent); + pointer-events: auto; + display: flex; + min-height: 1.5rem; + align-items: center; + justify-content: center; + flex-wrap: nowrap; + gap: 0.75rem; + z-index: 950000; + margin: 0 1rem; + overflow: visible; + flex: 0 1 auto; +} + +.documents-main--overlay-detail .panel-floating-region { + width: calc(100% - var(--detail-panel-width) - 2rem); + margin-right: var(--detail-panel-width); +} + +.panel-floating__buttons { + display: inline-flex; + align-items: center; + gap: 0.25rem; + pointer-events: auto; +} + +.panel-floating__label { + white-space: nowrap; + pointer-events: none; + font-size: 0.95rem; + color: var(--fg); + min-width: 0; +} + +.selection-summary { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.selection-summary__token { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.selection-summary__count { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.selection-summary__icon { + width: 1rem; + height: 1rem; +} + +.selection-summary--text { + font-weight: 600; +} + +.selection-summary__separator { + opacity: 0.45; +} + +.panel-floating-actions { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: nowrap; + pointer-events: auto; + min-width: 0; + flex: 1 1 auto; + overflow: visible; +} + +.panel-floating-actions--assignments { + display: flex; + flex-wrap: nowrap; + flex: 1 1 auto; + min-width: 0; + white-space: nowrap; + justify-content: flex-start; + overflow: visible; +} + +.panel-floating-actions .quick-add { + pointer-events: auto; +} + +.panel-floating-actions .quick-add__trigger { + pointer-events: auto; +} + +.panel-floating-actions .quick-add__trigger[disabled] { + opacity: 0.45; + cursor: not-allowed; +} + +.panel-floating-actions__button { + display: inline-flex; + align-items: center; + gap: 0; + pointer-events: auto; + font-size: 1.35rem; +} + +.panel-floating-actions__button .icon-inline { + display: inline-flex; + width: 1.35rem; + height: 1.35rem; +} + +@media (max-width: 960px) { + .panel-floating { + justify-content: center; + align-items: center; + gap: 0.5rem; + } + + + .panel-floating-actions__button { + font-size: 1.15rem; + } +} + +@media (max-width: 640px) { + .panel-floating { + gap: 0.5rem; + border-radius: 0.85rem; + padding: 0.4rem 0.65rem; + } + + .panel-floating-actions { + gap: 0.35rem; + } + + .panel-floating-actions__button .icon-inline { + width: 1.1rem; + height: 1.1rem; + } +} + +.documents-panel table { + max-width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} + +.documents-panel thead th { + background-color: var(--surface); + position: sticky; + top: 0; + z-index: 1; + padding: 0.45rem 0.6rem; + color: var(--muted); +} + + +.documents-panel th, +.documents-panel td { + padding: 0.45rem 0.6rem; + text-align: left; +} + +.documents-panel .documents-grid { + padding: 1rem 0.5rem; +} + +.documents-panel th.thumb-column, +.documents-panel td.thumb-cell { + width: 54px; + text-align: center; +} + +.documents-panel--view-grid .document-thumbnail-wrapper { + width: var(--documents-grid-icon-size); + height: var(--documents-grid-icon-size); + padding: 8px; + border-radius: 10px; + display: flex; + justify-content: center; + align-items: center; +} + +.documents-panel--view-grid .folder-card__icon { + width: var(--documents-grid-icon-size); + height: var(--documents-grid-icon-size); + padding: 8px; + border-radius: 10px; + display: flex; + justify-content: center; + align-items: center; +} + +.document-thumbnail-inner { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.document-thumbnail-inner--multipage::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 24px; + height: 24px; + background-image: url('../../assets/papercorner.svg'); + background-repeat: no-repeat; + background-size: contain; + pointer-events: none; +} + +.documents-panel--view-grid .document-thumbnail-inner--multipage::after { + width: 28px; + height: 28px; +} + +.document-thumbnail { + max-width: 100%; + max-height: 100%; + box-shadow: 0 1px 6px var(--shadow-medium); +} + +.documents-panel--view-grid .document-thumbnail { + box-shadow: 0 1px 12px var(--shadow-medium); +} + +.thumb-placeholder { + width: 100%; + height: 100%; + border-radius: 1px; + background: var(--surface-subtle); + color: var(--muted); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.08em; + box-shadow: 0 1px 3px var(--shadow-medium); +} + +.documents-panel--view-grid .thumb-placeholder { + font-size: 1.1rem; + letter-spacing: 0.12em; + display: inline-flex; + width: 100%; + height: 100%; +} + +.thumb-icon { + width: 100%; + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.thumb-icon__image { + display: block; +} + +.documents-panel th.actions-column, +.documents-panel td.actions { + width: 1%; + white-space: nowrap; +} + +.documents-panel td.doc-list__name { + width: 100%; +} + +.doc-title-edit { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-wrap: nowrap; +} + +.doc-title-edit input[type='text'] { + padding: 0.3rem 0.55rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--fg); + min-width: 8rem; +} + +.doc-title-edit input[type='text']:focus-visible { + outline: 2px solid var(--selection-ring); + outline-offset: 1px; +} + +.doc-title-edit .icon-button { + flex-shrink: 0; +} + +.documents-panel .doc-name__primary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.documents-panel .doc-name__primary-text { + overflow-wrap: anywhere; +} + + +.doc-entry { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.doc-entry--with-thumb .doc-entry__thumb { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.doc-entry__thumb .document-thumbnail-wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.doc-entry__main { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.doc-entry__name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.doc-entry__tags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.documents-panel td.actions .action-buttons { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.documents-panel tbody tr { + background: transparent; + transition: background 0.15s ease; +} + +.documents-panel tbody tr.folder { + cursor: pointer; +} + +.documents-panel tbody tr.folder.focused { + box-shadow: inset 2px 0 0 var(--accent-outline); + background: var(--sidebar-hover-bg); +} + +.documents-panel tbody tr.document { + cursor: pointer; +} + +.documents-panel tbody tr.document>*, +.documents-panel tbody tr.folder>* { + height: var(--documents-list-row-height); +} + +.documents-panel tbody tr.document.selected, +.documents-panel tbody tr.folder.selected { + background: var(--selection-soft); + box-shadow: inset 2px 0 0 var(--accent-outline-strong); +} + +.documents-panel tbody tr.document, +.documents-panel tbody tr.document * { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; +} + +.documents-panel tbody tr.document.focused:not(.selected) { + box-shadow: inset 2px 0 0 var(--accent-outline); +} + +.doc-name { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.documents-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--documents-grid-icon-size), var(--documents-grid-icon-size))); + gap: 0.3rem 1.5rem; + justify-content: flex-start; +} + +.document-card { + padding: 0; + display: flex; + flex-direction: column; + gap: 0.3rem; + cursor: pointer; + align-items: center; + text-align: center; +} + +.document-card.selected .document-thumbnail-wrapper, +.folder-card.selected .folder-card__icon { + background-color: var(--selection-soft); +} + +.document-card:focus-visible .document-thumbnail-wrapper { + background-color: var(--selection-ring); +} + +.document-card.is-dragging { + opacity: 0.55; +} + +.document-card__meta { + display: flex; + flex-direction: column; + gap: 0.35rem; + width: 100%; +} + +.document-card__title { + display: flex; + flex-direction: column; + gap: 0.35rem; + color: var(--fg); +} + +.document-card__title-row { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.document-card__title-badge { + padding: 0.2rem 0.5rem; + border-radius: 1rem; + max-width: 100%; + word-break: break-word; + font-size: var(--documents-grid-title-size); + color: inherit; +} + +.document-card .doc-correspondent-link { + font-size: var(--documents-grid-title-size); +} + +.document-card.selected .document-card__title-badge { + background-color: var(--accent); + color: var(--on-accent); +} + +.document-card__subtitle { + font-size: 0.78rem; + color: var(--muted); +} + +.document-card__tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + justify-content: center; +} + +.folder-card { + align-items: center; + text-align: center; +} + +.folder-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: var(--documents-grid-icon-size); + height: var(--documents-grid-icon-size); +} + +.folder-card__icon-svg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.folder-card__meta { + display: flex; + flex-direction: column; + gap: 0.28rem; + padding-top: 0.35rem; +} + +.folder-card__label-row { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.folder-card__name { + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; + font-size: var(--documents-grid-title-size); + padding: 0.2rem 0.5rem; + border-radius: 1rem; +} + +.folder-card__edit { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; +} + +.folder-card.selected .folder-card__name { + background-color: var(--accent); + color: var(--on-accent); +} + + +.doc-name__title { + max-width: 100%; + word-break: break-word; +} + +.doc-name__tags { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.doc-correspondents { + display: inline; + color: inherit; +} + +.doc-correspondent-link { + background: none; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + color: var(--accent); + text-decoration: none; + font: inherit; + cursor: pointer; + box-shadow: none; + appearance: none; +} + +.doc-correspondent-link:not(.is-static):hover, +.doc-correspondent-link:not(.is-static):focus-visible { + color: var(--accent-strong, var(--accent)); + text-decoration: underline; + text-decoration-thickness: 1.5px; + background-color: transparent; +} + +.doc-correspondent-link:not(.is-static):focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.documents-panel tbody tr.document.selected .doc-correspondents, +.documents-panel tbody tr.document.selected .doc-correspondent-link, +.document-card.selected .doc-correspondent-link { + color: inherit; +} + +.document-card.selected .doc-correspondents { + color: inherit; +} + +.doc-correspondent-link.is-active { + font-weight: 600; +} + +.doc-correspondent-link.is-static { + color: inherit; + cursor: default; + text-decoration: none; +} + +.doc-correspondent-link__separator { + color: inherit; +} + +.documents-panel tbody tr.document.dragging { + opacity: 0.4; +} + +.documents-panel tbody tr.document.is-tag-target { + background: var(--accent-soft); + box-shadow: inset 0 0 0 2px var(--accent); +} + +.document-card.is-tag-target { + box-shadow: 0 0 0 2px var(--accent); + border-color: var(--accent); +} + +.document-card.is-tag-target .document-card__title { + color: var(--accent); +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.32rem; + margin-bottom: 0.5rem; +} + +.filter-bar input[type='search'] { + flex: 1; + min-width: 180px; +} + +.filter-actions { + display: flex; + gap: 0.32rem; +} + +.search-hint { + margin-top: 0.75rem; + font-size: 0.85rem; + color: var(--muted); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.35rem; + border-radius: 2px; + background: var(--surface-subtle); + color: var(--fg); + font-size: 0.74rem; +} + +.tag-chip { + --tag-chip-resolved-l: var(--tag-chip-base-l, var(--tag-chip-default-l, 0.9)); + --tag-chip-resolved-c: var(--tag-chip-base-c, 0); + --tag-chip-resolved-h: var(--tag-chip-base-h, 0deg); + gap: 0.25rem; + border-radius: 1rem; + padding: 0.2rem 0.4rem; + font-weight: 600; + border: none; + background: oklch(min(1, calc(var(--tag-chip-resolved-l) + (1 - var(--tag-chip-resolved-l)) * var(--tag-chip-bg-lighten))) calc(var(--tag-chip-resolved-c) * var(--tag-chip-bg-chroma-scale)) var(--tag-chip-resolved-h) / 0.9); + --tag-chip-outline-color: var(--tag-chip-outline, + oklch(min(1, calc(var(--tag-chip-resolved-l) + (1 - var(--tag-chip-resolved-l)) * var(--tag-chip-outline-lighten))) min(1, calc(var(--tag-chip-resolved-c) * var(--tag-chip-outline-chroma-scale))) var(--tag-chip-resolved-h))); + --tag-chip-text-color: var(--tag-chip-text, + oklch(clamp(0, calc(var(--tag-chip-resolved-l) + var(--tag-chip-text-lighten)), 1) clamp(0, calc(var(--tag-chip-resolved-c) * var(--tag-chip-text-chroma-scale)), 1) var(--tag-chip-resolved-h))); + color: var(--tag-chip-text-color, currentColor); +} + +.tag-chip--more { + background: transparent; + border: 1px dashed var(--border-strong); + color: var(--muted); +} + +.tag-chip--removable { + gap: 0.35rem; +} + +.tag-chip__remove { + background: none; + border: none; + color: inherit; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + cursor: pointer; + opacity: 0.8; +} + +.tag-chip__remove:hover, +.tag-chip__remove:focus-visible { + opacity: 1; +} + +.tag-chip__remove:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + border-radius: 50%; +} + +.empty-state { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + text-align: center; + color: var(--muted); +} + +.documents-panel-wrapper { + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} \ No newline at end of file diff --git a/frontend/src/documents/styles/panel-sections.css b/frontend/src/documents/styles/panel-sections.css new file mode 100644 index 0000000..4dffbcf --- /dev/null +++ b/frontend/src/documents/styles/panel-sections.css @@ -0,0 +1,157 @@ +.document-viewer__message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-on-dark); + background: var(--overlay-dark); + padding: 0.5rem 1rem; + border-radius: 999px; + font-size: 0.9rem; +} + +.panel-section__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0 0 0.5rem; +} + +.panel-section__titles { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.panel-section__header h2 { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +.documents-panel__title { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-size: 0.9rem; + font-weight: 600; + min-width: 0; + flex-wrap: nowrap; + overflow: hidden; +} + +.panel-section__subtitle { + margin-top: 0.25rem; + font-size: 0.78rem; + color: var(--muted); +} + +.breadcrumb-trail { + display: flex; + align-items: center; + gap: 0.05rem; + flex-wrap: nowrap; + min-width: 0; + flex: 1 1 auto; + max-width: 100%; + overflow: hidden; + margin-left: -0.25rem; +} + +.breadcrumb-trail--measure { + position: absolute; + visibility: hidden; + pointer-events: none; + left: -9999px; + top: -9999px; + max-width: none; + overflow: visible; +} + +.breadcrumb-trail__link { + display: block; + align-items: center; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: none; + background: none; + padding: 0; + font: inherit; + color: inherit; + cursor: default; + min-width: 0; +} + +.breadcrumb-trail--measure .breadcrumb-trail__link, +.breadcrumb-trail--measure .breadcrumb-trail__ellipsis-button { + max-width: none; + overflow: visible; +} + +button.breadcrumb-trail__link, +.breadcrumb-trail__ellipsis-button, +.breadcrumb-trail__link.is-current { + padding: 0.25rem; + background: none; + border-radius: 0.25rem; +} + +.breadcrumb-trail__link:not(.is-current) { + cursor: pointer; + color: var(--accent); +} + +.breadcrumb-trail__link:not(.is-current):hover, +.breadcrumb-trail__link:not(.is-current):focus-visible, +.panel-header .breadcrumb-trail__link:not(.is-current):hover, +.panel-header .breadcrumb-trail__link:not(.is-current):focus-visible { + text-decoration: underline; + background: var(--accent-soft); + color: var(--accent); +} + +.breadcrumb-trail__separator { + color: var(--muted-subtle); + margin: 0; +} + +.breadcrumb-trail__ellipsis { + position: relative; + display: inline-flex; +} + +.breadcrumb-trail__ellipsis-button { + cursor: pointer; +} + +.documents-panel__breadcrumbs { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +.documents-panel__breadcrumbs { + max-width: 22rem; +} + +.panel-header__breadcrumbs-wrapper { + min-width: 0; + flex: 1 1 auto; +} + +.panel-section__body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.panel-section__body--scrollable, +.panel-section__body.scrollable { + overflow-y: auto; +} \ No newline at end of file diff --git a/frontend/src/documents/styles/shared-snippets.css b/frontend/src/documents/styles/shared-snippets.css new file mode 100644 index 0000000..369d69d --- /dev/null +++ b/frontend/src/documents/styles/shared-snippets.css @@ -0,0 +1,21 @@ +.doc-list__name { + width: 100%; +} + +.doc-list__name-content { + display: inline-flex; + align-items: center; + gap: 0.22rem; + max-width: 100%; +} + +.doc-list__name-content span { + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.preview-pane__nav-button svg { + width: 100%; + height: 100%; +} diff --git a/frontend/src/documents/styles/tags-correspondents.css b/frontend/src/documents/styles/tags-correspondents.css new file mode 100644 index 0000000..9fd9b87 --- /dev/null +++ b/frontend/src/documents/styles/tags-correspondents.css @@ -0,0 +1,173 @@ +.tags-panel__body { + overflow-y: auto; +} + +.tags-table { + width: 100%; + overflow: auto; +} + +.tags-table table { + width: 100%; + border-collapse: collapse; + min-width: 320px; +} + +.tags-table th, +.tags-table td { + padding: 0.45rem 0.6rem; + text-align: left; + font-size: 0.85rem; +} + +.tags-table th.numeric, +.tags-table td.numeric { + text-align: right; +} + +.tags-table th.actions, +.tags-table td.actions { + text-align: right; + width: 0; +} + +.tags-table tbody tr:hover { + background: var(--surface-subtle); +} + +.tags-table tr.editing { + background: var(--sidebar-hover-bg); +} + +.tags-table__label { + max-width: 24rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tags-table__swatch { + display: inline-block; + width: 1rem; + height: 1rem; + border-radius: 2px; + box-shadow: inset 0 0 0 1px var(--shadow-faint); +} + +.tags-panel__error { + margin: 0.5rem 0; + color: var(--danger); + font-size: 0.8rem; +} + +.tags-table__label-input { + width: 100%; +} + +.tags-table__color-editor { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.tags-table__color-picker { + width: 2.25rem; + height: 2.25rem; + padding: 0; + background: none; + cursor: pointer; +} + +.tags-table__edit-controls { + display: flex; + justify-content: flex-start; + padding: 0.25rem; + gap: 0.4rem; +} + +.tags-table__row-actions { + display: flex; + justify-content: flex-start; + padding: 0.25rem; + gap: 0.4rem; +} + +.correspondents-panel .header-actions { + gap: 0.5rem; +} + +.tags-actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: center; +} + +.tags-actions__form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} + +.tags-actions__form input[type='text'] { + min-width: 14rem; +} + +.correspondents-actions__form { + display: flex; + gap: 0.4rem; +} + +.correspondents-actions__form input { + min-width: 14rem; +} + + + +.correspondent-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: var(--surface-subtle); + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-size: 0.9rem; + border: none; +} + +.correspondent-pill__label { + line-height: 1.2; +} + +.correspondent-pill__label strong { + font-size: 0.8rem; + text-transform: capitalize; + color: var(--muted); +} + +.correspondent-pill__remove { + border: none; + background: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--muted); + padding: 0; +} + +.correspondent-pill__remove:hover { + color: var(--danger); +} + +.correspondent-form { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.correspondent-form input, +.correspondent-form select { + min-height: 32px; +} diff --git a/frontend/src/documents/types/workspaceTypes.ts b/frontend/src/documents/types/workspaceTypes.ts new file mode 100644 index 0000000..b4d9cfc --- /dev/null +++ b/frontend/src/documents/types/workspaceTypes.ts @@ -0,0 +1,63 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import type { DocumentId, FolderId, Identifier } from '../../types/identifiers'; +import type { Document, FolderNode, Tag, Correspondent } from '../../types/documents'; + +import type TagManager from '../../lib/assets/TagManager'; +import type CorrespondentManager from '../../lib/assets/CorrespondentManager'; + +interface DocumentsManagerInterface { + map(mapper: (doc: Document) => Document | undefined): boolean; + update(id: DocumentId, updater: (doc: Document) => Partial<Document> | Document | undefined): boolean; + ingest(rawDocs: unknown[]): { canonical: Document[]; changed: boolean }; + remove(ids: Array<DocumentId>): boolean; +} + +export interface DocumentsState { + documentLookup: Map<DocumentId, Document>; + setDocuments: Dispatch<SetStateAction<Document[]>>; + setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>; + documentsManager: DocumentsManagerInterface; + extractDocumentFromResponse?: (payload: unknown) => Document | null; + ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; +} + +export interface FolderState { + folderNodes: Map<FolderId, FolderNode>; + selectedFolder: FolderId; + setSelectedFolder: Dispatch<SetStateAction<FolderId>>; + folderLabelMap: Map<FolderId, string>; + setCreatingFolder?: (value: boolean) => void; +} + +export interface SelectionState { + setSelectedEntries: Dispatch<SetStateAction<string[]>>; + setSelectionOrder: Dispatch<SetStateAction<string[]>>; + selectionOrderRef: MutableRefObject<string[] | null>; + selectionAnchorRef: MutableRefObject<string | null>; + setFocusedDocumentId: Dispatch<SetStateAction<DocumentId | null>>; + focusedDocumentId: DocumentId | null; + setFocusedEntryKey: Dispatch<SetStateAction<string | null>>; + focusedEntryKey: string | null; +} + +export interface TagsState { + tags: Tag[]; + tagLookupById: Map<DocumentId, Tag>; + refreshTags: () => Promise<void>; + tagManager: TagManager; +} + +export interface CorrespondentsState { + correspondents: Correspondent[]; + correspondentLookupById: Map<Identifier, Correspondent>; + correspondentLookupByName?: Map<string, Correspondent>; + refreshCorrespondents: () => Promise<void>; + correspondentManager: CorrespondentManager; +} + +export interface DragState { + draggedDocumentIds: DocumentId[]; + draggedFolderId: FolderId | null; + setDraggedDocumentIds: (ids: DocumentId[]) => void; + setDraggedFolderId: (id: FolderId | null) => void; +} diff --git a/frontend/src/folders/FolderManagerContext.tsx b/frontend/src/folders/FolderManagerContext.tsx new file mode 100644 index 0000000..fe383ff --- /dev/null +++ b/frontend/src/folders/FolderManagerContext.tsx @@ -0,0 +1,57 @@ +import React, { createContext, useContext, useMemo, type ReactNode } from 'react'; +import { DEFAULT_FOLDER_NAME } from '../app/workspaceUtils'; + +type FolderId = string | null; + +interface FolderManager { + getNameSync: (folderId: FolderId) => string | null; + resolveName: (folderId: FolderId) => Promise<string>; +} + +const defaultManager: FolderManager = { + getNameSync: (folderId) => (folderId == null ? DEFAULT_FOLDER_NAME : `Folder ${folderId}`), + resolveName: async (folderId) => (folderId == null ? DEFAULT_FOLDER_NAME : `Folder ${folderId}`), +}; + +const FolderManagerContext = createContext<FolderManager>(defaultManager); + +interface FolderManagerProviderProps { + folderNodes?: Map<string | 'root', { name?: string | null }>; + ensureFolderData?: (folderId: FolderId | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise<void>; + children: ReactNode; +} + +export const FolderManagerProvider: React.FC<FolderManagerProviderProps> = ({ + folderNodes, + ensureFolderData, + children, +}) => { + const value = useMemo<FolderManager>(() => { + if (!folderNodes || !ensureFolderData) { + return defaultManager; + } + + const getNameSync = (folderId: FolderId) => { + if (folderId == null) return DEFAULT_FOLDER_NAME; + return folderNodes.get(folderId)?.name ?? null; + }; + + const resolveName = async (folderId: FolderId) => { + const cached = getNameSync(folderId); + if (cached) return cached; + if (folderId == null) return DEFAULT_FOLDER_NAME; + await ensureFolderData(folderId, { includeDocuments: false }); + return getNameSync(folderId) ?? `Folder ${folderId}`; + }; + + return { getNameSync, resolveName }; + }, [folderNodes, ensureFolderData]); + + return ( + <FolderManagerContext.Provider value={value}> + {children} + </FolderManagerContext.Provider> + ); +}; + +export const useFolderManager = (): FolderManager => useContext(FolderManagerContext); diff --git a/frontend/src/hooks/useApiError.ts b/frontend/src/hooks/useApiError.ts new file mode 100644 index 0000000..1886924 --- /dev/null +++ b/frontend/src/hooks/useApiError.ts @@ -0,0 +1,50 @@ +import { useCallback } from 'react'; + +type ApiLogger = Pick<typeof console, 'error'>; + +type ApiErrorVariant = 'error' | 'info' | 'success' | 'warning' | string; + +interface ReportPayload { + message: string; + variant: ApiErrorVariant; + retry?: (() => void) | null; + error: unknown; +} + +interface UseApiErrorOptions { + logger?: ApiLogger; + onReport?: (payload: ReportPayload) => void; +} + +export const normalizeMessage = (error: unknown): string => { + if (!error) return 'Something went wrong.'; + if (typeof (error as { trim?: () => string })?.trim === 'function') { + return (error as { trim: () => string }).trim(); + } + const typed = error as { response?: { data?: { error?: string; message?: string } }; message?: string }; + if (typed.response?.data?.error) return typed.response.data.error; + if (typed.response?.data?.message) return typed.response.data.message; + return typed.message || 'Something went wrong.'; +}; + +const useApiError = ({ + logger = console, + onReport, +}: UseApiErrorOptions = {}) => { + return useCallback( + ( + error: unknown, + { message, variant = 'error', retry = null }: { message?: string; variant?: ApiErrorVariant; retry?: (() => void) | null } = {}, + ) => { + const normalizedMessage = message || normalizeMessage(error); + logger.error('[API]', normalizedMessage, error); + if (onReport) { + onReport({ message: normalizedMessage, variant, retry, error }); + } + return normalizedMessage; + }, + [logger, onReport], + ); +}; + +export default useApiError; diff --git a/frontend/src/hooks/useNotifyApiError.ts b/frontend/src/hooks/useNotifyApiError.ts new file mode 100644 index 0000000..40e4ce6 --- /dev/null +++ b/frontend/src/hooks/useNotifyApiError.ts @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; +import { useStatusToast, ToastVariant } from '../lib/context/StatusToastContext'; +import { normalizeMessage } from './useApiError'; + +const useNotifyApiError = () => { + const { showToast } = useStatusToast(); + + return useCallback( + (error: unknown, fallbackMessage?: string, variant: ToastVariant = 'error') => { + const message = fallbackMessage || normalizeMessage(error); + console.error('[API]', message, error); + showToast(message, variant); + }, + [showToast], + ); +}; + +export default useNotifyApiError; diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..84aa91a --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> + <title>Papercrate + + +
+ + diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx new file mode 100644 index 0000000..40ed57a --- /dev/null +++ b/frontend/src/index.tsx @@ -0,0 +1,116 @@ +import '@fontsource/inter/400.css'; + +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { + HashRouter, + Navigate, + Outlet, + Route, + Routes, +} from 'react-router-dom'; +import './styles/index.css'; +import DocumentsRoute from './app/DocumentsRoute'; +import DropOverlay from './app/DropOverlay'; +import LoginRoute from './app/LoginRoute'; +import SettingsRoute from './app/SettingsRoute'; +import { AppStateProvider, useAppState } from './lib/store/appState'; +import { useDocumentsPreferences } from './app/useDocumentsPreferences'; +import { AppShellContext } from './lib/context/AppShellContext'; +import useDocumentsWorkspace from './documents/data/useDocumentsWorkspace'; +import UploadQueueOverlay from './app/UploadQueueOverlay'; +import { StatusToastProvider } from './lib/context/StatusToastContext'; +import StatusToastOverlay from './components/StatusToastOverlay'; + +const AppLayout: React.FC = () => { + const documentsPreferences = useDocumentsPreferences(); + const { + appStatus, + location, + shellRef, + dropOverlayState, + managementModals, + contextValue, + settingsOpen, + closeSettings, + } = useDocumentsWorkspace({ + documentsViewMode: documentsPreferences.documentsViewMode, + documentsSortField: documentsPreferences.documentsSortField, + documentsSortDirection: documentsPreferences.documentsSortDirection, + onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange, + onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange, + onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle, + searchIncludeDescendants: documentsPreferences.searchIncludeDescendants, + onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, + }); + + if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { + const redirectTarget = `${location.pathname}${location.search}${location.hash || ''}`; + return ( + + ); + } + + return ( + +
+ + + + + {managementModals} + {settingsOpen ? ( + + ) : null} +
+
+ ); +}; + +const TenantAwareLayout: React.FC = () => { + const { tenant } = useAppState(); + // Force remount when tenant changes to ensure clean state (folders, selection, etc.) + const key = tenant?.id ? String(tenant.id) : undefined; + + return ; +}; + +const AppRouter: React.FC = () => ( + + } /> + }> + } /> + } /> + } /> + } /> + } /> + + +); + +const container = document.getElementById('app'); + +if (!container) { + throw new Error('App root element #app not found'); +} + +const root = createRoot(container); +root.render( + + + + + + + , +); diff --git a/frontend/src/lib/api/api.ts b/frontend/src/lib/api/api.ts new file mode 100644 index 0000000..13579c9 --- /dev/null +++ b/frontend/src/lib/api/api.ts @@ -0,0 +1,8 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: '/api', + withCredentials: true, +}); + +export default api; diff --git a/frontend/src/lib/api/apiClient.ts b/frontend/src/lib/api/apiClient.ts new file mode 100644 index 0000000..adf448f --- /dev/null +++ b/frontend/src/lib/api/apiClient.ts @@ -0,0 +1,446 @@ +import api from './api'; +export { api }; +import type { + ApiTokenRecord, + AssetResponse, + CapabilityResponse, + CapabilitySetResponse, + DownloadLink, + DocumentResponse, + Identifier, + PasskeySummary, + TenantSnippet, + TagResponse, + CorrespondentResponse, + FolderTreeNode, + CreateFolderResponse, +} from './apiTypes'; +import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios'; + +export const httpClient: Pick = { + get: api.get.bind(api), + post: api.post.bind(api), + patch: api.patch.bind(api), + delete: api.delete.bind(api), + defaults: api.defaults, +}; + +type AuthAwareRequestConfig = InternalAxiosRequestConfig & { + _retry?: boolean; + skipAuthRefresh?: boolean; +}; + +type AuthRequestConfig = AxiosRequestConfig & { + skipAuthRefresh?: boolean; +}; +type AuthRefreshHandlers = { + onRefreshSuccess?: (token: string, payload?: { tenant?: unknown }) => void; + onRefreshFailure?: (error: unknown) => void; +}; + +let refreshPromise: Promise | null = null; +let authRefreshHandlers: AuthRefreshHandlers = {}; + +const normalizeNumber = (value: unknown): number | undefined => { + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +}; + +const normalizeDownload = (input?: DownloadLink | null): DownloadLink | null => { + if (!input?.url) { + return null; + } + const expires_at = normalizeNumber(input.expires_at); + if (!expires_at) { + return null; + } + return { url: input.url, expires_at }; +}; + +export const fetchDocument = async (id: Identifier): Promise => { + const { data } = await api.get<{ document?: DocumentResponse }>(`/documents/${id}`); + const doc = data?.document || (data as DocumentResponse); + if (doc?.current_version?.download) { + doc.current_version.download = normalizeDownload(doc.current_version.download); + } + return doc; +}; + +export const fetchAsset = async (id: Identifier): Promise => { + const { data } = await api.get(`/assets/${id}`); + const download = normalizeDownload(data.download); + return { + ...data, + download, + }; +}; + +export const listDocuments = async (params: Record = {}): Promise => { + const { data } = await api.get('/documents', { params }); + return Array.isArray(data) ? data : []; +}; + +export const getFolderTree = async (): Promise => { + const { data } = await api.get('/folders/tree'); + return Array.isArray(data) ? data : []; +}; + +export const listCapabilitySets = async (): Promise => { + const { data } = await api.get('/capability-sets'); + return Array.isArray(data) ? data : []; +}; + +export const listCapabilities = async (): Promise => { + const { data } = await api.get('/capabilities'); + return Array.isArray(data) ? data : []; +}; + +export const listApiTokens = async (): Promise => { + const { data } = await api.get('/profile/api-tokens'); + return Array.isArray(data) ? data : []; +}; + +export const listPasskeys = async (): Promise => { + const { data } = await api.get('/profile/passkeys'); + return Array.isArray(data) ? data : []; +}; + +export const moveDocumentsBulk = async (documentIds: Identifier[], folderId: Identifier | null): Promise => { + await api.post('/documents/bulk/move', { + document_ids: documentIds, + folder_id: folderId, + }); +}; + +export const queueDocumentReanalysis = async ( + documentId: Identifier, + options: { force?: boolean } = {}, +): Promise => { + const { force = false } = options; + await api.post(`/documents/${documentId}/assets`, null, { params: { force } }); +}; + +export const trashDocument = async (documentId: Identifier): Promise => { + await api.post(`/documents/${documentId}/trash`); +}; + +export const addDocumentTags = async (documentId: Identifier, tagIds: Identifier[]): Promise => { + await api.post(`/documents/${documentId}/tags`, { tag_ids: tagIds }); +}; + +export const createTag = async (payload: { label: string; color?: string | null }): Promise => { + const { data } = await api.post('/tags', payload); + return data; +}; + +export const createFolder = async (payload: { name: string; parent_id?: Identifier | null }): Promise => { + const { data } = await api.post('/folders', payload); + return data; +}; + +export const assignCorrespondentsBulk = async ( + payload: Record, +): Promise => { + const { data } = await api.post('/documents/bulk/correspondents', payload); + return data; +}; + +export const createApiToken = async (payload: { + capability_set_id: Identifier; + label?: string; + expires_at?: string; +}): Promise<{ token_info?: ApiTokenRecord; token?: string }> => { + const { data } = await api.post('/profile/api-tokens', payload); + return data as { token_info?: ApiTokenRecord; token?: string }; +}; + +export const regenerateApiToken = async ( + tokenId: Identifier, +): Promise<{ token_info?: ApiTokenRecord; token?: string }> => { + const { data } = await api.post(`/profile/api-tokens/${tokenId}/regenerate`); + return data as { token_info?: ApiTokenRecord; token?: string }; +}; + +export const startPasskeyRegistration = async (): Promise => { + const { data } = await api.post('/auth/passkeys/register/start', {}); + return data; +}; + +export const finishPasskeyRegistration = async (payload: unknown): Promise => { + const { data } = await api.post('/auth/passkeys/register/finish', payload); + return data; +}; + +export const startPasskeyLogin = async (username: string): Promise => { + const { data } = await api.post('/auth/passkeys/login/start', { username }); + return data; +}; + +export const finishPasskeyLogin = async (payload: unknown): Promise => { + const { data } = await api.post('/auth/passkeys/login/finish', payload); + return data; +}; + +export const performLogin = async (payload: Record): Promise => { + const { data } = await api.post('/auth/login', payload); + return data; +}; + +export const refreshSession = async (): Promise<{ access_token?: string; tenant?: unknown }> => { + const { data } = await api.post('/auth/refresh', undefined, { skipAuthRefresh: true } as AuthRequestConfig); + return data as { access_token?: string; tenant?: unknown }; +}; + +export const logoutSession = async (): Promise => { + await api.post('/auth/logout'); +}; + +export const selectTenant = async ( + payload: { tenant_id: Identifier }, + selectionToken: string, +): Promise => { + const { data } = await api.post('/auth/select-tenant', payload, { + headers: { + Authorization: `Bearer ${selectionToken}`, + }, + }); + return data; +}; + +export const startSignup = async (username: string): Promise => { + const { data } = await api.post('/auth/signup/start', { username }); + return data; +}; + +export const finishSignup = async (payload: unknown): Promise => { + const { data } = await api.post('/auth/signup/finish', payload); + return data; +}; + +export const updateDocument = async ( + id: Identifier, + payload: Record, +): Promise => { + const { data } = await api.patch(`/documents/${id}`, payload); + return data; +}; + +export const moveDocumentToFolder = async (id: Identifier, folderId: Identifier | null): Promise => { + await api.patch(`/documents/${id}/folder`, { folder_id: folderId }); +}; + +export const deleteDocumentTag = async (documentId: Identifier, tagId: Identifier): Promise => { + await api.delete(`/documents/${documentId}/tags/${tagId}`); +}; + +export const deleteFolder = async (folderId: Identifier): Promise => { + await api.delete(`/folders/${folderId}`); +}; + +export const moveFolder = async (folderId: Identifier, parentId: Identifier | null): Promise => { + await api.patch(`/folders/${folderId}`, { parent_id: parentId }); +}; + +export const renameFolder = async (folderId: Identifier, name: string): Promise => { + await api.patch(`/folders/${folderId}`, { name }); +}; + +export const createCapabilitySet = async ( + payload: { slug?: string; label?: string; capabilities: string[] }, +): Promise => { + const { data } = await api.post('/capability-sets', payload); + return data; +}; + +export const updateCapabilitySet = async ( + id: Identifier, + payload: { slug?: string; label?: string; capabilities?: string[] }, +): Promise => { + const { data } = await api.patch(`/capability-sets/${id}`, payload); + return data; +}; + +export const deleteCapabilitySet = async (id: Identifier): Promise => { + await api.delete(`/capability-sets/${id}`); +}; + +export const deleteApiToken = async (tokenId: Identifier): Promise => { + await api.delete(`/profile/api-tokens/${tokenId}`); +}; + +export const deletePasskey = async ( + passkeyId: Identifier, + options: { reason?: string } = {}, +): Promise => { + const query = options.reason ? `?reason=${encodeURIComponent(options.reason)}` : ''; + await api.delete(`/profile/passkeys/${passkeyId}${query}`); +}; + +export const listTenants = async (): Promise => { + const { data } = await api.get<{ tenants?: TenantSnippet[] } | TenantSnippet[]>('/tenants'); + if (Array.isArray(data)) { + return data; + } + return Array.isArray(data?.tenants) ? data.tenants : []; +}; + +export const setAuthToken = (token: string) => { + api.defaults.headers.common.Authorization = `Bearer ${token}`; +}; + +export const clearAuthToken = () => { + delete api.defaults.headers.common.Authorization; +}; + +export const setAuthRefreshHandlers = (handlers: AuthRefreshHandlers) => { + authRefreshHandlers = handlers; +}; + +const performTokenRefresh = async (): Promise => { + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = refreshSession() + .then((data) => { + const token = data?.access_token; + if (!token) { + throw new Error('Missing access token in refresh response'); + } + setAuthToken(token); + authRefreshHandlers.onRefreshSuccess?.(token, { tenant: data?.tenant }); + return token; + }) + .catch((error) => { + authRefreshHandlers.onRefreshFailure?.(error); + throw error; + }) + .finally(() => { + refreshPromise = null; + }); + + return refreshPromise; +}; + +api.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const response = error.response; + const config = (error.config || {}) as AuthAwareRequestConfig; + if (!response || response.status !== 401 || config._retry || config.skipAuthRefresh) { + return Promise.reject(error); + } + + config._retry = true; + + try { + const token = await performTokenRefresh(); + const headers = (config.headers ?? {}) as Record; + headers.Authorization = `Bearer ${token}`; + config.headers = headers as AuthAwareRequestConfig['headers']; + return api(config); + } catch (refreshError) { + return Promise.reject(refreshError); + } + }, +); + +export const uploadDocument = async ( + formData: FormData, +): Promise<{ reused?: boolean; document?: unknown; status?: number }> => { + const { data, status } = await api.post<{ reused?: boolean; document?: unknown }>('/documents', formData); + return { ...data, status }; +}; + +export const resolveFolderPath = async ( + payload: { parent_id?: Identifier | null; segments: string[] }, +): Promise<{ folder?: { id?: Identifier | null } }> => { + const { data } = await api.post<{ folder?: { id?: Identifier | null } }>('/folders/path', payload); + return data; +}; + +export const bulkTagDocuments = async ( + payload: { document_ids: Identifier[]; tag_ids: Identifier[]; action: 'add' | 'remove' }, +): Promise => { + await api.post('/documents/bulk/tags', payload); +}; + +export const bulkReanalyzeDocuments = async ( + payload: { document_ids: Identifier[]; force?: boolean }, +): Promise<{ queued?: number }> => { + const { data } = await api.post<{ queued?: number }>('/documents/bulk/reanalyze', payload); + return data; +}; + +export const listTags = async (): Promise => { + const { data } = await api.get('/tags'); + return Array.isArray(data) ? data : []; +}; + +export const updateTag = async ( + tagId: Identifier, + payload: { label?: string; color?: string | null }, +): Promise => { + await api.patch(`/tags/${tagId}`, payload); +}; + +export const deleteTag = async (tagId: Identifier): Promise => { + await api.delete(`/tags/${tagId}`); +}; + +export const listCorrespondents = async (): Promise => { + const { data } = await api.get('/correspondents'); + return Array.isArray(data) ? data : []; +}; + +export const createCorrespondent = async (payload: { name: string }): Promise => { + const { data } = await api.post('/correspondents', payload); + return data; +}; + +export const updateCorrespondent = async ( + correspondentId: Identifier, + payload: { name?: string }, +): Promise => { + await api.patch(`/correspondents/${correspondentId}`, payload); +}; + +export const deleteCorrespondent = async (correspondentId: Identifier): Promise => { + await api.delete(`/correspondents/${correspondentId}`); +}; + +export const addDocumentCorrespondent = async ( + documentId: Identifier, + correspondentId: Identifier, +): Promise => { + await api.post(`/documents/${documentId}/correspondents`, { + assignments: [{ correspondent_id: correspondentId }], + replace: false, + }); +}; + +export const removeDocumentCorrespondent = async ( + documentId: Identifier, + correspondentId: Identifier, +): Promise => { + await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`); +}; + + + +export const switchTenant = async (tenantId: Identifier): Promise<{ access_token: string; tenant: any; tenants?: any[] }> => { + const { data } = await api.post<{ access_token: string; tenant: any; tenants?: any[] }>('/auth/select-tenant', { + tenant_id: tenantId, + }); + return data; +}; + +export const listFolderContents = async ( + path: string, + params?: Record, +): Promise => { + const { data } = await api.get(`/folders/${path}/contents`, { params }); + return data; +}; + +export type { ApiTokenRecord } from './apiTypes'; diff --git a/frontend/src/lib/api/apiTypes.ts b/frontend/src/lib/api/apiTypes.ts new file mode 100644 index 0000000..fb3ab62 --- /dev/null +++ b/frontend/src/lib/api/apiTypes.ts @@ -0,0 +1,113 @@ +// Types aligned with OpenAPI schemas for common endpoints. +import type { Identifier } from '../../types/identifiers'; + +export type { Identifier }; + +export interface DownloadLink { + url: string; + expires_at: number; +} + +export interface TagResponse { + id: string; + label: string; + color?: string | null; +} + +export interface CorrespondentResponse { + id: string; + name: string; + metadata: Record; +} + +export interface AssetResponse { + id: string; + asset_type: string; + mime_type: string; + metadata: Record; + download?: DownloadLink | null; + [key: string]: unknown; +} + +interface DocumentVersionResponse { + id: string; + version_number: number; + size_bytes: number; + checksum: string; + created_at: string; + mime_type?: string | null; + metadata: Record; + download: DownloadLink; + assets?: AssetResponse[] | null; +} + +export interface DocumentResponse { + id: string; + filename: string; + title: string; + original_name: string; + mime_type?: string | null; + folder_id?: string | null; + created_at: string; + updated_at: string; + issued_at?: string | null; + metadata: Record; + tags: TagResponse[]; + correspondents?: CorrespondentResponse[]; + current_version?: DocumentVersionResponse | null; +} + +export interface FolderInfo { + id: string; + name: string; + parent_id?: string | null; + created_at?: string; + updated_at?: string; +} + +export interface CreateFolderResponse { + folder: FolderInfo; +} + +export interface FolderTreeNode extends FolderInfo { + children?: FolderTreeNode[]; + hasChildren?: boolean; + loaded?: boolean; +} + +export interface CapabilitySetResponse { + id: string; + slug: string; + is_system: boolean; + cap_version: number; + capabilities: string[]; +} + +export interface CapabilityResponse { + id?: string; + name: string; +} + +export interface TenantSnippet { + id: string; + name: string; +} + +export interface ApiTokenRecord { + id: string; + label?: string | null; + capability_set_id: string; + created_at: string; + last_used_at?: string | null; + expires_at?: string | null; +} + +export interface PasskeySummary { + id: string; + nickname?: string | null; + createdAt: string; + lastUsedAt?: string | null; + transports?: string[]; + revokedAt?: string | null; + revokedReason?: string | null; +} diff --git a/frontend/src/lib/assets/AssetManager.ts b/frontend/src/lib/assets/AssetManager.ts new file mode 100644 index 0000000..2ad4b32 --- /dev/null +++ b/frontend/src/lib/assets/AssetManager.ts @@ -0,0 +1,175 @@ +import type { Identifier } from '../../types/identifiers'; +import type { Asset } from '../../types/assets'; +import type { DocumentVersion, Document } from '../../types/documents'; + +type Nullable = T | null; + +export type { Asset }; + +const resolveAssetExpiresAt = (asset?: { download?: { expires_at: number } | null } | null): number | null => + asset?.download?.expires_at ?? null; + +export const resolveAssetUrl = (asset?: { download?: { url: string } | null } | null): string | null => + asset?.download?.url ?? null; + +export type EnsureAssetUrl = ( + documentId: Identifier, + asset: Asset, + options?: { force?: boolean;[key: string]: unknown }, +) => Promise; + +export type GetAsset = (document: Document, assetType: string) => Nullable; + +const getAssetFromGroup = ( + assets?: Asset[] | Record | null, + assetType: string = '', +): Nullable => { + if (!assetType || !assets) { + return null; + } + + if (Array.isArray(assets)) { + return assets.find((entry) => entry?.asset_type === assetType) || null; + } + + return assets?.[assetType] || null; +}; + +export const getAssetFromVersion = (currentVersion: Nullable, assetType: string) => { + if (!currentVersion) { + return null; + } + return getAssetFromGroup(currentVersion.assets, assetType); +}; + + + +export const resolveDocumentAssetUrl = ( + doc: Nullable, + type: string, + { + ensureAssetUrl, + getAsset, + ensureOptions, + }: { + ensureAssetUrl?: EnsureAssetUrl; + getAsset?: GetAsset; + ensureOptions?: { force?: boolean;[key: string]: unknown }; + } = {}, +): Nullable => { + if (!doc || !type) { + return null; + } + const asset = getAsset ? getAsset(doc, type) : null; + if (!asset) { + return null; + } + const url = resolveAssetUrl(asset); + const expiresAt = resolveAssetExpiresAt(asset); + const now = Date.now(); + if (url && (!expiresAt || expiresAt > now)) { + return url; + } + if (doc.id && asset.id && ensureAssetUrl) { + const force = Boolean(url && expiresAt && expiresAt <= now); + const options: { force: boolean;[key: string]: unknown } = { + force, + ...(ensureOptions || {}), + }; + ensureAssetUrl(doc.id, asset, options).catch(() => { }); + } + return null; +}; + +class AssetManager { + fetchAsset: ((id: Identifier) => Promise) | null; + + assetCache: Map; + assetInflight: Map>; + + constructor({ fetchAsset }: { fetchAsset: ((id: Identifier) => Promise) | null }) { + this.fetchAsset = fetchAsset; + this.assetCache = new Map(); + this.assetInflight = new Map(); + } + + setFetchAsset(fetchAsset: ((id: Identifier) => Promise) | null) { + this.fetchAsset = fetchAsset; + } + + rememberAsset(entry?: Nullable) { + if (entry?.id) { + this.assetCache.set(entry.id, entry); + } + } + + ensureAsset( + documentId?: Identifier | null, + asset?: Nullable, + { force = false }: { force?: boolean } = {}, + ): Promise> { + if (!documentId || !asset?.id) { + return Promise.resolve(asset); + } + + const baseAsset = this.assetCache.get(asset.id) || asset; + const assetExpiresAt = resolveAssetExpiresAt(baseAsset); + const now = Date.now(); + + const isPrimarySatisfied = () => { + const assetUrl = resolveAssetUrl(baseAsset); + if (assetUrl && (!assetExpiresAt || assetExpiresAt > now)) { + return true; + } + return false; + }; + + let needsFetch = force; + if (!needsFetch) { + needsFetch = !isPrimarySatisfied(); + } + + if (!needsFetch) { + this.rememberAsset(baseAsset); + return Promise.resolve(baseAsset); + } + + const inflightKey = `${documentId}:${asset.id}`; + if (!force && this.assetInflight.has(inflightKey)) { + return this.assetInflight.get(inflightKey); + } + + if (!this.fetchAsset) { + return Promise.reject(new Error('AssetManager fetcher is not configured.')); + } + + const request: Promise = this.fetchAsset(asset.id) + .then((data) => { + if (!data) return null; + const cachedEntry = this.assetCache.get(asset.id) || baseAsset; + const combined = { ...cachedEntry, ...asset, ...data }; + const expires_at = resolveAssetExpiresAt(combined); + const entry = { + ...combined, + url: resolveAssetUrl(combined), + expires_at, + }; + + this.rememberAsset(entry); + return entry; + }) + .finally(() => { + this.assetInflight.delete(inflightKey); + }); + + this.assetInflight.set(inflightKey, request); + return request; + } + + reset() { + this.assetCache.clear(); + this.assetInflight.clear(); + } +} + +export default AssetManager; diff --git a/frontend/src/lib/assets/CorrespondentManager.ts b/frontend/src/lib/assets/CorrespondentManager.ts new file mode 100644 index 0000000..dbaf656 --- /dev/null +++ b/frontend/src/lib/assets/CorrespondentManager.ts @@ -0,0 +1,144 @@ +import { listCorrespondents, createCorrespondent, updateCorrespondent, deleteCorrespondent } from '../api/apiClient'; +import type { Identifier } from '../../types/identifiers'; +import type { Correspondent } from '../../types/documents'; + +interface CorrespondentPayload { + name: string; +} + +type Listener = () => void; + +class CorrespondentManager { + private byId: Map = new Map(); + private listeners: Set = new Set(); + private correspondentsPromise: Promise | null = null; + private loaded = false; + + constructor() { + // No specific options for now + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit() { + this.listeners.forEach((listener) => listener()); + } + + getSnapshot(): Map { + return this.byId; + } + + ingest(correspondents: Correspondent[]): void { + let changed = false; + let nextMap: Map | null = null; + + correspondents.forEach((corr) => { + if (!corr.id) return; + const existing = this.byId.get(corr.id); + if (JSON.stringify(existing) !== JSON.stringify(corr)) { + if (!nextMap) nextMap = new Map(this.byId); + nextMap.set(corr.id, corr); + changed = true; + } + }); + + if (changed && nextMap) { + this.byId = nextMap; + this.emit(); + } + } + + remove(ids: Identifier[]): void { + let changed = false; + let nextMap: Map | null = null; + + ids.forEach((id) => { + if (this.byId.has(id)) { + if (!nextMap) nextMap = new Map(this.byId); + nextMap.delete(id); + changed = true; + } + }); + if (changed && nextMap) { + this.byId = nextMap; + this.emit(); + } + } + + async ensureAll(force = false): Promise { + if (this.loaded && !force && this.byId.size > 0) { + return Array.from(this.byId.values()); + } + + if (this.correspondentsPromise && !force) { + return this.correspondentsPromise; + } + + this.correspondentsPromise = this.fetchCorrespondentsInternal(); + return this.correspondentsPromise; + } + + private async fetchCorrespondentsInternal(): Promise { + try { + const results = await listCorrespondents(); + const castResults = (results || []) as unknown as Correspondent[]; + this.byId = new Map(); // Reset + castResults.forEach(item => { + if (item.id) this.byId.set(item.id, item); + }); + // Emit needed for full refresh + this.emit(); + this.loaded = true; + return castResults; + } catch (error) { + console.warn('Failed to fetch correspondents', error); + return []; + } finally { + this.correspondentsPromise = null; + } + } + + async create(payload: CorrespondentPayload): Promise { + const response = await createCorrespondent(payload); + const newEntry = response as unknown as Correspondent; + this.ingest([newEntry]); + return newEntry; + } + + async update(id: Identifier, changes: Partial): Promise { + await updateCorrespondent(id, changes); + const existing = this.byId.get(id); + if (existing) { + const updated = { ...existing, ...changes }; + this.ingest([updated as Correspondent]); + } else { + this.ensureAll(true); + } + } + + async delete(id: Identifier): Promise { + await deleteCorrespondent(id); + this.remove([id]); + } + + normalizeName(name?: string | null): string { + return name?.trim?.() || ''; + } + + buildPayload({ name }: { name?: string | null } = {}): CorrespondentPayload { + const normalizedName = this.normalizeName(name); + if (!normalizedName) { + throw new Error('Correspondent name is required.'); + } + return { + name: normalizedName, + }; + } +} + +export default CorrespondentManager; diff --git a/frontend/src/lib/assets/TagManager.ts b/frontend/src/lib/assets/TagManager.ts new file mode 100644 index 0000000..d8d7021 --- /dev/null +++ b/frontend/src/lib/assets/TagManager.ts @@ -0,0 +1,161 @@ +import { generateRandomTagColor } from '../../utils/colors'; +import { listTags, createTag, updateTag, deleteTag } from '../api/apiClient'; +import type { TagId } from '../../types/identifiers'; +import type { Tag } from '../../types/documents'; + +type ColorGenerator = () => string; + +interface TagManagerOptions { + colorGenerator?: ColorGenerator; +} + +interface TagPayload { + label: string; + color: string; +} + +type Listener = () => void; + +class TagManager { + private readonly colorGenerator: ColorGenerator; + private byId: Map = new Map(); + private listeners: Set = new Set(); + private tagsPromise: Promise | null = null; + private loaded = false; + + constructor({ colorGenerator = generateRandomTagColor }: TagManagerOptions = {}) { + this.colorGenerator = colorGenerator; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emit() { + this.listeners.forEach((listener) => listener()); + } + + getSnapshot(): Map { + return this.byId; + } + + ingest(tags: Tag[]): void { + let changed = false; + let nextMap: Map | null = null; + + tags.forEach((tag) => { + if (!tag.id) return; + const existing = this.byId.get(tag.id); + if (JSON.stringify(existing) !== JSON.stringify(tag)) { + if (!nextMap) nextMap = new Map(this.byId); + nextMap.set(tag.id, tag); + changed = true; + } + }); + + if (changed && nextMap) { + this.byId = nextMap; + this.emit(); + } + } + + remove(ids: TagId[]): void { + let changed = false; + let nextMap: Map | null = null; + + ids.forEach((id) => { + if (this.byId.has(id)) { + if (!nextMap) nextMap = new Map(this.byId); + nextMap.delete(id); + changed = true; + } + }); + if (changed && nextMap) { + this.byId = nextMap; + this.emit(); + } + } + + async ensureAll(force = false): Promise { + if (this.loaded && !force && this.byId.size > 0) { + return Array.from(this.byId.values()); + } + + if (this.tagsPromise && !force) { + return this.tagsPromise; + } + + this.tagsPromise = this.fetchTagsInternal(); + return this.tagsPromise; + } + + private async fetchTagsInternal(): Promise { + try { + const tags = await listTags(); + const castTags = (tags || []) as unknown as Tag[]; + this.byId = new Map(); // Reset + castTags.forEach(tag => { + if (tag.id) this.byId.set(tag.id, tag); + }); + // Emit strictly needed? Usually ingest handles this but here we doing full reset + this.emit(); + this.loaded = true; + return castTags; + } catch (error) { + console.warn('Failed to fetch tags', error); + return []; + } finally { + this.tagsPromise = null; + } + } + + async create(payload: TagPayload): Promise { + const response = await createTag(payload); + const newTag = response as unknown as Tag; + this.ingest([newTag]); + return newTag; + } + + async update(tagId: TagId, changes: Partial): Promise { + await updateTag(tagId, changes); + // Optimistic update or re-fetch? + // Since updateTag doesn't return the full tag, we can optimistically update + const existing = this.byId.get(tagId); + if (existing) { + const updated = { ...existing, ...changes }; + this.ingest([updated]); + } else { + // Fallback: fetch specific tag or refresh all? + // For now, let's refresh all to be safe, or just ignore if we don't have it. + // But if we are updating it, we probably should have it. + // Let's trigger a refresh in background to be safe. + this.ensureAll(true); + } + } + + async delete(tagId: TagId): Promise { + await deleteTag(tagId); + this.remove([tagId]); + } + + normalizeLabel(label?: string | null): string { + return label?.trim?.() || ''; + } + + buildPayload({ label, color }: { label?: string | null; color?: string | null } = {}): TagPayload { + const normalizedLabel = this.normalizeLabel(label); + if (!normalizedLabel) { + throw new Error('Tag label is required.'); + } + const trimmedColor = color?.trim?.() || null; + return { + label: normalizedLabel, + color: trimmedColor || this.colorGenerator(), + }; + } +} + +export default TagManager; diff --git a/frontend/src/lib/context/ApiContext.tsx b/frontend/src/lib/context/ApiContext.tsx new file mode 100644 index 0000000..d51110d --- /dev/null +++ b/frontend/src/lib/context/ApiContext.tsx @@ -0,0 +1,38 @@ +import React, { useEffect, useMemo } from 'react'; +import type { PropsWithChildren } from 'react'; +import { httpClient, setAuthToken, clearAuthToken } from '../api/apiClient'; +import { createSafeContext } from '../../utils/createSafeContext'; + +type HttpClient = typeof httpClient; + +interface ApiContextValue { + client: HttpClient; + setAuthToken: (token: string) => void; + clearAuthToken: () => void; +} + +const [ApiContext, useApi] = createSafeContext('Api'); + +export const ApiProvider: React.FC> = ({ + initialToken = null, + children, +}) => { + useEffect(() => { + if (initialToken) { + setAuthToken(initialToken); + } + }, [initialToken]); + + const value = useMemo( + () => ({ + client: httpClient, + setAuthToken, + clearAuthToken, + }), + [], + ); + + return {children}; +}; + +export { useApi }; diff --git a/frontend/src/lib/context/AppShellContext.ts b/frontend/src/lib/context/AppShellContext.ts new file mode 100644 index 0000000..ec7e660 --- /dev/null +++ b/frontend/src/lib/context/AppShellContext.ts @@ -0,0 +1,5 @@ +import { createSafeContext } from '../../utils/createSafeContext'; + +type AppShellContextValue = Record; + +export const [AppShellContext, useAppShell] = createSafeContext('AppShell'); diff --git a/frontend/src/lib/context/DocumentOpenContext.tsx b/frontend/src/lib/context/DocumentOpenContext.tsx new file mode 100644 index 0000000..20670ac --- /dev/null +++ b/frontend/src/lib/context/DocumentOpenContext.tsx @@ -0,0 +1,63 @@ +import React, { useCallback } from 'react'; +import type { Document } from '../../types/documents'; +import type { Identifier } from '../../types/identifiers'; +import { createSafeContext } from '../../utils/createSafeContext'; + +type DocumentOpenIntent = 'preview' | 'inspect' | 'navigate'; + +interface DocumentOpenContextValue { + openDocument: (doc: Document, intent?: DocumentOpenIntent) => void; +} + +const [DocumentOpenContext, useDocumentOpen] = createSafeContext('DocumentOpen'); + +interface DocumentOpenProviderProps { + children: React.ReactNode; + onOpenViewer?: (docId: Identifier) => void; + onOpenFullscreenPreview?: (doc: Document) => void; + onOpenDetailPanel?: (docId: Identifier) => void; +} + +export const DocumentOpenProvider: React.FC = ({ + children, + onOpenViewer, + onOpenFullscreenPreview, + onOpenDetailPanel, +}) => { + const openDocument = useCallback((doc: Document, intent: DocumentOpenIntent = 'preview') => { + if (!doc) return; + + switch (intent) { + case 'preview': + if (onOpenFullscreenPreview) { + onOpenFullscreenPreview(doc); + } + break; + case 'inspect': + // Responsive behavior: on mobile, "inspect" just navigates to the document + if (window.matchMedia('(max-width: 768px)').matches) { + if (onOpenViewer) { + onOpenViewer(doc.id); + } + } else { + if (onOpenDetailPanel) { + onOpenDetailPanel(doc.id); + } + } + break; + case 'navigate': + if (onOpenViewer) { + onOpenViewer(doc.id); + } + break; + } + }, [onOpenFullscreenPreview, onOpenDetailPanel, onOpenViewer]); + + return ( + + {children} + + ); +}; + +export { useDocumentOpen }; diff --git a/frontend/src/lib/context/StatusToastContext.tsx b/frontend/src/lib/context/StatusToastContext.tsx new file mode 100644 index 0000000..fcd8a2b --- /dev/null +++ b/frontend/src/lib/context/StatusToastContext.tsx @@ -0,0 +1,115 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { createSafeContext } from '../../utils/createSafeContext'; + +export type ToastVariant = 'info' | 'success' | 'error'; + +interface ToastMessage { + id: string; + message: string; + variant: ToastVariant; + timestamp: number; + duration: number; +} + +interface StatusToastContextValue { + toasts: ToastMessage[]; + showToast: (message: string, variant?: ToastVariant, duration?: number) => void; + removeToast: (id: string) => void; +} + +const [StatusToastContext, useStatusToast] = createSafeContext('StatusToast'); + +const DEFAULT_DURATIONS: Record = { + success: 3000, + info: 5000, + error: 8000, +}; + +const MAX_TOASTS = 3; + +export const StatusToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [toasts, setToasts] = useState([]); + const timeoutRefs = useRef>(new Map()); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + + // Clear timeout if it exists + const timeout = timeoutRefs.current.get(id); + if (timeout) { + clearTimeout(timeout); + timeoutRefs.current.delete(id); + } + }, []); + + const showToast = useCallback((message: string, variant: ToastVariant = 'info', duration?: number) => { + // Log to console based on variant + if (variant === 'error') { + console.error(`[Toast] ${message}`); + } else if (variant === 'success') { + console.log(`[Toast] ${message}`); + } else { + console.info(`[Toast] ${message}`); + } + + const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const finalDuration = duration ?? DEFAULT_DURATIONS[variant]; + + const newToast: ToastMessage = { + id, + message, + variant, + timestamp: Date.now(), + duration: finalDuration, + }; + + setToasts((prev) => { + const updated = [...prev, newToast]; + + // If we exceed max toasts, remove the oldest ones + if (updated.length > MAX_TOASTS) { + const removed = updated.slice(0, updated.length - MAX_TOASTS); + removed.forEach((toast) => { + const timeout = timeoutRefs.current.get(toast.id); + if (timeout) { + clearTimeout(timeout); + timeoutRefs.current.delete(toast.id); + } + }); + return updated.slice(-MAX_TOASTS); + } + + return updated; + }); + + // Set auto-dismiss timeout - trigger fade then remove + const timeout = setTimeout(() => { + removeToast(id); + }, finalDuration); + + timeoutRefs.current.set(id, timeout); + }, [removeToast]); + + // Cleanup all timeouts on unmount + useEffect(() => { + const timeouts = timeoutRefs.current; + return () => { + timeouts.forEach((timeout) => clearTimeout(timeout)); + timeouts.clear(); + }; + }, []); + + const value: StatusToastContextValue = { + toasts, + showToast, + removeToast, + }; + + return ( + + {children} + + ); +}; + +export { useStatusToast }; diff --git a/frontend/src/lib/store/appState.tsx b/frontend/src/lib/store/appState.tsx new file mode 100644 index 0000000..48ee7e2 --- /dev/null +++ b/frontend/src/lib/store/appState.tsx @@ -0,0 +1,271 @@ +import React, { useEffect, useReducer } from 'react'; +import { createSafeContext } from '../../utils/createSafeContext'; +import { clearAuthToken, setAuthToken, setAuthRefreshHandlers } from '../api/apiClient'; +import { ApiProvider } from '../context/ApiContext'; +import { listTenants } from '../api/apiClient'; +import { STORED_TOKEN_KEY } from '../../constants/app'; + +type Tenant = Record | null; + +interface TenantSelection { + selectionToken: string; + tenants: Tenant[]; +} + +type AppStatus = + | 'logged-out' + | 'authenticating' + | 'authenticated' + | 'selecting-tenant' + | 'bootstrapping' + | 'ready'; + +interface AppState { + status: AppStatus; + token: string; + error: string | null; + isRefreshing: boolean; + tenantSelection: TenantSelection | null; + tenant: Tenant; + tenants: Tenant[]; +} + +type AppAction = + | { type: 'LOGIN_REQUEST' } + | { type: 'LOGIN_SUCCESS'; token: string; tenant?: Tenant } + | { type: 'LOGIN_FAILURE'; error?: string | null } + | { type: 'TENANT_SELECTION_REQUIRED'; selectionToken: string; tenants: Tenant[] } + | { type: 'CLEAR_TENANT_SELECTION' } + | { type: 'LOGOUT_SUCCESS' } + | { type: 'BOOTSTRAP_START' } + | { type: 'BOOTSTRAP_SUCCESS' } + | { type: 'BOOTSTRAP_FAILURE'; error?: string | null } + | { type: 'TOKEN_REFRESH_START' } + | { type: 'TOKEN_REFRESH_SUCCESS'; token: string; tenant?: Tenant } + | { type: 'TOKEN_REFRESH_FAILURE'; error?: string | null } + | { type: 'LOGOUT' } + | { type: 'RESET_ERROR' } + | { type: 'SET_TENANTS'; tenants: Tenant[] }; + +const storage = window.sessionStorage; + +const storedToken = storage?.getItem(STORED_TOKEN_KEY) ?? ''; +let STORED_TENANT: Tenant = null; + +if (storage) { + try { + const rawTenant = storage.getItem('papercrate_tenant'); + if (rawTenant) { + STORED_TENANT = JSON.parse(rawTenant); + } + } catch (error) { + console.warn('[app] Failed to parse stored tenant metadata', error); + } +} + +if (storedToken) { + setAuthToken(storedToken); +} + +const initialAppState: AppState = { + status: storedToken ? 'authenticated' : 'logged-out', + token: storedToken, + error: null, + isRefreshing: false, + tenantSelection: null, + tenant: STORED_TENANT, + tenants: [], +}; + +const [AppStateContext, useAppState] = createSafeContext('AppState'); +const [AppDispatchContext, useAppDispatch] = createSafeContext>('AppDispatch'); + +const appStateReducer = (state: AppState, action: AppAction): AppState => { + switch (action.type) { + case 'LOGIN_REQUEST': + return { + ...state, + status: 'authenticating', + error: null, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'LOGIN_SUCCESS': + return { + ...state, + status: 'authenticated', + token: action.token, + error: null, + tenantSelection: null, + tenant: action.tenant ?? null, + tenants: state.tenants, + }; + case 'LOGIN_FAILURE': + return { + status: 'logged-out', + token: '', + error: action.error ?? null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'TENANT_SELECTION_REQUIRED': + return { + status: 'selecting-tenant', + token: '', + error: null, + isRefreshing: false, + tenantSelection: { + selectionToken: action.selectionToken, + tenants: action.tenants, + }, + tenant: null, + tenants: [], + }; + case 'CLEAR_TENANT_SELECTION': + return { + status: 'logged-out', + token: '', + error: null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'LOGOUT_SUCCESS': + case 'LOGOUT': + return { + status: 'logged-out', + token: '', + error: null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'BOOTSTRAP_START': + return { ...state, status: 'bootstrapping', error: null }; + case 'BOOTSTRAP_SUCCESS': + return { ...state, status: 'ready', error: null }; + case 'BOOTSTRAP_FAILURE': + return { ...state, status: 'authenticated', error: action.error ?? null }; + case 'TOKEN_REFRESH_START': + return { ...state, isRefreshing: true, error: null }; + case 'TOKEN_REFRESH_SUCCESS': + return { + ...state, + token: action.token, + isRefreshing: false, + status: state.status === 'logged-out' ? 'authenticated' : state.status, + tenantSelection: null, + tenant: action.tenant ?? state.tenant ?? null, + tenants: state.tenants, + }; + case 'TOKEN_REFRESH_FAILURE': + return { + status: 'logged-out', + token: '', + error: action.error ?? null, + isRefreshing: false, + tenantSelection: null, + tenant: null, + tenants: [], + }; + case 'RESET_ERROR': + return { ...state, error: null }; + case 'SET_TENANTS': + return { + ...state, + tenants: Array.isArray(action.tenants) ? action.tenants : [], + }; + default: + return state; + } +}; + +const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(appStateReducer, initialAppState); + + useEffect(() => { + const token = state.token ?? ''; + if (token) { + setAuthToken(token); + storage?.setItem('papercrate_token', token); + } else { + clearAuthToken(); + storage?.removeItem('papercrate_token'); + } + }, [state.token]); + + useEffect(() => { + if (state.tenant) { + try { + storage?.setItem('papercrate_tenant', JSON.stringify(state.tenant)); + } catch (error) { + console.warn('[app] Failed to persist tenant info', error); + } + } else { + storage?.removeItem('papercrate_tenant'); + } + }, [state.tenant]); + + useEffect(() => { + setAuthRefreshHandlers({ + onRefreshSuccess: (token, payload) => { + dispatch({ type: 'TOKEN_REFRESH_SUCCESS', token, tenant: payload?.tenant ?? null }); + }, + onRefreshFailure: (error) => { + dispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null }); + }, + }); + + return () => { + setAuthRefreshHandlers({}); + }; + }, [dispatch]); + + useEffect(() => { + let abort = false; + + const loadTenants = async () => { + if (state.status !== 'authenticated' || !state.token) { + dispatch({ type: 'SET_TENANTS', tenants: [] }); + return; + } + + try { + if (!abort) { + const tenants = await listTenants(); + dispatch({ + type: 'SET_TENANTS', + tenants, + }); + } + } catch (error) { + if (!abort) { + console.warn('Failed to load tenant list', error); + } + } + }; + + loadTenants(); + + return () => { + abort = true; + }; + }, [state.status, state.token, dispatch]); + + return ( + + + + {children} + + + + ); +}; + +export { AppStateProvider, useAppState, useAppDispatch }; diff --git a/frontend/src/login/LoginView.tsx b/frontend/src/login/LoginView.tsx new file mode 100644 index 0000000..3d5910a --- /dev/null +++ b/frontend/src/login/LoginView.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useState } from 'react'; + +const loginLogoSrc = new URL('../assets/logo.webp', import.meta.url).toString(); + +interface StatusBannerProps { + status?: { message: string; variant: string } | null; +} + +const StatusBanner: React.FC = ({ status }) => { + if (!status) return null; + return
{status.message}
; +}; + +interface TenantOption { + id?: string; + name?: string; +} + +interface TenantSelectionState { + tenants?: TenantOption[]; +} + +interface LoginViewProps { + status?: { message: string; variant: string } | null; + tenantSelection?: TenantSelectionState | null; + onSelectTenant?: (tenant: TenantOption) => void; + onCancelSelection?: () => void; + selectingTenantId?: string | null; + onPasskeyLogin?: (username: string) => void; + onSignup?: (username: string) => void; + passkeySupported?: boolean; + passkeyLoading?: boolean; + signupSupported?: boolean; + signupLoading?: boolean; + magicLoginPending?: boolean; + initialUsername?: string; +} + +const LoginView: React.FC = ({ + status, + tenantSelection, + onSelectTenant, + onCancelSelection, + selectingTenantId, + onPasskeyLogin, + onSignup, + passkeySupported = false, + passkeyLoading = false, + signupSupported = false, + signupLoading = false, + magicLoginPending = false, + initialUsername = '', +}) => { + const hasTenantSelection = Boolean(tenantSelection?.tenants?.length); + const [username, setUsername] = useState(initialUsername); + + useEffect(() => { + setUsername(initialUsername); + }, [initialUsername]); + + const handlePasskeyClick = () => { + if (!onPasskeyLogin) { + return; + } + onPasskeyLogin(username); + }; + + const handleSignupClick = () => { + if (!onSignup) { + return; + } + onSignup(username); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!passkeySupported || passkeyLoading || magicLoginPending || !username.trim()) { + return; + } + handlePasskeyClick(); + }; + + return ( +
+
+
+
+ Papercrate logo +

Papercrate

+
+ +
+ {hasTenantSelection ? ( +
+

Select a tenant to finish signing in.

+
+ {tenantSelection?.tenants?.map((tenant) => ( + + ))} +
+ +
+ ) : ( + <> +

Use your registered passkey to sign in or create a new account.

+
+ + setUsername(event.target.value)} + placeholder="Username" + autoComplete="username" + disabled={passkeyLoading || magicLoginPending} + required + /> + {passkeySupported ? ( + + ) : ( +

Passkeys are not supported in this browser.

+ )} +
+ {signupSupported ? ( + + ) : null} + + )} + +
+
+
+
+ ); +}; + +export default LoginView; diff --git a/frontend/src/login/login.css b/frontend/src/login/login.css new file mode 100644 index 0000000..24d057b --- /dev/null +++ b/frontend/src/login/login.css @@ -0,0 +1,186 @@ + +.login-screen { + min-height: 100vh; + min-height: 100dvh; + height: 100%; + background: var(--bg); + padding: 2rem; +} + +.login-screen__inner { + min-height: calc(100vh - 4rem); + min-height: calc(100dvh - 4rem); + max-width: 24rem; + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1.75rem; +} + +.login-screen__content { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 1.75rem; +} + +.login-screen__brand { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.65rem; + text-align: center; +} + +.login-screen__brand img { + width: 11rem; + height: 11rem; + border: none; + background: transparent; + padding: 0; + box-shadow: none; +} + +.login-screen__brand h1 { + margin: 0; + font-size: 2.35rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.login-card { + width: min(340px, 100%); + background: var(--surface); + border-radius: 2px; + padding: 1.75rem; + box-shadow: none; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.login-card p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.login-card form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.login-card label { + font-size: 0.85rem; + font-weight: 600; + color: var(--muted); +} + +.login-card__fields { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; +} + +.login-card .login-card__fields input { + appearance: none; + border-radius: 0.45rem; + border: 1px solid var(--border); + padding: 0.55rem 0.65rem; + font-size: 0.95rem; + background: var(--surface); + color: var(--fg); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.login-card .login-card__fields input:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); + background: var(--surface); +} + +.login-card__passkey-button { + margin-top: 1rem; + width: 100%; + display: inline-flex; + justify-content: center; +} + +.login-card__signup-button { + margin-top: 0.75rem; + width: 100%; + display: inline-flex; + justify-content: center; +} + +.login-card .status-banner { + margin-bottom: 0; +} + +.login-card__selection { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.login-card__tenant-list { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.login-card__tenant-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: 0.6rem; + border: 1px solid var(--border); + background: var(--surface); + color: inherit; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.login-card__tenant-button:hover:not([disabled]) { + background: var(--surface-hover); + border-color: var(--border-strong); +} + +.login-card__tenant-button:disabled { + opacity: 0.6; + cursor: wait; +} + +.login-card__tenant-button.is-loading { + opacity: 0.6; +} + +.login-card__back-button { + align-self: flex-start; + background: none; + border: none; + padding: 0; + color: var(--accent); + font-size: 0.85rem; + cursor: pointer; + text-decoration: underline; +} + +.login-card__back-button:hover { + text-decoration: none; +} + +.login-card__back-button:disabled { + opacity: 0.6; + cursor: default; +} diff --git a/frontend/src/settings/SettingsModal.tsx b/frontend/src/settings/SettingsModal.tsx new file mode 100644 index 0000000..2f2c723 --- /dev/null +++ b/frontend/src/settings/SettingsModal.tsx @@ -0,0 +1,127 @@ +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import PanelHeader from '../components/PanelHeader'; +import { CloseIcon } from '../components/icons'; +import { DEFAULT_SETTINGS_SECTIONS } from '../constants/settings'; + +export interface SettingsSectionConfig { + id: string; + label: string; + component?: React.ComponentType; + render?: (props: Record) => ReactNode; +} + +interface SettingsModalProps { + open?: boolean; + onClose?: () => void; + sections?: SettingsSectionConfig[]; + defaultSectionId?: string; + [key: string]: unknown; +} + +const SettingsModal: React.FC = ({ + open = false, + onClose, + sections, + defaultSectionId, + ...sectionProps +}) => { + const sectionList: SettingsSectionConfig[] = useMemo(() => { + if (Array.isArray(sections) && sections.length) { + return sections; + } + return DEFAULT_SETTINGS_SECTIONS as SettingsSectionConfig[]; + }, [sections]); + + const firstSectionId = sectionList[0]?.id ?? null; + const resolvedDefaultSection = defaultSectionId || firstSectionId; + + const [activeSection, setActiveSection] = useState(resolvedDefaultSection); + + useEffect(() => { + if (!open) { + setActiveSection(resolvedDefaultSection); + return; + } + const hasActiveSection = sectionList.some((section) => section.id === activeSection); + if (!hasActiveSection) { + setActiveSection(resolvedDefaultSection); + } + }, [open, sectionList, resolvedDefaultSection, activeSection]); + + const handleBackdropClick = useCallback(() => { + onClose?.(); + }, [onClose]); + + const handleInnerClick = useCallback((event) => { + event.stopPropagation(); + }, []); + + if (!open) { + return null; + } + + const activeSectionConfig = sectionList.find((section) => section.id === activeSection); + let sectionContent = null; + if (activeSectionConfig) { + if (activeSectionConfig.component) { + const SectionComponent = activeSectionConfig.component; + sectionContent = ; + } else if (activeSectionConfig.render) { + sectionContent = activeSectionConfig.render(sectionProps); + } + } + + return ( +
+
+ + + + )} + /> +
+ +
+ {sectionContent || ( +

Select a settings section.

+ )} +
+
+
+
+ ); +}; + +export default SettingsModal; diff --git a/frontend/src/settings/components/CapabilityDropdown.tsx b/frontend/src/settings/components/CapabilityDropdown.tsx new file mode 100644 index 0000000..a45b5fc --- /dev/null +++ b/frontend/src/settings/components/CapabilityDropdown.tsx @@ -0,0 +1,196 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { JSX } from 'react'; +import { CheckIcon, ChevronDownIcon } from '../../components/icons'; +import type { CapabilityValue } from '../../types/identifiers'; + +export interface CapabilityDropdownOption { + value?: CapabilityValue | null; + id?: CapabilityValue | null; + label?: string; +} + +interface CapabilityDropdownProps { + id?: string; + options?: Array; + selectedValues?: CapabilityValue[]; + onSelect?: (value: CapabilityValue) => void; + onDeselect?: (value: CapabilityValue) => void; + formatLabel?: (value: CapabilityValue) => string; + disabled?: boolean; + loading?: boolean; + summaryLabel?: string; +} + + + +const resolveCapabilityValue = ( + option: CapabilityDropdownOption | CapabilityValue | null, +): CapabilityValue | null => { + if (option == null) { + return null; + } + if (typeof option !== 'object') { + return option; + } + if (option.value != null) { + return option.value; + } + if (option.id != null) { + return option.id; + } + return null; +}; + +const CapabilityDropdown = ({ + id, + options = [], + selectedValues = [], + onSelect, + onDeselect, + formatLabel, + disabled = false, + loading = false, + summaryLabel = 'capabilities', +}: CapabilityDropdownProps): JSX.Element => { + const anchorRef = useRef(null); + const menuRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + const toggle = useCallback(() => { + if (disabled) { + return; + } + setIsOpen((previous) => !previous); + }, [disabled]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + const handlePointerEvent = (event: MouseEvent | TouchEvent) => { + if (anchorRef.current?.contains(event.target as Node)) { + return; + } + if (menuRef.current?.contains(event.target as Node)) { + return; + } + close(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + close(); + } + }; + + document.addEventListener('mousedown', handlePointerEvent); + document.addEventListener('touchstart', handlePointerEvent, { passive: true }); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handlePointerEvent); + document.removeEventListener('touchstart', handlePointerEvent); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [close, isOpen]); + + const handleOptionClick = useCallback((value: CapabilityValue) => { + if (selectedValues.includes(value)) { + onDeselect?.(value); + } else { + onSelect?.(value); + } + }, [onDeselect, onSelect, selectedValues]); + + const derivedOptions = useMemo(() => options.filter(Boolean), [options]); + const total = derivedOptions.length; + const selectedCount = selectedValues.length; + + const summaryText = useMemo(() => { + if (total) { + return `${selectedCount}/${total} ${summaryLabel} enabled`; + } + if (selectedCount) { + return `${selectedCount} ${summaryLabel} selected`; + } + if (loading) { + return `Loading ${summaryLabel}…`; + } + return `No ${summaryLabel}`; + }, [loading, selectedCount, summaryLabel, total]); + + const buttonText = total || selectedCount || loading ? summaryText : `Select ${summaryLabel}`; + const emptyMessage = loading ? `Loading ${summaryLabel}…` : `No ${summaryLabel} available.`; + const isDisabled = disabled || (total === 0 && !selectedCount) || loading; + const menuId = id ? `${id}-menu` : undefined; + + return ( +
+ + {isOpen ? ( + + ) : null} +
+ ); +}; + +export default CapabilityDropdown; diff --git a/frontend/src/settings/sections/ApiTokensSection.tsx b/frontend/src/settings/sections/ApiTokensSection.tsx new file mode 100644 index 0000000..4f5561a --- /dev/null +++ b/frontend/src/settings/sections/ApiTokensSection.tsx @@ -0,0 +1,482 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ChangeEvent, FormEvent } from 'react'; +import type { Identifier } from '../../types/identifiers'; +import { formatDateTime } from '../../utils/date'; + +interface ApiTokenEntry { + id?: Identifier; + label?: string; + expires_at?: string; + created_at?: string; + last_used_at?: string; + revoked_at?: string; + revoked_reason?: string; + revokedReason?: string; + transports?: string[]; + capability_set_id?: Identifier; + [key: string]: unknown; +} + +interface CapabilitySetEntry { + id?: Identifier; + slug?: string; + label?: string; + capabilities?: string[]; +} + +interface CapabilitySetOption { + value: string; + label: string; + capabilities: string[]; + sourceId?: Identifier; + slug?: string; +} + +interface CapabilitySelectionOption { + value: string; + label: string; +} + +interface CopyFeedbackState { + type: 'success' | 'error'; + message: string; +} + +interface ApiTokensSectionProps { + tokens?: ApiTokenEntry[]; + loading?: boolean; + creating?: boolean; + deletingId?: Identifier | null; + regeneratingId?: Identifier | null; + createdToken?: string | null; + capabilitySets?: CapabilitySetEntry[]; + capabilitySetsLoading?: boolean; + capabilities?: string[]; + capabilitiesLoading?: boolean; + onRefresh?: () => void; + onCreate?: (payload: { label?: string; expires_at?: string; capability_set_id: Identifier | string }) => Promise | unknown; + onDelete?: (tokenId: Identifier) => Promise | unknown; + onRegenerate?: (tokenId: Identifier) => Promise | unknown; + onDismissCreatedToken?: () => void; + onRefreshCapabilitySets?: () => void; + onRefreshCapabilities?: () => void; +} + +const ApiTokensSection = ({ + tokens = [], + loading = false, + creating = false, + deletingId = null, + regeneratingId = null, + createdToken = null, + capabilitySets = [], + capabilitySetsLoading = false, + capabilities = [], + capabilitiesLoading = false, + onRefresh, + onCreate, + onDelete, + onRegenerate, + onDismissCreatedToken, + onRefreshCapabilitySets, + onRefreshCapabilities, +}: ApiTokensSectionProps) => { + const [newTokenLabel, setNewTokenLabel] = useState(''); + const [newTokenExpires, setNewTokenExpires] = useState(''); + const [newTokenCapabilitySetId, setNewTokenCapabilitySetId] = useState(''); + const [formError, setFormError] = useState(null); + const supportsClipboardWrite = Boolean(navigator.clipboard?.writeText); + const [canCopyToken, setCanCopyToken] = useState(supportsClipboardWrite); + const [copyFeedback, setCopyFeedback] = useState(null); + + const capabilitySetOptions = useMemo( + () => + capabilitySets.map((set) => ({ + value: set.id != null ? String(set.id) : set.slug ?? '', + label: `${set.label || set.slug || set.id || 'Capability set'}`, + capabilities: Array.isArray(set.capabilities) + ? set.capabilities.map((cap) => String(cap ?? '')) + : [], + sourceId: set.id, + slug: set.slug, + })), + [capabilitySets], + ); + + const capabilitySetMap = useMemo>( + () => { + const entries = capabilitySetOptions.map((option) => [option.value, option] as const); + return Object.fromEntries(entries); + }, + [capabilitySetOptions], + ); + + const capabilitySelectionOptions = useMemo(() => ( + Array.isArray(capabilities) + ? capabilities.map((capability) => { + const capabilityText = `${capability ?? ''}`; + if (!capabilityText.includes(':')) { + return { value: capability, label: capabilityText }; + } + const [namespace, action] = capabilityText.split(':'); + if (!namespace || !action) { + return { value: capability, label: capabilityText }; + } + const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`; + const formattedAction = action.replace(/_/g, ' '); + return { + value: capability, + label: `${formattedNamespace}: ${formattedAction}`, + }; + }) + : [] + ), [capabilities]); + + const capabilityLabelMap = useMemo(() => { + const map = new Map(); + capabilitySelectionOptions.forEach(({ value, label }) => { + map.set(value, label || String(value)); + }); + return map; + }, [capabilitySelectionOptions]); + + const formatCapabilityLabel = useCallback((value: string) => ( + capabilityLabelMap.get(value) || String(value) + ), [capabilityLabelMap]); + + useEffect(() => { + if (!capabilitySetOptions.length) { + setNewTokenCapabilitySetId(''); + return; + } + if (!newTokenCapabilitySetId + || !capabilitySetOptions.some((option) => option.value === newTokenCapabilitySetId)) { + setNewTokenCapabilitySetId(capabilitySetOptions[0].value); + } + }, [capabilitySetOptions, newTokenCapabilitySetId]); + + const selectedTokenCapabilitySetCapabilities = useMemo( + () => capabilitySetMap[newTokenCapabilitySetId]?.capabilities || [], + [capabilitySetMap, newTokenCapabilitySetId], + ); + + const handleRefresh = useCallback(() => { + if (onRefresh) { + onRefresh(); + } + onRefreshCapabilitySets?.(); + onRefreshCapabilities?.(); + }, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]); + + const handleCopyToken = useCallback(async () => { + if (!createdToken || !canCopyToken) { + return; + } + + const showSuccess = () => + setCopyFeedback({ type: 'success', message: 'Token copied to clipboard.' }); + const showFailure = () => + setCopyFeedback({ + type: 'error', + message: + 'Copy failed. Your browser may require HTTPS access; please select the token manually.', + }); + + try { + await navigator.clipboard.writeText(createdToken); + showSuccess(); + return; + } catch { + // Some browsers expose writeText but still reject outside secure context + setCanCopyToken(false); + } + + showFailure(); + }, [createdToken, canCopyToken]); + + const handleDismissSecret = useCallback(() => { + setCopyFeedback(null); + onDismissCreatedToken?.(); + }, [onDismissCreatedToken]); + + useEffect(() => { + setCopyFeedback(null); + setCanCopyToken(Boolean(navigator.clipboard?.writeText)); + }, [createdToken]); + + const handleNewCapabilitySetChange = useCallback((event: ChangeEvent) => { + setFormError(null); + setNewTokenCapabilitySetId(event.target.value); + }, []); + + const handleCreateToken = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + setFormError(null); + let normalizedLabel = newTokenLabel.trim(); + if (normalizedLabel.length === 0) { + normalizedLabel = undefined; + } + + let normalizedExpires; + if (newTokenExpires) { + const parsed = new Date(newTokenExpires); + if (Number.isNaN(parsed.getTime())) { + setFormError('Enter a valid expiration date.'); + return; + } + normalizedExpires = parsed.toISOString(); + } + + if (!capabilitySetOptions.length) { + setFormError('Capability sets are still loading.'); + return; + } + + const selectedCapabilitySetId = newTokenCapabilitySetId || capabilitySetOptions[0]?.value; + if (!selectedCapabilitySetId) { + setFormError('Select a capability set.'); + return; + } + + const resolvedCapabilitySetId = capabilitySetMap[selectedCapabilitySetId]?.sourceId ?? selectedCapabilitySetId; + + const result = await onCreate?.({ + label: normalizedLabel, + expires_at: normalizedExpires, + capability_set_id: resolvedCapabilitySetId, + }); + + if (result !== false) { + setNewTokenLabel(''); + setNewTokenExpires(''); + setNewTokenCapabilitySetId(capabilitySetOptions[0]?.value || ''); + setFormError(null); + } + }, + [ + capabilitySetOptions, + capabilitySetMap, + newTokenCapabilitySetId, + newTokenExpires, + newTokenLabel, + onCreate, + ], + ); + + const handleRegenerateToken = useCallback( + async (token: ApiTokenEntry) => { + if (!token?.id) { + return; + } + await onRegenerate?.(token.id); + }, + [onRegenerate], + ); + + return ( +
+
+ +
+ +

+ API tokens use predefined capability sets. Choose the set that matches the access you need when + creating or updating a token. +

+ + {createdToken ? ( +
+

+ Copy this token now; you will not be able to view it again after closing this window. +

+
{createdToken}
+
+ {canCopyToken ? ( + + ) : null} + +
+ {copyFeedback ? ( +

+ {copyFeedback.message} +

+ ) : null} +
+ ) : null} + +
+
+ + setNewTokenLabel(event.target.value)} + placeholder="Personal API token" + /> +
+
+ + setNewTokenExpires(event.target.value)} + /> +
+
+ + + {capabilitySetsLoading ? ( + Loading capability sets… + ) : null} + {!capabilitySetsLoading && !capabilitySetOptions.length ? ( + No capability sets available yet. + ) : null} +
+
+ {selectedTokenCapabilitySetCapabilities.length ? ( +
+ {selectedTokenCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + ))} +
+ ) : ( + No capabilities selected. + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} +
+
+ +
+
+ {formError ? ( +

{formError}

+ ) : null} + + {loading && !tokens.length ? ( +

Loading tokens…

+ ) : null} + + {!loading && !tokens.length ? ( +

No API tokens yet.

+ ) : null} + + {tokens.length ? ( + + + + + + + + + + + + + {tokens.map((token) => { + const isRevoked = Boolean(token?.revoked_at); + const capabilityKey = token?.capability_set_id != null + ? String(token.capability_set_id) + : null; + const selectedSet = capabilityKey ? capabilitySetMap[capabilityKey] : null; + const capabilitySetLabel = selectedSet?.label + || selectedSet?.slug + || token.capability_set_id + || '—'; + + return ( + + + + + + + + + ); + })} + +
LabelCreatedLast usedExpiresCapability setActions
{token.label || '—'}{formatDateTime(token.created_at)}{formatDateTime(token.last_used_at)}{formatDateTime(token.expires_at)} +
+ {capabilitySetLabel} +
+ {capabilitySetsLoading ? ( + Loading capability sets… + ) : null} +
+ {isRevoked ? ( + Revoked + ) : ( + <> + + + + )} +
+ ) : null} +
+ ); +}; + +export default ApiTokensSection; diff --git a/frontend/src/settings/sections/CapabilitySetsSection.tsx b/frontend/src/settings/sections/CapabilitySetsSection.tsx new file mode 100644 index 0000000..db0f975 --- /dev/null +++ b/frontend/src/settings/sections/CapabilitySetsSection.tsx @@ -0,0 +1,643 @@ +import React, { + FormEvent, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { IconX } from '../../components/icons'; +import CapabilityDropdown, { CapabilityDropdownOption } from '../components/CapabilityDropdown'; +import type { CapabilitySetId, CapabilityValue } from '../../types/identifiers'; + +interface CapabilitySet { + id: CapabilitySetId; + slug?: string; + label?: string; + capabilities?: CapabilityValue[] | null; + is_system?: boolean; + cap_version?: number | null; +} + +interface CapabilitySetMutationPayload { + slug?: string; + label?: string; + capabilities: CapabilityValue[]; +} + +type RefreshHandler = (() => void | Promise) | undefined; +type CreateCapabilitySetHandler = (payload: CapabilitySetMutationPayload) => Promise | boolean | CapabilitySet | void | null; +type UpdateCapabilitySetHandler = ( + id: CapabilitySetId, + payload: CapabilitySetMutationPayload, +) => Promise | boolean | CapabilitySet | void | null; +type DeleteCapabilitySetHandler = (id: CapabilitySetId) => Promise | boolean | void | null; + +interface CapabilitySetsSectionProps { + capabilitySets?: CapabilitySet[]; + capabilitySetsLoading?: boolean; + creatingCapabilitySet?: boolean; + savingCapabilitySetId?: CapabilitySetId | null; + deletingCapabilitySetId?: CapabilitySetId | null; + supportsCapabilitySetLabels?: boolean; + capabilities?: CapabilityValue[]; + capabilitiesLoading?: boolean; + onRefreshCapabilitySets?: RefreshHandler; + onRefreshCapabilities?: RefreshHandler; + onRefresh?: RefreshHandler; + onCreateCapabilitySet?: CreateCapabilitySetHandler; + onUpdateCapabilitySet?: UpdateCapabilitySetHandler; + onDeleteCapabilitySet?: DeleteCapabilitySetHandler; +} + +type CapabilityOptionInput = CapabilityDropdownOption | CapabilityValue | null; + + + +const resolveCapabilityValue = (option: CapabilityOptionInput): CapabilityValue | null => { + if (option == null) { + return null; + } + if (typeof option === 'string' || typeof option === 'number') { + return option; + } + if (option.value != null) { + return option.value as CapabilityValue; + } + if (option.id != null) { + return option.id as CapabilityValue; + } + return null; +}; + +const CapabilitySetsSection: React.FC = ({ + capabilitySets = [], + capabilitySetsLoading = false, + creatingCapabilitySet = false, + savingCapabilitySetId = null, + deletingCapabilitySetId = null, + supportsCapabilitySetLabels = false, + capabilities = [], + capabilitiesLoading = false, + onRefreshCapabilitySets, + onRefreshCapabilities, + onRefresh, + onCreateCapabilitySet, + onUpdateCapabilitySet, + onDeleteCapabilitySet, +}) => { + const [newCapabilitySetSlug, setNewCapabilitySetSlug] = useState(''); + const [newCapabilitySetLabel, setNewCapabilitySetLabel] = useState(''); + const [newCapabilitySetCapabilities, setNewCapabilitySetCapabilities] = useState([]); + const [capabilitySetFormError, setCapabilitySetFormError] = useState(null); + + const [editingCapabilitySetId, setEditingCapabilitySetId] = useState(null); + const [editCapabilitySetSlug, setEditCapabilitySetSlug] = useState(''); + const [editCapabilitySetLabel, setEditCapabilitySetLabel] = useState(''); + const [editCapabilitySetCapabilities, setEditCapabilitySetCapabilities] = useState([]); + const [capabilitySetEditError, setCapabilitySetEditError] = useState(null); + + const capabilitySelectionOptions = useMemo(() => ( + (capabilities ?? []).map((capability) => { + const capabilityLabel = `${capability ?? ''}`; + if (!capabilityLabel.includes(':')) { + return { value: capability, label: capabilityLabel }; + } + const [namespace, action] = capabilityLabel.split(':'); + if (!namespace || !action) { + return { value: capability, label: capabilityLabel }; + } + const formattedNamespace = `${namespace.charAt(0).toUpperCase()}${namespace.slice(1)}`; + const formattedAction = action.replace(/_/g, ' '); + return { + value: capability, + label: `${formattedNamespace}: ${formattedAction}`, + }; + }) + ), [capabilities]); + + const capabilityLabelMap = useMemo(() => { + const map = new Map(); + capabilitySelectionOptions.forEach(({ value, label }) => { + if (value == null) { + return; + } + map.set(value, label || String(value)); + }); + return map; + }, [capabilitySelectionOptions]); + + const capabilityOrder = useMemo(() => { + const order = new Map(); + capabilitySelectionOptions.forEach((option, index) => { + if (option.value == null) { + return; + } + order.set(option.value, index); + }); + return order; + }, [capabilitySelectionOptions]); + + const sortCapabilityValues = useCallback((values: CapabilityValue[] | null) => { + if (!Array.isArray(values)) { + return []; + } + return [...values].sort((a, b) => { + const indexA = capabilityOrder.has(a) ? capabilityOrder.get(a)! : Number.MAX_SAFE_INTEGER; + const indexB = capabilityOrder.has(b) ? capabilityOrder.get(b)! : Number.MAX_SAFE_INTEGER; + if (indexA === indexB) { + return String(a).localeCompare(String(b)); + } + return indexA - indexB; + }); + }, [capabilityOrder]); + + const formatCapabilityLabel = useCallback((value: CapabilityValue) => ( + capabilityLabelMap.get(value) || String(value) + ), [capabilityLabelMap]); + + const capabilitySetOptions = useMemo( + () => capabilitySets.map((set) => ({ + value: set.id, + label: set.label || set.slug || set.id, + capabilities: Array.isArray(set.capabilities) ? set.capabilities : [], + isSystem: Boolean(set?.is_system), + version: set?.cap_version != null ? Number(set.cap_version) : null, + })), + [capabilitySets], + ); + + const hasCapabilitySets = capabilitySetOptions.length > 0; + const columnCount = supportsCapabilitySetLabels ? 6 : 5; + + const handleCapabilitySetsRefresh = useCallback(() => { + if (onRefreshCapabilitySets) { + onRefreshCapabilitySets(); + } else { + onRefresh?.(); + } + onRefreshCapabilities?.(); + }, [onRefresh, onRefreshCapabilities, onRefreshCapabilitySets]); + + const handleAddCapabilityToNewSet = useCallback((option: CapabilityOptionInput) => { + const value = resolveCapabilityValue(option); + if (!value) { + return; + } + setCapabilitySetFormError(null); + setNewCapabilitySetCapabilities((previous) => { + if (previous.includes(value)) { + return previous; + } + return sortCapabilityValues([...previous, value]); + }); + }, [sortCapabilityValues]); + + const handleRemoveCapabilityFromNewSet = useCallback((value: CapabilityValue) => { + setCapabilitySetFormError(null); + setNewCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); + }, []); + + const handleCreateCapabilitySetSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + setCapabilitySetFormError(null); + + if (!Array.isArray(newCapabilitySetCapabilities) || newCapabilitySetCapabilities.length === 0) { + setCapabilitySetFormError('Select at least one capability.'); + return; + } + + if (!capabilitySelectionOptions.length) { + setCapabilitySetFormError('Capabilities are still loading.'); + return; + } + + const payload: CapabilitySetMutationPayload = { + slug: newCapabilitySetSlug, + label: newCapabilitySetLabel, + capabilities: sortCapabilityValues(newCapabilitySetCapabilities), + }; + + const result = await onCreateCapabilitySet?.(payload); + if (result === false) { + setCapabilitySetFormError('Failed to create capability set.'); + return; + } + + setNewCapabilitySetSlug(''); + setNewCapabilitySetLabel(''); + setNewCapabilitySetCapabilities([]); + setCapabilitySetFormError(null); + }, + [ + capabilitySelectionOptions, + newCapabilitySetCapabilities, + newCapabilitySetLabel, + newCapabilitySetSlug, + onCreateCapabilitySet, + sortCapabilityValues, + ], + ); + + const handleStartEditCapabilitySet = useCallback((capabilitySet: CapabilitySet | null) => { + if (!capabilitySet) { + return; + } + setCapabilitySetEditError(null); + setEditingCapabilitySetId(capabilitySet.id); + setEditCapabilitySetSlug(capabilitySet.slug || ''); + setEditCapabilitySetLabel(capabilitySet.label || ''); + setEditCapabilitySetCapabilities( + sortCapabilityValues(Array.isArray(capabilitySet.capabilities) ? capabilitySet.capabilities : []), + ); + }, [sortCapabilityValues]); + + const handleCancelEditCapabilitySet = useCallback(() => { + setEditingCapabilitySetId(null); + setEditCapabilitySetSlug(''); + setEditCapabilitySetLabel(''); + setEditCapabilitySetCapabilities([]); + setCapabilitySetEditError(null); + }, []); + + const handleAddCapabilityToEditSet = useCallback((option: CapabilityOptionInput) => { + const value = resolveCapabilityValue(option); + if (!value) { + return; + } + setCapabilitySetEditError(null); + setEditCapabilitySetCapabilities((previous) => { + if (previous.includes(value)) { + return previous; + } + return sortCapabilityValues([...previous, value]); + }); + }, [sortCapabilityValues]); + + const handleRemoveCapabilityFromEditSet = useCallback((value: CapabilityValue) => { + setCapabilitySetEditError(null); + setEditCapabilitySetCapabilities((previous) => previous.filter((item) => item !== value)); + }, []); + + const handleUpdateCapabilitySetSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + if (!editingCapabilitySetId) { + return; + } + if (!Array.isArray(editCapabilitySetCapabilities) || editCapabilitySetCapabilities.length === 0) { + setCapabilitySetEditError('Select at least one capability.'); + return; + } + + const payload: CapabilitySetMutationPayload = { + slug: editCapabilitySetSlug, + label: editCapabilitySetLabel, + capabilities: sortCapabilityValues(editCapabilitySetCapabilities), + }; + + const result = await onUpdateCapabilitySet?.(editingCapabilitySetId, payload); + if (result === false) { + setCapabilitySetEditError('Failed to update capability set.'); + return; + } + + handleCancelEditCapabilitySet(); + }, + [ + editCapabilitySetCapabilities, + editCapabilitySetLabel, + editCapabilitySetSlug, + editingCapabilitySetId, + handleCancelEditCapabilitySet, + onUpdateCapabilitySet, + sortCapabilityValues, + ], + ); + + const handleDeleteCapabilitySet = useCallback( + async (capabilitySet: CapabilitySet | null) => { + if (!capabilitySet?.id) { + return; + } + const displayName = capabilitySet.slug || capabilitySet.label || capabilitySet.id; + const confirmed = window.confirm(`Delete capability set "${displayName}"?`); + if (!confirmed) { + return; + } + const result = await onDeleteCapabilitySet?.(capabilitySet.id); + if (result === false) { + setCapabilitySetEditError('Failed to delete capability set.'); + } + }, + [onDeleteCapabilitySet], + ); + + useEffect(() => { + if (!editingCapabilitySetId) { + return; + } + const exists = capabilitySets.some((set) => set.id === editingCapabilitySetId); + if (!exists) { + handleCancelEditCapabilitySet(); + } + }, [capabilitySets, editingCapabilitySetId, handleCancelEditCapabilitySet]); + + return ( +
+
+ +
+ +

+ Capability sets bundle permissions that you can assign to API tokens and user memberships. +

+ +
+
+ + setNewCapabilitySetSlug(event.target.value)} + placeholder="e.g. api_readonly" + disabled={creatingCapabilitySet || capabilitySetsLoading} + /> + Leave blank to generate a slug automatically. +
+ {supportsCapabilitySetLabels ? ( +
+ + setNewCapabilitySetLabel(event.target.value)} + placeholder="Friendly name (optional)" + disabled={creatingCapabilitySet || capabilitySetsLoading} + /> +
+ ) : null} +
+ +
+ + {newCapabilitySetCapabilities.length ? ( +
+ {newCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + + ))} +
+ ) : ( + No capabilities selected. + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} + {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( + No capabilities available. + ) : null} +
+
+
+ +
+
+ {capabilitySetFormError ? ( +

{capabilitySetFormError}

+ ) : null} + {capabilitySetsLoading && !hasCapabilitySets ? ( +

Loading capability sets…

+ ) : null} + + {!capabilitySetsLoading && !hasCapabilitySets ? ( +

No capability sets yet.

+ ) : null} + + {hasCapabilitySets ? ( + + + + + {supportsCapabilitySetLabels ? : null} + + + + + + + + {capabilitySets.map((set) => { + const isSystem = Boolean(set?.is_system); + const isEditing = editingCapabilitySetId === set.id; + const capabilityList = sortCapabilityValues( + Array.isArray(set?.capabilities) ? set.capabilities : [], + ); + const saving = savingCapabilitySetId === set.id; + const deleting = deletingCapabilitySetId === set.id; + + return ( + + + + {supportsCapabilitySetLabels ? ( + + ) : null} + + + + + + {isEditing ? ( + + + + ) : null} + + ); + })} + +
SlugLabelCapabilitiesSystemVersionActions
{set.slug || '—'}{set.label || '—'} + {capabilityList.length + ? capabilityList.map((value) => formatCapabilityLabel(value)).join(', ') + : '—'} + {isSystem ? 'Yes' : 'No'}{set.cap_version != null ? Number(set.cap_version) : '—'} + {isSystem ? ( + System set + ) : ( + <> + + + + )} +
+
+
+ + setEditCapabilitySetSlug(event.target.value)} + disabled={saving || capabilitySetsLoading} + /> +
+ {supportsCapabilitySetLabels ? ( +
+ + setEditCapabilitySetLabel(event.target.value)} + disabled={saving || capabilitySetsLoading} + /> +
+ ) : null} +
+ +
+ + {editCapabilitySetCapabilities.length ? ( +
+ {editCapabilitySetCapabilities.map((value) => ( + + {formatCapabilityLabel(value)} + + + ))} +
+ ) : ( + + No capabilities selected. + + )} + {capabilitiesLoading ? ( + Loading capabilities… + ) : null} + {!capabilitiesLoading && !capabilitySelectionOptions.length ? ( + No capabilities available. + ) : null} +
+
+
+ + +
+
+ {capabilitySetEditError ? ( +

{capabilitySetEditError}

+ ) : null} +
+ ) : null} +
+ ); +}; + +export default CapabilitySetsSection; diff --git a/frontend/src/settings/sections/PasskeysSection.tsx b/frontend/src/settings/sections/PasskeysSection.tsx new file mode 100644 index 0000000..71af6f7 --- /dev/null +++ b/frontend/src/settings/sections/PasskeysSection.tsx @@ -0,0 +1,165 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { FormEvent } from 'react'; +import type { PasskeyRecord, RegisterPasskeyResult } from '../usePasskeys'; +import { formatDateTime } from '../../utils/date'; + +interface PasskeysSectionProps { + passkeys?: PasskeyRecord[]; + passkeysSupported?: boolean | null; + passkeysLoading?: boolean; + registeringPasskey?: boolean; + revokingPasskeyId?: string | null; + onRefreshPasskeys?: () => void | Promise; + onRegisterPasskey?: (args: { nickname?: string }) => Promise; + onRevokePasskey?: (id: string, reason?: string) => Promise; +} + +const PasskeysSection = ({ + passkeys = [], + passkeysSupported = null, + passkeysLoading = false, + registeringPasskey = false, + revokingPasskeyId = null, + onRefreshPasskeys, + onRegisterPasskey, + onRevokePasskey, +}: PasskeysSectionProps) => { + const [newPasskeyNickname, setNewPasskeyNickname] = useState(''); + + const hasPasskeys = useMemo(() => Array.isArray(passkeys) && passkeys.length > 0, [passkeys]); + + const handlePasskeyRefresh = useCallback(() => { + onRefreshPasskeys?.(); + }, [onRefreshPasskeys]); + + const handlePasskeyRegister = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + const nickname = newPasskeyNickname.trim(); + const result = await onRegisterPasskey?.({ nickname }); + if (result?.ok) { + setNewPasskeyNickname(''); + } + }, + [newPasskeyNickname, onRegisterPasskey], + ); + + const handlePasskeyRevoke = useCallback( + async (passkey) => { + if (!passkey?.id) { + return; + } + const reasonInput = window.prompt('Optional reason for revoking this passkey:', ''); + const reason = reasonInput ? reasonInput.trim() : undefined; + await onRevokePasskey?.(passkey.id, reason); + }, + [onRevokePasskey], + ); + + return ( +
+
+ +
+ + {passkeysSupported === false ? ( +

Passkeys are not enabled for this account.

+ ) : ( + <> +
+
+ + setNewPasskeyNickname(event.target.value)} + disabled={registeringPasskey} + /> +
+
+ +
+
+ + {passkeysLoading && !hasPasskeys ? ( +

Loading passkeys…

+ ) : null} + + {!passkeysLoading && !hasPasskeys ? ( +

No passkeys registered yet.

+ ) : null} + + {hasPasskeys ? ( + + + + + + + + + + + + + {passkeys.map((passkey) => { + const createdAt = passkey.created_at || passkey.createdAt; + const lastUsedAt = passkey.last_used_at || passkey.lastUsedAt; + const revokedAt = passkey.revoked_at || passkey.revokedAt; + const revokedReason = passkey.revoked_reason || passkey.revokedReason; + const revoked = Boolean(revokedAt); + const transports = Array.isArray(passkey.transports) + ? passkey.transports.filter(Boolean) + : []; + + return ( + + + + + + + + + ); + })} + +
NicknameCreatedLast usedTransportsStatusActions
{passkey.nickname || '—'}{formatDateTime(createdAt)}{formatDateTime(lastUsedAt)}{transports.length ? transports.join(', ') : '—'} + {revoked + ? revokedReason + ? `Revoked (${revokedReason})` + : 'Revoked' + : 'Active'} + + {revoked ? ( + Revoked + ) : ( + + )} +
+ ) : null} + + )} +
+ ); +}; + +export default PasskeysSection; diff --git a/frontend/src/settings/settings.css b/frontend/src/settings/settings.css new file mode 100644 index 0000000..0f1ca26 --- /dev/null +++ b/frontend/src/settings/settings.css @@ -0,0 +1,337 @@ +.settings-modal__body { + display: grid; + grid-template-columns: 12rem 1fr; + gap: 1.5rem; + height: 90vh; + padding: 1.5rem; + overflow: hidden; +} + +.settings-modal__sidebar { + border-right: 1px solid var(--border-muted, var(--border)); + padding-right: 1rem; +} + +.settings-modal__sidebar ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.settings-modal__sidebar button { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 0.4rem; + border: none; + background: none; + color: inherit; + font: inherit; + padding: 0.45rem 0.6rem; + border-radius: 0.35rem; + cursor: pointer; +} + +.settings-modal__sidebar button:hover, +.settings-modal__sidebar button:focus-visible { + background: var(--sidebar-hover-bg); +} + +.settings-modal__sidebar button.active { + background: var(--accent-soft); + font-weight: 600; +} + +.settings-modal__content { + overflow-y: auto; + padding-bottom: 1rem; +} + +.settings-section h4 { + margin-top: 0; +} + +.settings-section { + display: flex; + flex-direction: column; +} + +.settings-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.settings-actions .secondary { + min-width: 7rem; +} + +.settings-form { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; + margin-bottom: 0.75rem; +} + +.settings-form__field { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 14rem; +} + +.settings-form__field--full { + flex: 1 1 100%; + min-width: 100%; +} + +fieldset.settings-form__field { + border: 1px solid var(--border-muted, var(--border)); + border-radius: 0.5rem; + padding: 0.75rem 0.9rem 0.85rem; + background: var(--surface-soft); +} + +fieldset.settings-form__field legend { + padding: 0 0.35rem; + font-weight: 600; + color: var(--muted-strong, inherit); +} + +.settings-form__field input[type='text'], +.settings-form__field input[type='datetime-local'] { + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: 0.35rem; + background: var(--surface-soft); + color: inherit; +} + +.settings-form__choices { + display: flex; + flex-direction: column; + gap: 0.4rem; + align-items: flex-start; +} + +.settings-capability-picker { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.capability-dropdown { + position: relative; + width: 100%; +} + +.capability-dropdown__trigger { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 0.45rem; + background: var(--surface-soft); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.capability-dropdown__trigger:hover:not(:disabled), +.capability-dropdown__trigger:focus-visible { + border-color: var(--accent); + background: var(--surface); + outline: none; +} + +.capability-dropdown__trigger:disabled { + cursor: not-allowed; + color: var(--muted); + background: var(--surface-muted, var(--surface-soft)); +} + +.capability-dropdown__chevron { + flex-shrink: 0; + opacity: 0.8; +} + +.capability-dropdown__menu { + margin-top: 0.35rem; + max-height: 18rem; + overflow-y: auto; + padding: 0.25rem 0; + width: 100%; +} + +.capability-dropdown__option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.75rem; +} + +.capability-dropdown__option-icon { + width: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.capability-dropdown__option:not(.is-selected) .capability-dropdown__option-icon { + color: transparent; +} + +.capability-dropdown__option-label { + flex: 1; + text-align: left; +} + +.capability-dropdown__empty { + padding: 0.6rem 0.8rem; + color: var(--muted); +} + +.settings-capability-picker__chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.settings-capability-picker__chips--inline { + margin-top: 0.35rem; +} + +.settings-capabilities-summary { + display: inline-block; + margin-top: 0.4rem; + color: var(--muted); + font-size: 0.9rem; +} + +.settings-capability-picker__placeholder { + color: var(--muted); + font-size: 0.9rem; +} + +.settings-capability-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.settings-capability-list--compact { + margin-top: 0.2rem; + gap: 0.25rem; +} + +.settings-capability-list__item { + display: inline-flex; + align-items: center; +} + +.settings-choice { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; + font-weight: 500; + color: inherit; +} + +.settings-choice input[type='checkbox'] { + width: 1.05rem; + height: 1.05rem; + cursor: pointer; + accent-color: var(--accent); +} + +.settings-choice input[type='checkbox']:disabled + span { + color: var(--muted); +} + +.settings-form__actions { + display: flex; + gap: 0.5rem; +} + +.settings-form__error { + color: var(--danger); + font-size: 0.85rem; + margin: 0 0 0.75rem; +} + +.settings-empty { + margin: 1rem 0; + color: var(--muted); +} + +.settings-table { + width: 100%; + border-collapse: collapse; + margin-top: 0.5rem; +} + +.settings-table th, +.settings-table td { + padding: 0.55rem 0.75rem; + border-bottom: 1px solid var(--border-muted, var(--border)); + text-align: left; + font-size: 0.9rem; +} + +.settings-table tr:last-child td { + border-bottom: none; +} + +.settings-table__actions { + white-space: nowrap; +} + +.settings-status { + color: var(--muted); + font-size: 0.9rem; +} + +.settings-table tr.is-revoked { + opacity: 0.65; +} + +.settings-notice { + border: 1px solid var(--accent); + background: var(--accent-soft); + padding: 0.9rem 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.token-display { + background: var(--surface-ink-soft); + padding: 0.5rem 0.65rem; + border-radius: 0.35rem; + font-family: var(--font-mono); + font-size: 0.95rem; + overflow-x: auto; +} + +.settings-notice__actions { + display: flex; + gap: 0.5rem; + margin-top: 0.6rem; +} + +.settings-notice__actions button { + flex: 0 0 auto; +} diff --git a/frontend/src/settings/useApiTokens.ts b/frontend/src/settings/useApiTokens.ts new file mode 100644 index 0000000..ecd67cf --- /dev/null +++ b/frontend/src/settings/useApiTokens.ts @@ -0,0 +1,186 @@ +import { useCallback, useState } from 'react'; +import { + createApiToken, + deleteApiToken, + listApiTokens, + regenerateApiToken, + type ApiTokenRecord, +} from '../lib/api/apiClient'; +import type { ApiTokenId, CapabilitySetId } from '../types/identifiers'; + +interface ApiTokensResponse { + token_info?: ApiTokenRecord; + token?: string; +} + +interface CreateTokenArgs { + label?: string; + expires_at?: string; + capability_set_id?: CapabilitySetId; +} + +interface UseApiTokensArgs { + notifyApiError?: (error: unknown, message: string) => void; + setStatusMessage?: (message: string, variant?: string) => void; + token?: string | null; +} + +interface UseApiTokensResult { + tokens: ApiTokenRecord[]; + loading: boolean; + creating: boolean; + deletingId: ApiTokenId | null; + regeneratingId: ApiTokenId | null; + createdSecret: string | null; + refresh: () => Promise; + create: (args?: CreateTokenArgs) => Promise; + revoke: (tokenId?: ApiTokenId | null) => Promise; + regenerate: (tokenId?: ApiTokenId | null) => Promise; + dismissSecret: () => void; +} + +const useApiTokens = ({ notifyApiError, setStatusMessage, token }: UseApiTokensArgs): UseApiTokensResult => { + const [tokens, setTokens] = useState([]); + const [loading] = useState(false); + const [creating, setCreating] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [regeneratingId, setRegeneratingId] = useState(null); + const [createdSecret, setCreatedSecret] = useState(null); + + const refresh = useCallback(async () => { + if (!token) { + return; + } + try { + const data = await listApiTokens(); + setTokens(Array.isArray(data) ? data : []); + } catch (error) { + notifyApiError?.(error, 'Failed to load API tokens.'); + } + }, [notifyApiError, token]); + + const create = useCallback( + async ({ label, expires_at, capability_set_id }: CreateTokenArgs = {}) => { + if (creating || !capability_set_id) { + return false; + } + setCreating(true); + try { + const payload: { capability_set_id: CapabilitySetId; label?: string; expires_at?: string } = { capability_set_id }; + if (label) { + payload.label = label; + } + if (expires_at) { + payload.expires_at = expires_at; + } + + const data = await createApiToken(payload) as ApiTokensResponse; + if (data?.token_info) { + setTokens((previous) => { + const filtered = previous.filter((entry) => entry.id !== data.token_info?.id); + return data.token_info ? [data.token_info, ...filtered] : filtered; + }); + } else { + await refresh(); + } + + if (data?.token) { + setCreatedSecret(data.token); + } + + setStatusMessage?.('API token created.', 'success'); + return data; + } catch (error) { + notifyApiError?.(error, 'Failed to create API token.'); + return false; + } finally { + setCreating(false); + } + }, + [creating, notifyApiError, refresh, setStatusMessage], + ); + + const revoke = useCallback( + async (tokenId?: string | null) => { + if (!tokenId) { + return false; + } + setDeletingId(tokenId); + try { + await deleteApiToken(tokenId); + await refresh(); + setStatusMessage?.('API token revoked.', 'success'); + return true; + } catch (error) { + notifyApiError?.(error, 'Failed to revoke API token.'); + return false; + } finally { + setDeletingId(null); + } + }, + [notifyApiError, refresh, setStatusMessage], + ); + + const regenerate = useCallback( + async (tokenId?: string | null) => { + if (!tokenId) { + return false; + } + setRegeneratingId(tokenId); + try { + const data = await regenerateApiToken(tokenId) as ApiTokensResponse; + if (data?.token_info) { + setTokens((previous) => { + let found = false; + const next = previous.map((entry) => { + if (entry.id === data.token_info?.id) { + found = true; + return data.token_info; + } + return entry; + }); + if (!found && data.token_info) { + return [data.token_info, ...previous]; + } + return next; + }); + } else { + await refresh(); + } + + if (data?.token) { + setCreatedSecret(data.token); + } + + setStatusMessage?.('API token regenerated.', 'success'); + return true; + } catch (error) { + notifyApiError?.(error, 'Failed to regenerate API token.'); + return false; + } finally { + setRegeneratingId(null); + } + }, + [notifyApiError, refresh, setStatusMessage], + ); + + const dismissSecret = useCallback(() => { + setCreatedSecret(null); + }, []); + + return { + tokens, + loading, + creating, + deletingId, + regeneratingId, + createdSecret, + refresh, + create, + revoke, + regenerate, + dismissSecret, + }; +}; + +export default useApiTokens; diff --git a/frontend/src/settings/useCapabilities.ts b/frontend/src/settings/useCapabilities.ts new file mode 100644 index 0000000..ab8bb08 --- /dev/null +++ b/frontend/src/settings/useCapabilities.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react'; +import { listCapabilities } from '../lib/api/apiClient'; + +interface UseCapabilitiesOptions { + notifyApiError?: (error: unknown, fallbackMessage: string) => void; + token?: string | null; +} + +const useCapabilities = ({ notifyApiError, token }: UseCapabilitiesOptions) => { + const [capabilities, setCapabilities] = useState([]); + const [capabilitiesLoading, setCapabilitiesLoading] = useState(false); + + const refreshCapabilities = useCallback(async () => { + if (!token) { + setCapabilities([]); + return; + } + setCapabilitiesLoading(true); + try { + const data = await listCapabilities(); + setCapabilities(Array.isArray(data) ? data.map((item) => item.name) : []); + } catch (error) { + notifyApiError?.(error, 'Failed to load capabilities.'); + setCapabilities([]); + } finally { + setCapabilitiesLoading(false); + } + }, [notifyApiError, token]); + + useEffect(() => { + if (token) { + refreshCapabilities(); + } else { + setCapabilities([]); + } + }, [refreshCapabilities, token]); + + return { + capabilities, + capabilitiesLoading, + refreshCapabilities, + }; +}; + +export default useCapabilities; diff --git a/frontend/src/settings/useCapabilitySets.ts b/frontend/src/settings/useCapabilitySets.ts new file mode 100644 index 0000000..4857f6d --- /dev/null +++ b/frontend/src/settings/useCapabilitySets.ts @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + createCapabilitySet as createCapabilitySetRequest, + deleteCapabilitySet as deleteCapabilitySetRequest, + listCapabilitySets, + updateCapabilitySet as updateCapabilitySetRequest, +} from '../lib/api/apiClient'; +import type { Identifier } from '../types/identifiers'; + +interface CapabilitySet { + id?: Identifier; + slug: string; + label?: string; + capabilities: string[]; + [key: string]: unknown; +} + +interface UseCapabilitySetsOptions { + notifyApiError?: (error: unknown, message: string) => void; + setStatusMessage?: (message: string, level?: string) => void; + token?: string | null; +} + +const useCapabilitySets = ({ notifyApiError, setStatusMessage, token }: UseCapabilitySetsOptions) => { + const [capabilitySets, setCapabilitySets] = useState([]); + const [capabilitySetsLoading, setCapabilitySetsLoading] = useState(false); + const [creatingCapabilitySet, setCreatingCapabilitySet] = useState(false); + const [savingCapabilitySetId, setSavingCapabilitySetId] = useState(null); + const [deletingCapabilitySetId, setDeletingCapabilitySetId] = useState(null); + const [supportsCapabilitySetLabels, setSupportsCapabilitySetLabels] = useState(false); + + const applyCapabilitySets = useCallback((updater: CapabilitySet[] | ((prev: CapabilitySet[]) => CapabilitySet[])) => { + setCapabilitySets((previous) => { + const base = Array.isArray(previous) ? [...previous] : []; + const next = Array.isArray(updater) + ? [...updater] + : updater(base); + const supportsLabels = next.some((item) => Object.prototype.hasOwnProperty.call(item || {}, 'label')); + setSupportsCapabilitySetLabels(supportsLabels); + return next; + }); + }, []); + + const refreshCapabilitySets = useCallback(async () => { + if (!token) { + applyCapabilitySets([]); + return; + } + setCapabilitySetsLoading(true); + try { + const data = await listCapabilitySets(); + applyCapabilitySets(Array.isArray(data) ? data : []); + } catch (error) { + notifyApiError?.(error, 'Failed to load capability sets.'); + } finally { + setCapabilitySetsLoading(false); + } + }, [applyCapabilitySets, notifyApiError, token]); + + useEffect(() => { + if (token) { + refreshCapabilitySets(); + } else { + applyCapabilitySets([]); + } + }, [applyCapabilitySets, refreshCapabilitySets, token]); + + const createCapabilitySet = useCallback( + async ({ slug, label, capabilities }: { slug?: string; label?: string; capabilities?: string[] } = {}) => { + if (creatingCapabilitySet) { + return false; + } + if (!Array.isArray(capabilities) || capabilities.length === 0) { + setStatusMessage?.('Select at least one capability.', 'error'); + return false; + } + setCreatingCapabilitySet(true); + try { + const payload: { slug?: string; label?: string; capabilities: string[] } = { + capabilities, + }; + const trimmedSlug = slug?.trim(); + if (trimmedSlug) { + payload.slug = trimmedSlug; + } + const trimmedLabel = label?.trim(); + if (trimmedLabel && supportsCapabilitySetLabels) { + payload.label = trimmedLabel; + } + + const data = await createCapabilitySetRequest(payload); + if (data) { + applyCapabilitySets((previous) => { + const next = previous.filter((entry) => entry?.id !== data.id); + next.push(data); + next.sort((a, b) => (a.slug || '').localeCompare(b.slug || '')); + return next; + }); + } else { + await refreshCapabilitySets(); + } + setStatusMessage?.('Capability set created.', 'success'); + return data; + } catch (error) { + notifyApiError?.(error, 'Failed to create capability set.'); + return false; + } finally { + setCreatingCapabilitySet(false); + } + }, + [ + applyCapabilitySets, + creatingCapabilitySet, + notifyApiError, + refreshCapabilitySets, + setStatusMessage, + supportsCapabilitySetLabels, + ], + ); + + const updateCapabilitySet = useCallback( + async ( + capabilitySetId: Identifier | null, + { slug, label, capabilities }: { slug?: string; label?: string; capabilities?: string[] } = {}, + ) => { + if (!capabilitySetId) { + return false; + } + setSavingCapabilitySetId(capabilitySetId); + try { + const payload: { slug?: string; label?: string; capabilities?: string[] } = {}; + if (slug !== undefined) { + const trimmed = slug?.trim(); + if (trimmed) { + payload.slug = trimmed; + } else if (slug === '') { + payload.slug = ''; + } + } + if (label !== undefined && supportsCapabilitySetLabels) { + const trimmed = label?.trim(); + if (trimmed) { + payload.label = trimmed; + } else if (label === '') { + payload.label = ''; + } + } + if (Array.isArray(capabilities)) { + payload.capabilities = capabilities; + } + + const data = await updateCapabilitySetRequest(capabilitySetId, payload); + if (data) { + applyCapabilitySets((previous) => { + let found = false; + const next = previous.map((entry) => { + if (entry?.id === data.id) { + found = true; + return data; + } + return entry; + }); + if (!found) { + next.push(data); + } + next.sort((a, b) => (a.slug || '').localeCompare(b.slug || '')); + return next; + }); + } else { + await refreshCapabilitySets(); + } + setStatusMessage?.('Capability set updated.', 'success'); + return true; + } catch (error) { + notifyApiError?.(error, 'Failed to update capability set.'); + return false; + } finally { + setSavingCapabilitySetId(null); + } + }, + [ + applyCapabilitySets, + notifyApiError, + refreshCapabilitySets, + setStatusMessage, + supportsCapabilitySetLabels, + ], + ); + + const deleteCapabilitySet = useCallback( + async (capabilitySetId: Identifier | null) => { + if (!capabilitySetId) { + return false; + } + setDeletingCapabilitySetId(capabilitySetId); + try { + await deleteCapabilitySetRequest(capabilitySetId); + applyCapabilitySets((previous) => previous.filter((entry) => entry?.id !== capabilitySetId)); + setStatusMessage?.('Capability set deleted.', 'success'); + return true; + } catch (error) { + notifyApiError?.(error, 'Failed to delete capability set.'); + return false; + } finally { + setDeletingCapabilitySetId(null); + } + }, + [applyCapabilitySets, notifyApiError, setStatusMessage], + ); + + return { + capabilitySets, + capabilitySetsLoading, + creatingCapabilitySet, + savingCapabilitySetId, + deletingCapabilitySetId, + supportsCapabilitySetLabels, + refreshCapabilitySets, + createCapabilitySet, + updateCapabilitySet, + deleteCapabilitySet, + }; +}; + +export default useCapabilitySets; diff --git a/frontend/src/settings/usePasskeys.ts b/frontend/src/settings/usePasskeys.ts new file mode 100644 index 0000000..855fe1a --- /dev/null +++ b/frontend/src/settings/usePasskeys.ts @@ -0,0 +1,230 @@ +import { useState, useCallback } from 'react'; +import { useAppState } from '../lib/store/appState'; +import { useStatusToast } from '../lib/context/StatusToastContext'; +/* global PublicKeyCredentialCreationOptions, CredentialCreationOptions */ + +import { + isWebAuthnAvailable, + preparePublicKeyCreationOptions, + serializeRegistrationCredential, +} from '../utils/webauthn'; +import { + deletePasskey, + finishPasskeyRegistration, + listPasskeys, + startPasskeyRegistration, +} from '../lib/api/apiClient'; +import type { PasskeyId } from '../types/identifiers'; + +type ApiError = { + response?: { + status?: number; + data?: { + error?: string; + }; + }; + name?: string; +}; + +export interface PasskeyRecord { + id?: PasskeyId; + nickname?: string; + created_at?: string; + createdAt?: string; + last_used_at?: string; + lastUsedAt?: string; + revoked_at?: string; + revokedAt?: string; + revoked_reason?: string; + revokedReason?: string; + transports?: string[]; + [key: string]: unknown; +} + +interface PasskeyChallengeResponse { + challengeId?: string; + challenge_id?: string; + publicKey?: PublicKeyCredentialCreationOptions; + public_key?: PublicKeyCredentialCreationOptions; + challenge?: { + publicKey?: PublicKeyCredentialCreationOptions; + }; + publicKeyCredentialCreationOptions?: PublicKeyCredentialCreationOptions; +} + +interface PasskeyRegisterPayload { + challengeId: string; + credential: unknown; + nickname?: string; +} + +type RegisterPasskeyFailureReason = 'unsupported' | 'busy' | 'cancelled' | 'error'; +export type RegisterPasskeyResult = + | { ok: true } + | { ok: false; reason: RegisterPasskeyFailureReason; message?: string }; + +type RevokePasskeyFailureReason = 'missing-id' | 'error'; +type RevokePasskeyResult = + | { ok: true } + | { ok: false; reason: RevokePasskeyFailureReason; message?: string }; + +import useNotifyApiError from '../hooks/useNotifyApiError'; + +interface UsePasskeysArgs { } + +interface UsePasskeysResult { + passkeys: PasskeyRecord[]; + passkeysSupported: boolean | null; + passkeysLoading: boolean; + registeringPasskey: boolean; + revokingPasskeyId: PasskeyId | null; + refreshPasskeys: () => Promise; + registerPasskey: (options?: { nickname?: string }) => Promise; + revokePasskey: ( + passkeyId: PasskeyId, + reason?: string, + ) => Promise; +} + +const usePasskeys = (_: UsePasskeysArgs = {}): UsePasskeysResult => { + const { token } = useAppState(); + const [passkeys, setPasskeys] = useState([]); + const [passkeysSupported, setPasskeysSupported] = useState(null); + const [passkeysLoading, setPasskeysLoading] = useState(false); + const [registeringPasskey, setRegisteringPasskey] = useState(false); + const [revokingPasskeyId, setRevokingPasskeyId] = useState(null); + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + + const refreshPasskeys = useCallback(async (): Promise => { + if (!token) { + return; + } + setPasskeysLoading(true); + try { + const passkeyData = await listPasskeys(); + setPasskeys(passkeyData); + setPasskeysSupported(true); + } catch (error) { + const status = (error as ApiError)?.response?.status; + if (status === 400 || status === 404) { + setPasskeysSupported(false); + setPasskeys([]); + } else { + notifyApiError(error, 'Failed to load passkeys.'); + } + } finally { + setPasskeysLoading(false); + } + }, [notifyApiError, token]); + + const registerPasskey = useCallback( + async ({ nickname }: { nickname?: string } = {}): Promise => { + if (!isWebAuthnAvailable()) { + setPasskeysSupported(false); + showToast('Passkeys are not supported in this browser.', 'error'); + return { ok: false, reason: 'unsupported' }; + } + if (registeringPasskey) { + return { ok: false, reason: 'busy' }; + } + + setRegisteringPasskey(true); + try { + const data = await startPasskeyRegistration(); + const challengeData = data as PasskeyChallengeResponse | undefined; + const challengeId = challengeData?.challengeId || challengeData?.challenge_id; + const publicKeyOptions = + challengeData?.publicKey + || challengeData?.public_key + || challengeData?.challenge?.publicKey + || challengeData?.publicKeyCredentialCreationOptions; + + if (!challengeId || !publicKeyOptions) { + throw new Error('Invalid passkey challenge response.'); + } + + const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions }); + const credential = (await navigator.credentials.create({ + publicKey, + } as CredentialCreationOptions)) as PublicKeyCredential | null; + + if (!credential) { + return { ok: false, reason: 'cancelled' }; + } + + const serialized = serializeRegistrationCredential(credential); + const payload: PasskeyRegisterPayload = { + challengeId, + credential: serialized, + }; + const trimmedNickname = nickname?.trim?.(); + if (trimmedNickname) { + payload.nickname = trimmedNickname; + } + + await finishPasskeyRegistration(payload); + await refreshPasskeys(); + setPasskeysSupported(true); + showToast('Passkey registered.', 'success'); + return { ok: true }; + } catch (error) { + const typedError = error as ApiError; + if (typedError?.name === 'NotAllowedError') { + showToast('Passkey registration cancelled.', 'info'); + return { ok: false, reason: 'cancelled' }; + } + + const status = typedError?.response?.status; + if (status === 400 || status === 404) { + setPasskeysSupported(false); + } + + const message = typedError?.response?.data?.error || 'Failed to register passkey.'; + notifyApiError(error, message); + return { ok: false, reason: 'error', message }; + } finally { + setRegisteringPasskey(false); + } + }, + [notifyApiError, refreshPasskeys, registeringPasskey, showToast], + ); + + const revokePasskey = useCallback( + async ( + passkeyId: PasskeyId, + reason?: string, + ): Promise => { + if (passkeyId == null) { + return { ok: false, reason: 'missing-id' }; + } + setRevokingPasskeyId(passkeyId); + try { + await deletePasskey(passkeyId, { reason }); + await refreshPasskeys(); + showToast('Passkey revoked.', 'success'); + return { ok: true }; + } catch (error) { + const message = (error as ApiError)?.response?.data?.error || 'Failed to revoke passkey.'; + notifyApiError(error, message); + return { ok: false, reason: 'error', message }; + } finally { + setRevokingPasskeyId(null); + } + }, + [notifyApiError, refreshPasskeys, showToast], + ); + + return { + passkeys, + passkeysSupported, + passkeysLoading, + registeringPasskey, + revokingPasskeyId, + refreshPasskeys, + registerPasskey, + revokePasskey, + }; +}; + +export default usePasskeys; diff --git a/frontend/src/sidebar/Sidebar.tsx b/frontend/src/sidebar/Sidebar.tsx new file mode 100644 index 0000000..07eeabf --- /dev/null +++ b/frontend/src/sidebar/Sidebar.tsx @@ -0,0 +1,137 @@ +import React, { useRef } from 'react'; +import { useAppShell } from '../lib/context/AppShellContext'; +import { usePanelResizeBindings } from '../app/PanelManagerContext'; +import SidebarFolderList from './components/SidebarFolderList'; +import SidebarTagList from './components/SidebarTagList'; +import SidebarCorrespondentList from './components/SidebarCorrespondentList'; +import { TenantOption } from './components/SidebarMenu'; +import SidebarHeader from './components/SidebarHeader'; +import SidebarSearch from './components/SidebarSearch'; + +const Sidebar: React.FC = () => { + const shell = useAppShell() as any; + const { + openTagsModal, + handleTagCreate, + tags = [], + } = shell.tags || {}; + const { + openCorrespondentsModal, + handleCorrespondentCreate, + correspondents = [], + } = shell.correspondents || {}; + const { + handleLogout, + tenant, + tenants, + tenantOptions, + handleTenantSelect, + } = shell.session || {}; + const { + openSettings, + } = shell.ui || {}; + const { + handleFileSelection, + } = shell.upload || {}; + const { + folderClickHandlers = {}, + handleFolderDelete, + handleFolderRename, + selectedFolder = null, + handleFolderDragStart, + handleFolderDragEnd, + draggedFolderId = null, + handlePromptCreateFolder, + creatingFolder = false, + } = shell.folderTree || {}; + + const sidebarSuppressed = shell.ui?.sidebarSuppressed; + + const onSelect = folderClickHandlers.onSelect; + const onDrop = folderClickHandlers.onDrop; + const onDragOver = folderClickHandlers.onDragOver; + const onDragLeave = folderClickHandlers.onDragLeave; + + const onManageTags = openTagsModal; + const onManageCorrespondents = openCorrespondentsModal; + + // Derived or context-based handlers/values + const tenantName = (tenant as TenantOption)?.name; + const effectiveTenants = (tenants || tenantOptions || []); + const activeTenantId = (tenant as TenantOption)?.id; + + const onUploadFiles = handleFileSelection; + + const sidebarRef = useRef(null); + const { + panelStyle: sidebarStyle, + handleProps: sidebarHandleProps, + isPanelResizing: isResizingSidebar, + } = usePanelResizeBindings('sidebar', { panelRef: sidebarRef }); + + const sidebarClassNames = ['sidebar']; + if (isResizingSidebar) { + sidebarClassNames.push('sidebar--resizing'); + } + if (sidebarSuppressed) { + sidebarClassNames.push('sidebar--suppressed'); + } + + return ( + + ); +}; + +export default Sidebar; diff --git a/frontend/src/sidebar/SidebarContext.tsx b/frontend/src/sidebar/SidebarContext.tsx new file mode 100644 index 0000000..d328ba4 --- /dev/null +++ b/frontend/src/sidebar/SidebarContext.tsx @@ -0,0 +1,290 @@ +import React, { + useMemo, + useState, + useCallback, + useEffect, +} from 'react'; +import { + DARK_MODE_MEDIA_QUERY, + DEFAULT_NEUTRAL_CHROMA, + DEFAULT_NEUTRAL_HUE, + DEFAULT_THEME_MODE, + SIDEBAR_COLLAPSE_STORAGE_KEY, + THEME_MODES, + THEME_STORAGE_KEY, +} from '../constants/sidebar'; +import { createSafeContext } from '../utils/createSafeContext'; + +interface SidebarContextValue { + collapsed: boolean; + setCollapsed: (value: boolean) => void; + neutralHue: number; + setNeutralHue: (value: number | string) => void; + resetNeutralHue: () => void; + neutralChroma: number; + setNeutralChroma: (value: number | string) => void; + resetNeutralChroma: () => void; + themeMode: ThemeMode; + setThemeMode: (mode: ThemeMode) => void; + cycleThemeMode: () => void; + themeModes: ThemeMode[]; +} + +type ThemeMode = (typeof THEME_MODES)[number]; + +const [SidebarContext, useSidebarContext] = createSafeContext('Sidebar'); + +const loadInitialThemeSettings = () => { + const defaults = { + neutralHue: DEFAULT_NEUTRAL_HUE, + neutralChroma: DEFAULT_NEUTRAL_CHROMA, + mode: DEFAULT_THEME_MODE, + }; + + const root = document.documentElement; + const readNumberVar = (name, fallback) => { + const inlineValue = root.style.getPropertyValue(name); + const inlineParsed = Number.parseFloat(inlineValue); + if (!Number.isNaN(inlineParsed)) { + return inlineParsed; + } + const computedValue = window.getComputedStyle(root).getPropertyValue(name); + const computedParsed = Number.parseFloat(computedValue); + if (!Number.isNaN(computedParsed)) { + return computedParsed; + } + return fallback; + }; + + const loadFromRoot = () => { + const current = root.style.getPropertyValue('--neutral-hue'); + const parsed = Number.parseInt(current, 10); + return { + neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed, + neutralChroma: readNumberVar('--neutral-chroma', defaults.neutralChroma), + }; + }; + + const rootValues = loadFromRoot(); + let neutralHueValue = rootValues.neutralHue; + let neutralChromaValue = rootValues.neutralChroma; + let modeValue = defaults.mode; + + const composite = window.localStorage.getItem(THEME_STORAGE_KEY); + if (composite) { + try { + const parsed = JSON.parse(composite); + const storedHue = Number.parseInt(parsed?.neutralHue, 10); + if (!Number.isNaN(storedHue)) { + neutralHueValue = storedHue; + } + const storedChroma = Number.parseFloat(parsed?.neutralChroma); + if (!Number.isNaN(storedChroma)) { + neutralChromaValue = Math.min(Math.max(storedChroma, 0), 1); + } + + const storedMode = parsed?.mode; + if (THEME_MODES.includes(storedMode)) { + modeValue = storedMode; + } + } catch (error) { + console.warn('[theme] failed to parse stored theme settings', error); + } + } else { + const legacyHue = window.localStorage.getItem('papercrate_neutral_hue'); + if (legacyHue) { + const parsedLegacyHue = Number.parseInt(legacyHue, 10); + if (!Number.isNaN(parsedLegacyHue)) { + neutralHueValue = parsedLegacyHue; + } + } + const legacyMode = window.localStorage.getItem('papercrate_theme_mode'); + if (THEME_MODES.includes(legacyMode)) { + modeValue = legacyMode; + } + } + + return { + neutralHue: neutralHueValue, + neutralChroma: neutralChromaValue, + mode: modeValue, + }; +}; + +const loadInitialCollapsedState = (defaultValue) => { + try { + const stored = window.sessionStorage.getItem(SIDEBAR_COLLAPSE_STORAGE_KEY); + if (stored === '1' || stored === 'true') { + return true; + } + if (stored === '0' || stored === 'false') { + return false; + } + } catch (error) { + console.warn('[sidebar] failed to read collapse state', error); + } + return Boolean(defaultValue); +}; + +export const SidebarProvider = ({ initialCollapsed = false, children }) => { + const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed)); + const initialTheme = useMemo(() => loadInitialThemeSettings(), []); + const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue); + const [neutralChroma, setNeutralChromaState] = useState(initialTheme.neutralChroma); + const [themeMode, setThemeModeState] = useState(initialTheme.mode); + const [systemPrefersDark, setSystemPrefersDark] = useState(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + return window.matchMedia(DARK_MODE_MEDIA_QUERY).matches; + }); + + useEffect(() => { + document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); + }, [neutralHue]); + + useEffect(() => { + document.documentElement.style.setProperty('--neutral-chroma', String(neutralChroma)); + }, [neutralChroma]); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return () => undefined; + } + const mediaQuery = window.matchMedia(DARK_MODE_MEDIA_QUERY); + const handler = (event: MediaQueryListEvent) => { + setSystemPrefersDark(event.matches); + }; + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + } + mediaQuery.addListener(handler); + return () => mediaQuery.removeListener(handler); + }, []); + + useEffect(() => { + const root = document.documentElement; + if (themeMode === 'system') { + if (systemPrefersDark) { + root.setAttribute('data-theme', 'dark'); + } else { + root.removeAttribute('data-theme'); + } + return; + } + root.setAttribute('data-theme', themeMode); + }, [themeMode, systemPrefersDark]); + + useEffect(() => { + try { + const payload = JSON.stringify({ + neutralHue, + neutralChroma, + mode: themeMode, + }); + window.localStorage.setItem(THEME_STORAGE_KEY, payload); + window.localStorage.removeItem('papercrate_neutral_hue'); + window.localStorage.removeItem('papercrate_theme_mode'); + } catch (error) { + console.warn('[theme] failed to persist theme settings', error); + } + }, [neutralHue, neutralChroma, themeMode]); + + const setNeutralHue = useCallback((value) => { + setNeutralHueState((prev) => { + if (value === '' || value === null || value === undefined) { + return DEFAULT_NEUTRAL_HUE; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return prev; + } + const clamped = Math.min(Math.max(Math.round(parsed), 0), 360); + return clamped; + }); + }, []); + + const setNeutralChroma = useCallback((value) => { + setNeutralChromaState((prev) => { + if (value === '' || value === null || value === undefined) { + return DEFAULT_NEUTRAL_CHROMA; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return prev; + } + const clamped = Math.min(Math.max(parsed, 0), 1); + return Math.round(clamped * 1000) / 1000; + }); + }, []); + + const resetNeutralChroma = useCallback(() => { + setNeutralChromaState(DEFAULT_NEUTRAL_CHROMA); + }, []); + + const resetNeutralHue = useCallback(() => { + setNeutralHueState(DEFAULT_NEUTRAL_HUE); + }, []); + + const setThemeMode = useCallback((mode) => { + if (!THEME_MODES.includes(mode)) { + return; + } + setThemeModeState(mode); + }, []); + + const cycleThemeMode = useCallback(() => { + const index = THEME_MODES.indexOf(themeMode); + const nextIndex = index === -1 ? 0 : (index + 1) % THEME_MODES.length; + setThemeModeState(THEME_MODES[nextIndex]); + }, [themeMode]); + + useEffect(() => { + try { + window.sessionStorage.setItem(SIDEBAR_COLLAPSE_STORAGE_KEY, collapsed ? '1' : '0'); + } catch (error) { + console.warn('[sidebar] failed to persist collapse state', error); + } + }, [collapsed]); + + const setCollapsed = useCallback((value: boolean) => { + setCollapsedState(Boolean(value)); + }, []); + + const contextValue = useMemo( + () => ({ + collapsed, + setCollapsed, + neutralHue, + setNeutralHue, + neutralChroma, + setNeutralChroma, + resetNeutralHue, + resetNeutralChroma, + themeMode, + setThemeMode, + cycleThemeMode, + themeModes: THEME_MODES, + defaultNeutralHue: DEFAULT_NEUTRAL_HUE, + defaultNeutralChroma: DEFAULT_NEUTRAL_CHROMA, + }), + [ + collapsed, + setCollapsed, + neutralHue, + setNeutralHue, + neutralChroma, + setNeutralChroma, + resetNeutralHue, + resetNeutralChroma, + themeMode, + setThemeMode, + cycleThemeMode, + ], + ); + + return {children}; +}; + +export { useSidebarContext }; diff --git a/frontend/src/sidebar/components/SidebarCorrespondentList.tsx b/frontend/src/sidebar/components/SidebarCorrespondentList.tsx new file mode 100644 index 0000000..cf182ca --- /dev/null +++ b/frontend/src/sidebar/components/SidebarCorrespondentList.tsx @@ -0,0 +1,114 @@ +import React, { useCallback, useMemo } from 'react'; +import { PlusIcon, SettingsIcon } from '../../components/icons'; +import type { Identifier } from '../../types/identifiers'; +import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; + +import type { Correspondent } from '../../types/documents'; + +interface SidebarCorrespondentListProps { + correspondents: Correspondent[]; + onCreateCorrespondent?: (payload: { name: string }) => Promise | void; + onManageCorrespondents?: () => void; +} + +const SidebarCorrespondentList: React.FC = ({ + correspondents, + onCreateCorrespondent, + onManageCorrespondents, +}) => { + const { + activeCorrespondentIds, + toggleCorrespondent: toggleCorrespondentFilter, + } = useDocumentsFilter(); + + const sortedCorrespondents = useMemo(() => { + if (!Array.isArray(correspondents)) { + return []; + } + return correspondents + .filter((entry): entry is Correspondent & { name: string } => Boolean(entry?.name)) + .slice() + .sort((a, b) => a.name!.localeCompare(b.name!, undefined, { sensitivity: 'base' })); + }, [correspondents]); + + const activeCorrespondentSet = useMemo( + () => new Set(activeCorrespondentIds || []), + [activeCorrespondentIds], + ); + + const handleCreateCorrespondent = useCallback(async () => { + const input = window.prompt('New correspondent name'); + if (!input) { + return; + } + const trimmed = input.trim(); + if (!trimmed) { + return; + } + try { + await onCreateCorrespondent?.({ name: trimmed }); + } catch (error: unknown) { + console.error('[sidebar] failed to create correspondent', error); + } + }, [onCreateCorrespondent]); + + const handleToggleCorrespondent = useCallback((correspondentId: Identifier | null) => { + toggleCorrespondentFilter(correspondentId); + }, [toggleCorrespondentFilter]); + + return ( +
+
+

Correspondents

+
+ + + {correspondents.length} +
+
+
    + {sortedCorrespondents.map((correspondent) => { + const isActive = activeCorrespondentSet.has(correspondent.id); + const className = `sidebar-correspondent-item${isActive ? ' active' : ''}`; + const handleSelect = () => { + const nextId = isActive ? null : correspondent.id; + handleToggleCorrespondent(nextId); + }; + return ( +
  • + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleSelect(); + } + }} + > + {correspondent.name} + +
  • + ); + })} +
+
+ ); +}; + +export default SidebarCorrespondentList; diff --git a/frontend/src/sidebar/components/SidebarFolderList.tsx b/frontend/src/sidebar/components/SidebarFolderList.tsx new file mode 100644 index 0000000..c690785 --- /dev/null +++ b/frontend/src/sidebar/components/SidebarFolderList.tsx @@ -0,0 +1,177 @@ +import React, { useCallback } from 'react'; +import type { ReactNode } from 'react'; +import type { Identifier } from '../../types/identifiers'; +import { FolderPlusIcon } from '../../components/icons'; +import FolderNode, { FolderIdentifier } from './SidebarFolderNode'; +import type { FolderTreeNode } from '../../lib/api/apiTypes'; +import { useAppShell } from '../../lib/context/AppShellContext'; +import FoldersManager from '../../documents/FoldersManager'; +import { useSyncExternalStore } from 'react'; + +interface SidebarFolderListProps { + selectedFolder: FolderIdentifier | null; + onSelect: (folderId: FolderIdentifier) => void; + onDrop: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onDragOver: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onDragLeave: (event: React.DragEvent) => void; + onDeleteFolder: (folderId: FolderIdentifier) => void; + onRenameFolder?: (folderId: FolderIdentifier, name: string) => void; + onFolderDragStart?: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onFolderDragEnd?: (event: React.DragEvent) => void; + draggedFolderId?: FolderIdentifier | null; + onCreateFolder?: (parentId?: Identifier | null) => void; + creatingFolder?: boolean; +} + +const SidebarFolderList: React.FC = ({ + selectedFolder, + onSelect, + onDrop, + onDragOver, + onDragLeave, + onDeleteFolder, + onRenameFolder, + onFolderDragStart, + onFolderDragEnd, + draggedFolderId, + onCreateFolder, + creatingFolder, +}) => { + const shell = useAppShell() as any; + const foldersManager = shell.folderTree?.foldersManager; + + const getTreeSnapshot = useCallback(() => { + if (!foldersManager) return []; + return (foldersManager as FoldersManager).getTreeSnapshot(); + }, [foldersManager]); + + const getFolderMap = useCallback(() => { + if (!foldersManager) return new Map(); + return (foldersManager as FoldersManager).getSnapshot(); + }, [foldersManager]); + + const subscribeToTree = useCallback((callback: () => void) => { + if (!foldersManager) return () => { }; + return (foldersManager as FoldersManager).subscribe(callback); + }, [foldersManager]); + + const roots = useSyncExternalStore(subscribeToTree, getTreeSnapshot); + const folderMap = useSyncExternalStore(subscribeToTree, getFolderMap); + const [expandedIds, setExpandedIds] = React.useState>(new Set(['root'])); + + // Auto-expand ancestors when selected folder changes + React.useEffect(() => { + if (!selectedFolder || !folderMap) return; + + const ancestors = new Set(); + let current = folderMap.get(String(selectedFolder)); + + while (current) { + const parentId = current.parentId || current.parent_id; + if (!parentId || parentId === 'root') break; + + ancestors.add(String(parentId)); + current = folderMap.get(String(parentId)); + } + + if (ancestors.size > 0) { + setExpandedIds((prev) => { + const next = new Set(prev); + let changed = false; + ancestors.forEach(id => { + if (!next.has(id)) { + next.add(id); + changed = true; + } + }); + return changed ? next : prev; + }); + } + }, [selectedFolder, folderMap]); + + const handleToggle = useCallback((folderId: FolderIdentifier) => { + setExpandedIds((prev) => { + const next = new Set(prev); + const id = String(folderId); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const renderNodes = useCallback( + (nodes: FolderTreeNode[], depth: number): ReactNode => + nodes.map((node) => { + const isExpanded = expandedIds.has(String(node.id)); + const liveNode = folderMap.get(String(node.id)); + const displayNode = liveNode ? { ...node, name: liveNode.name ?? node.name } : node; + + return ( + + ); + }), + [ + expandedIds, + folderMap, + selectedFolder, + handleToggle, + onSelect, + onDrop, + onDragOver, + onDragLeave, + onDeleteFolder, + onRenameFolder, + onFolderDragStart, + onFolderDragEnd, + draggedFolderId, + onCreateFolder, + ], + ); + + return ( +
+
+

Folders

+ {onCreateFolder ? ( +
+ +
+ ) : null} +
+
    + {renderNodes(roots as FolderTreeNode[], 0)} +
+
+ ); +}; + +export default SidebarFolderList; diff --git a/frontend/src/sidebar/components/SidebarFolderNode.tsx b/frontend/src/sidebar/components/SidebarFolderNode.tsx new file mode 100644 index 0000000..0951501 --- /dev/null +++ b/frontend/src/sidebar/components/SidebarFolderNode.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import { + ChevronIcon, + TrashIcon, + EditIcon, + FolderIcon, + FolderPlusIcon, +} from '../../components/icons'; +import type { Identifier } from '../../types/identifiers'; + +import type { FolderTreeNode } from '../../lib/api/apiTypes'; + +export type FolderIdentifier = Identifier | 'root'; + +interface FolderNodeProps { + node: FolderTreeNode; + depth: number; + isSelected: boolean; + onToggle: (folderId: FolderIdentifier) => void; + onSelect: (folderId: FolderIdentifier) => void; + onDrop: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onDragOver: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onDragLeave: (event: React.DragEvent) => void; + onDelete: (folderId: FolderIdentifier) => void; + onRename?: (folderId: FolderIdentifier, name: string) => void; + renderChildren: (nodes: FolderTreeNode[], depth: number) => ReactNode; + expanded: boolean; + onFolderDragStart?: (event: React.DragEvent, folderId: FolderIdentifier) => void; + onFolderDragEnd?: (event: React.DragEvent) => void; + draggingFolderId?: FolderIdentifier | null; + onCreateFolder?: (parentId?: Identifier | null) => void; +} + +const FolderNode: React.FC = ({ + node, + depth, + isSelected, + onToggle, + onSelect, + onDrop, + onDragOver, + onDragLeave, + onDelete, + onRename, + renderChildren, + expanded, + onFolderDragStart, + onFolderDragEnd, + draggingFolderId, + onCreateFolder, +}) => { + const isRoot = node.id === 'root'; + const childNodes = node.children || []; + const hasChildren = childNodes.length > 0; + const canToggle = hasChildren; // Simplified: only toggle if we have children to show + const showChevron = canToggle; + const icon = showChevron ? : null; + const canDrag = !isRoot; + const isDragging = draggingFolderId === node.id; + const isExpanded = expanded; + const rowClasses = ['folder-row']; + if (isSelected) { + rowClasses.push('active'); + } + + const handleToggleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + if (canToggle) { + onToggle(node.id); + } + }; + + return ( +
  • +
    onSelect(node.id)} + onDoubleClick={() => { + if (canToggle) { + onToggle(node.id); + } + }} + onDragOver={(event) => onDragOver(event, node.id)} + onDragLeave={onDragLeave} + onDrop={(event) => onDrop(event, node.id)} + onDragStart={(event) => { + if (!canDrag || !onFolderDragStart) return; + onFolderDragStart(event, node.id); + }} + onDragEnd={(event) => { + onFolderDragEnd?.(event); + }} + > + + {icon} + + + + + {node.name} + + +
    + + {node.id !== 'root' && ( + <> + + + + )} +
    +
    + {isExpanded && childNodes.length > 0 && ( +
      + {renderChildren(childNodes, depth + 1)} +
    + )} +
  • + ); +}; + +export default FolderNode; diff --git a/frontend/src/sidebar/components/SidebarHeader.tsx b/frontend/src/sidebar/components/SidebarHeader.tsx new file mode 100644 index 0000000..c81a27e --- /dev/null +++ b/frontend/src/sidebar/components/SidebarHeader.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import type { CSSProperties } from 'react'; +import { + SidebarCollapseIcon, + ChevronDownIcon, + UploadIcon, + LogoIcon, +} from '../../components/icons'; +import PanelHeader from '../../components/PanelHeader'; +import useFloatingMenu from '../../components/useFloatingMenu'; +import type { Identifier } from '../../types/identifiers'; +import SidebarMenu, { TenantOption } from './SidebarMenu'; +import { usePanelManager } from '../../app/PanelManagerContext'; +import { FolderIdentifier } from './SidebarFolderNode'; + +type UploadHandler = ( + files: FileList | File[] | Iterable, + targetFolderId?: FolderIdentifier | null, +) => void; + +interface SidebarHeaderProps { + tenantName?: string | null; + tenants: TenantOption[]; + activeTenantId: Identifier | null; + onSelectTenant?: (tenant: TenantOption | null, options?: { refreshOnly?: boolean }) => void; + onOpenSettings?: () => void; + onLogout?: () => void; + onUploadFiles?: UploadHandler; + selectedFolder?: FolderIdentifier | null; +} + +interface FloatingMenuControls { + isOpen: boolean; + toggle: () => void; + close: () => void; + menuRef: React.RefObject; + menuStyle: CSSProperties | null; + updatePosition: () => void; +} + +const SidebarHeader: React.FC = ({ + tenantName, + tenants, + activeTenantId, + onSelectTenant, + onOpenSettings, + onLogout, + onUploadFiles, + selectedFolder, +}) => { + const { collapseSidebar } = usePanelManager(); + const uploadInputRef = useRef(null); + const tenantButtonRef = useRef(null); + + const { + isOpen: tenantMenuOpen, + toggle: toggleTenantMenuFloating, + close: closeTenantMenu, + menuRef: tenantMenuRef, + menuStyle: tenantMenuStyle, + updatePosition: refreshTenantMenuPosition, + } = useFloatingMenu({ + anchorRef: tenantButtonRef, + minWidth: 220, + offset: 6, + }) as FloatingMenuControls; + + const toggleTenantMenu = useCallback(() => { + if (!tenantMenuOpen && tenants.length === 0 && onSelectTenant) { + onSelectTenant(null, { refreshOnly: true }); + } + toggleTenantMenuFloating(); + }, [tenantMenuOpen, tenants.length, onSelectTenant, toggleTenantMenuFloating]); + + useEffect(() => { + if (tenantMenuOpen) { + refreshTenantMenuPosition(); + } + }, [tenantMenuOpen, refreshTenantMenuPosition, tenants.length]); + + const handleCollapse = useCallback(() => { + collapseSidebar(); + }, [collapseSidebar]); + + const handleUploadButtonClick = useCallback(() => { + if (!onUploadFiles || !uploadInputRef.current) { + return; + } + uploadInputRef.current.click(); + }, [onUploadFiles]); + + const handleUploadInputChange = useCallback( + (event: React.ChangeEvent) => { + const files = event.target?.files; + if (files && files.length && onUploadFiles) { + onUploadFiles(files, selectedFolder ?? 'root'); + } + if (event.target) { + event.target.value = ''; + } + }, + [onUploadFiles, selectedFolder], + ); + + return ( + <> + + + + + + )} + actions={( + <> + + + + )} + /> + + ); +}; + +export default SidebarHeader; diff --git a/frontend/src/sidebar/components/SidebarMenu.tsx b/frontend/src/sidebar/components/SidebarMenu.tsx new file mode 100644 index 0000000..a8ee6f4 --- /dev/null +++ b/frontend/src/sidebar/components/SidebarMenu.tsx @@ -0,0 +1,316 @@ +import React, { useCallback, useId } from 'react'; +import type { CSSProperties } from 'react'; +import { createPortal } from 'react-dom'; +import { + GithubIcon, + MatrixIcon, + WorldIcon, + CheckIcon, + SettingsIcon, + LogoutIcon, + RestoreIcon, + SunIcon, + MoonIcon, + DesktopIcon, +} from '../../components/icons'; +import { useSidebarContext } from '../SidebarContext'; +import { THEME_MODE_LABELS, THEME_MODES } from '../../constants/sidebar'; +import type { Identifier } from '../../types/identifiers'; + +export interface TenantOption { + id?: Identifier | null; + name?: string | null; +} + +interface CommunityLink { + label: string; + href: string; + title: string; + Icon: typeof GithubIcon; +} + +const COMMUNITY_LINKS: CommunityLink[] = [ + { + label: 'GitHub', + href: 'https://github.com/papercrate-dms/papercrate', + title: 'Open Papercrate on GitHub', + Icon: GithubIcon, + }, + { + label: 'Matrix', + href: 'https://matrix.to/#/#papercrate:matrix.org', + title: 'Join the Papercrate Matrix room', + Icon: MatrixIcon, + }, + { + label: 'Website', + href: 'https://papercrate.org', + title: 'Visit papercrate.org', + Icon: WorldIcon, + }, +]; + +interface SidebarMenuProps { + isOpen: boolean; + menuRef: React.RefObject; + style: CSSProperties | null; + tenants: TenantOption[]; + activeTenantId: Identifier | null; + onSelectTenant?: (tenant: TenantOption | null, options?: { refreshOnly?: boolean }) => void; + onOpenSettings?: () => void; + onLogout?: () => void; + onClose: () => void; +} + +const SidebarMenu: React.FC = ({ + isOpen, + menuRef, + style, + tenants, + activeTenantId, + onSelectTenant, + onOpenSettings, + onLogout, + onClose, +}) => { + const { + neutralHue, + setNeutralHue, + resetNeutralHue, + neutralChroma, + setNeutralChroma, + resetNeutralChroma, + themeMode, + cycleThemeMode, + themeModes, + } = useSidebarContext(); + + const neutralHueInputId = useId(); + const neutralChromaInputId = useId(); + + const handleThemeAdjustmentsReset = useCallback(() => { + resetNeutralHue(); + resetNeutralChroma(); + }, [resetNeutralHue, resetNeutralChroma]); + + const handleNeutralHueChange = useCallback( + (value: string) => { + if (value === '') { + resetNeutralHue(); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralHue(parsed); + }, + [resetNeutralHue, setNeutralHue], + ); + + const handleNeutralChromaChange = useCallback( + (value: string) => { + if (value === '') { + resetNeutralChroma(); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralChroma(parsed); + }, + [resetNeutralChroma, setNeutralChroma], + ); + + const formatSliderValue = useCallback((value: number | string) => { + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return String(value); + } + return parsed.toFixed(2); + }, []); + + const themeModeList = Array.isArray(themeModes) && themeModes.length ? themeModes : THEME_MODES; + const themeModeIndex = themeModeList.indexOf(themeMode as string); + const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex; + const resolvedThemeMode = themeModeList[safeThemeModeIndex] || THEME_MODES[0]; + const nextThemeMode = themeModeList[(safeThemeModeIndex + 1) % themeModeList.length]; + const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode] || THEME_MODE_LABELS.system; + const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode] || THEME_MODE_LABELS.system; + const themeModeIcon = resolvedThemeMode === 'dark' + ? + : resolvedThemeMode === 'light' + ? + : ; + + const handleThemeModeToggle = useCallback(() => { + cycleThemeMode(); + }, [cycleThemeMode]); + + const handleTenantSelect = useCallback( + (tenant: TenantOption | null) => { + const targetId = tenant?.id || null; + if (!targetId) { + return; + } + onClose(); + onSelectTenant?.(tenant); + }, + [onClose, onSelectTenant], + ); + + const handleLogoutFromMenu = useCallback(() => { + onClose(); + onLogout?.(); + }, [onClose, onLogout]); + + const handleSettingsFromMenu = useCallback(() => { + onClose(); + onOpenSettings?.(); + }, [onClose, onOpenSettings]); + + const themeMenuSection = + neutralHue != null + ? ( +
    +
    + Theme +
    + + +
    +
    + + +
    + ) + : null; + + const communityMenuFooter = COMMUNITY_LINKS.length + ? ( +
    + {COMMUNITY_LINKS.map(({ href, label, title, Icon }) => ( + + + {label} + + ))} +
    + ) + : null; + + const showTenantList = tenants.length > 1; + const menuClassName = `menu${!showTenantList && !themeMenuSection && !communityMenuFooter ? ' menu--simple' : ''}`; + + if (!isOpen || !style) { + return null; + } + + return createPortal( +
    + {showTenantList ? ( +
    +
    Switch tenant
    +
    + {tenants.map((tenant) => { + const tenantId = tenant?.id || null; + const isActive = tenantId === activeTenantId; + const tenantLabel = tenant?.name || tenantId || 'Tenant'; + return ( + + ); + })} +
    +
    + ) : null} +
    +
    + + +
    +
    + {themeMenuSection} + {communityMenuFooter} +
    , + document.body, + ); +}; + +export default SidebarMenu; diff --git a/frontend/src/sidebar/components/SidebarSearch.tsx b/frontend/src/sidebar/components/SidebarSearch.tsx new file mode 100644 index 0000000..9d268fb --- /dev/null +++ b/frontend/src/sidebar/components/SidebarSearch.tsx @@ -0,0 +1,51 @@ +import React, { useCallback } from 'react'; +import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; + +const SidebarSearch: React.FC = () => { + const { + query: searchQuery, + isActive: isFilterActive, + setQuery: setFilterQuery, + submit: submitFilter, + clear: clearFilterState, + } = useDocumentsFilter(); + + const handleSearchInputChange = useCallback( + (event: React.ChangeEvent) => { + setFilterQuery(event.target.value); + }, + [setFilterQuery], + ); + + const handleSearchFormSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + submitFilter(); + }, + [submitFilter], + ); + + const handleSearchClear = useCallback(() => { + clearFilterState(); + }, [clearFilterState]); + + return ( +
    + + {isFilterActive && ( + + )} +
    + ); +}; + +export default SidebarSearch; + diff --git a/frontend/src/sidebar/components/SidebarTagList.tsx b/frontend/src/sidebar/components/SidebarTagList.tsx new file mode 100644 index 0000000..deb163c --- /dev/null +++ b/frontend/src/sidebar/components/SidebarTagList.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, useMemo } from 'react'; +import { PlusIcon, SettingsIcon } from '../../components/icons'; +import { getTagColorStyle } from '../../utils/colors'; +import { writeTagTransferData, clearTagTransferData } from '../../documents/features/tagging/tagTransfer'; +import type { Identifier } from '../../types/identifiers'; +import { useDocumentsFilter } from '../../documents/context/DocumentsFilterContext'; + +import type { Tag } from '../../types/documents'; + +interface SidebarTagListProps { + tags: Tag[]; + untaggedFilterId: Identifier | null; + onCreateTag?: (payload: { label: string }) => Promise | void; + onManageTags?: () => void; +} + +const SidebarTagList: React.FC = ({ + tags, + untaggedFilterId, + onCreateTag, + onManageTags, +}) => { + const { + activeTagIds, + toggleTag: toggleTagFilter, + } = useDocumentsFilter(); + + const activeTagSet = useMemo( + () => new Set(activeTagIds || []), + [activeTagIds], + ); + + const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false; + const untaggedButtonClassName = ['badge', 'tag-chip', 'tag-chip--untagged', untaggedActive ? 'active' : null] + .filter(Boolean) + .join(' '); + + const getTagChipClassName = (isActive: boolean) => + ['badge', 'tag-chip', isActive ? 'active' : null] + .filter(Boolean) + .join(' '); + + const handleCreateTag = useCallback(async () => { + const input = window.prompt('New tag name'); + if (!input) { + return; + } + const trimmed = input.trim(); + if (!trimmed) { + return; + } + try { + await onCreateTag?.({ label: trimmed }); + } catch (error: unknown) { + console.error('[sidebar] failed to create tag', error); + } + }, [onCreateTag]); + + const handleToggleTag = useCallback((tagId: Identifier | null) => { + if (tagId == null) return; + toggleTagFilter(tagId); + }, [toggleTagFilter]); + + return ( +
    +
    +

    Tags

    +
    + + + {tags.length} +
    +
    +
    + {untaggedFilterId ? ( + + ) : null} + {tags.map((tag) => { + const isActive = activeTagSet.has(tag.id); + const style = getTagColorStyle(tag.color); + const className = getTagChipClassName(isActive); + return ( + + ); + })} +
    +
    + ); +}; + +export default SidebarTagList; diff --git a/frontend/src/sidebar/sidebar.css b/frontend/src/sidebar/sidebar.css new file mode 100644 index 0000000..bbe7b6b --- /dev/null +++ b/frontend/src/sidebar/sidebar.css @@ -0,0 +1,722 @@ +.folder-tree { + list-style: none; + margin: 0; + padding: 0; +} + +.folder-node { + margin: 0; +} + +.folder-row { + display: flex; + align-items: center; + gap: 0.22rem; + padding: 0.32rem 0.48rem; + border-radius: 0.5rem; + cursor: pointer; + color: inherit; + transition: background 0.12s ease, color 0.12s ease; + position: relative; + overflow: hidden; + user-select: none; + -webkit-user-select: none; +} + +.sidebar .folder-row.is-drop-target { + outline: 2px dashed var(--accent-strong, var(--accent)); + outline-offset: 2px; +} + +.folder-row .name-wrap { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex: 1; + min-width: 0; +} + +.folder-row .name-wrap .name__label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-row .folder-icon-image { + flex-shrink: 0; +} + +.folder-row:hover { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.folder-row.active { + background: var(--sidebar-active-bg); + color: var(--fg); +} + +.folder-row .toggle { + width: 1.05rem; + display: inline-flex; + align-items: center; + justify-content: center; + user-select: none; + flex-shrink: 0; + transition: transform 0.18s ease; +} + +.folder-row .toggle.invisible { + visibility: hidden; +} + +.toggle-icon { + width: 0.9rem; + height: 0.9rem; + transition: transform 0.18s ease; +} + +.folder-row .toggle.expanded .toggle-icon { + transform: rotate(90deg); +} + +.folder-row__actions { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + display: flex; + gap: 0; + margin-right: 0.3rem; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + background-color: var(--surface-overlay); + border-radius: 0.2rem; +} + +.folder-row__actions .icon-button { + margin: 0; +} + +.folder-row:hover .folder-row__actions, +.folder-row:focus-within .folder-row__actions { + opacity: 1; + pointer-events: auto; + background-color: var(--surface-overlay); +} + +.folder-children { + list-style: none; + margin: 0 0 0 0.45rem; + padding: 0; +} + + + +.sidebar-section { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.sidebar { + gap: 0; + overflow: visible; + color: var(--sidebar-fg); + display: flex; + flex-direction: column; + width: min(100vw, var(--sidebar-width)); + max-width: min(100vw, var(--sidebar-width)); + flex: 0 0 var(--sidebar-width); + background: var(--bg); + position: relative; + z-index: 1000000; + container-type: inline-size; +} + +.sidebar--resizing { + cursor: col-resize; + user-select: none; +} + + +.sidebar__body { + flex: 1; + display: flex; + flex-direction: column; + padding: 0.5rem 1rem; + gap: 0.75rem; + overflow-y: auto; + min-height: 0; +} + +.sidebar__title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--fg); + padding-left: 0.25rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.sidebar__logo { + width: 1.6rem; + height: 1.6rem; + display: inline-block; +} + +.sidebar__title-text { + display: inline-block; +} + +@container (max-width: 20rem) { + .sidebar__title-text { + display: none; + } +} + +.sidebar__tenant { + color: var(--muted); + font-weight: 500; +} + +.sidebar__title-button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + padding: 0; +} + +.sidebar__title-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar__collapse-button { + color: color-mix(in oklch, var(--muted) 85%, transparent); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; +} + +.sidebar:hover .sidebar__collapse-button, +.sidebar__collapse-button:focus-visible { + opacity: 0.85; + pointer-events: auto; +} + +.sidebar__collapse-button:hover:not([disabled]), +.sidebar__collapse-button:focus-visible { + color: var(--muted); +} + + +.sidebar__title-button .sidebar__title-chevron { + opacity: 0; + transform: translateY(-1px); + transition: + opacity 0.12s ease, + transform 0.2s ease; +} + +.sidebar__title-button:hover .sidebar__title-chevron, +.sidebar__title-button:focus-visible .sidebar__title-chevron, +.sidebar__title-chevron.is-open { + opacity: 1; +} + +.sidebar__title-chevron.is-open { + transform: rotate(180deg) translateY(-1px); +} + +.menu { + position: absolute; + top: 100%; + left: 1em; + right: auto; + background: var(--surface); + border: 1px solid var(--border-muted, var(--border)); + border-radius: 0.5rem; + box-shadow: 0 12px 28px var(--shadow-strong); + min-width: 220px; + z-index: 2500000; + overflow: visible; +} + +.menu[data-floating-position] { + top: auto; + left: auto; + min-width: var(--floating-min-width, 220px); +} + +.menu__list { + overflow-y: auto; + max-height: min(20rem, 60vh); + padding: 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.menu__heading { + padding: 0.5rem 1rem 0.25rem; + font-size: 0.82rem; + font-weight: 600; + color: var(--muted); +} + +.menu__heading--with-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.menu button.menu__item, +.menu .menu__item { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 0.25rem; + background: none; + color: var(--sidebar-fg); + font: inherit; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.menu button.menu__item:hover, +.menu button.menu__item:focus-visible, +.menu .menu__item:hover, +.menu .menu__item:focus-visible { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.menu button.menu__item.active, +.menu .menu__item.active { + font-weight: 600; + color: var(--accent); + background: var(--accent-soft); +} + +.menu button.menu__item:focus-visible, +.menu .menu__item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.menu__label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.menu__check-slot { + width: 1rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.menu__active-indicator { + font-size: 0.75rem; + color: var(--muted); +} + +.menu__empty { + display: block; + padding: 0.6rem 0.75rem; + color: var(--muted); + font-size: 0.85rem; +} + +.menu__wrapper { + padding: 0.35rem 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.menu__section { + border-top: 1px solid var(--border-muted, var(--border)); +} + +.menu__section:first-of-type { + border-top: none; +} + + +.menu__section { + border-top: 1px solid var(--border); + padding: 0.35rem 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.menu__heading-actions { + display: inline-flex; + gap: 0.35rem; +} + +.menu__slider { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--muted); + padding: 0 1rem 0.25rem; +} + +.menu__section>.menu__slider { + margin-top: -0.25rem; +} + +.menu__slider input[type='range'] { + flex: 1; + accent-color: var(--accent); +} + +.menu__slider-label { + min-width: 2.5rem; +} + +.menu__slider-value { + font-variant-numeric: tabular-nums; + color: var(--fg); + min-width: 3rem; + text-align: right; +} + +.menu__button { + width: 100%; + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + padding: 0.4rem 0.5rem; + border-radius: 0.35rem; + transition: background 0.15s ease, color 0.15s ease; +} + +.menu__footer { + border-top: 1px solid var(--border); + margin-top: 0.35rem; + padding: 0.35rem 0.5rem; + display: flex; + gap: 0.35rem; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + color: var(--muted); +} + +.menu__footer-link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + text-decoration: none; + color: var(--muted); + font-size: 0.82rem; + padding: 0.25rem 0.4rem; + border-radius: 999px; + transition: background 0.15s ease, color 0.15s ease; +} + +.menu__footer-link:visited { + color: var(--muted); +} + +.menu__footer-link:hover, +.menu__footer-link:focus-visible { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.menu__button .menu__check-slot { + width: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent); +} + +.menu__button.active { + font-weight: 600; + color: var(--accent); + background: var(--accent-soft); +} + +.menu__button:hover, +.menu__button:focus-visible { + background: var(--sidebar-hover-bg); +} + +.menu__button--danger { + color: var(--danger); +} + +.menu__button--danger:hover, +.menu__button--danger:focus-visible { + background: var(--surface-danger-subtle); + color: var(--danger); +} + +.sidebar__search { + display: flex; + align-items: center; + gap: 0.4rem; + margin: 0 0 0.75rem; +} + +.sidebar__search input[type='search'] { + flex: 1; + padding: 0.4rem 0.6rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--fg); +} + +.sidebar__search button { + padding: 0.35rem 0.75rem; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--accent); + font-size: 0.85rem; + cursor: pointer; +} + +.sidebar__search button:hover:not([disabled]) { + background: var(--sidebar-hover-bg); +} + +.sidebar-section:first-of-type, +.sidebar-section--folders { + margin-top: 0; +} + +.sidebar-section--folders { + display: flex; + flex-direction: column; +} + +.sidebar-section__header { + display: flex; + align-items: center; + font-size: 0.75rem; + justify-content: space-between; + color: var(--muted); +} + +.sidebar-section__title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + width: 100%; + background: transparent; + border: none; + border-radius: 6px; + padding: 0.25rem 0.35rem; + margin: -0.25rem -0.35rem; + color: inherit; + font: inherit; + cursor: pointer; + text-align: left; +} + +.sidebar-section__title:hover:not([disabled]), +.sidebar-section__title:focus-visible { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.sidebar-section__title:hover:not([disabled]) h3, +.sidebar-section__title:focus-visible h3 { + color: var(--fg); +} + +.sidebar-section__header h3 { + margin: 0; + font-weight: 600; +} + +.sidebar-section__actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.sidebar-section__actions .meta { + font-size: 0.75rem; + color: var(--muted); +} + +.sidebar-slider { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--muted); +} + +.sidebar-slider input[type='range'] { + flex: 1; + accent-color: var(--accent); +} + +.sidebar-slider__value { + min-width: 3rem; + text-align: right; + color: var(--fg); + font-variant-numeric: tabular-nums; +} + +.sidebar-item { + background: transparent; + padding: 0.16rem 0.26rem; + text-align: left; + color: var(--sidebar-fg); + border-radius: 2px; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; + display: flex; + align-items: center; + gap: 0.38rem; + width: 100%; +} + +.sidebar-item:hover { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.sidebar-item.active { + background: var(--sidebar-active-bg); + color: var(--fg); + font-weight: 600; +} + +.sidebar-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar-item__icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: var(--accent); +} + +.sidebar-item.active .sidebar-item__icon { + color: currentColor; +} + +.sidebar-tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.25rem 0 0.1rem; + --tag-chip-base-default: var(--surface-soft); +} + +.sidebar-tag-cloud .tag-chip { + border-radius: 999px; + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: transform 0.15s ease, opacity 0.12s ease; +} + +.sidebar-tag-cloud .tag-chip:focus-visible { + outline: 2px solid var(--tag-chip-outline-color, var(--accent)); + outline-offset: 2px; +} + +.sidebar-tag-cloud .tag-chip.active { + outline: 2px solid var(--tag-chip-outline-color, var(--accent)); + outline-offset: 0; +} + +.sidebar-tag-cloud .tag-chip:hover { + transform: none; + outline: 3px solid var(--tag-chip-outline-color, var(--accent)); + outline-offset: 0; +} + +.sidebar-tag-cloud .tag-chip--untagged { + --tag-chip-base: var(--surface-subtle); + color: var(--muted); +} + +.sidebar-tag-cloud .tag-chip--untagged.active { + color: var(--fg); +} + +.sidebar-tag-cloud--has-active .tag-chip:not(.active) { + opacity: 0.45; +} + +.sidebar-correspondent-list { + list-style: none; + margin: 0.4rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar-correspondent-item { + display: block; + background: transparent; + border-radius: 4px; + padding: 0.35rem 0.5rem; + text-align: left; + color: var(--sidebar-fg); + cursor: pointer; + font-size: 0.85rem; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.sidebar-correspondent-item:hover { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.sidebar-correspondent-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar-correspondent-item.active { + background: var(--sidebar-active-bg); + color: var(--fg); +} \ No newline at end of file diff --git a/frontend/src/styles/base/controls.css b/frontend/src/styles/base/controls.css new file mode 100644 index 0000000..6f17852 --- /dev/null +++ b/frontend/src/styles/base/controls.css @@ -0,0 +1,107 @@ +button { + font: inherit; + border-radius: 2px; + border: 1px solid transparent; + padding: 0.35rem 0.85rem; + background: var(--accent); + color: var(--on-accent); + cursor: pointer; + font-weight: 500; + transition: background 0.15s ease, border-color 0.15s ease; +} + +button[disabled] { + opacity: 0.55; + cursor: not-allowed; +} + +a.button-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font: inherit; + border-radius: 2px; + border: 1px solid transparent; + padding: 0.35rem 0.85rem; + background: var(--accent); + color: var(--on-accent); + text-decoration: none; + font-weight: 500; + transition: background 0.15s ease, border-color 0.15s ease; +} + +a.button-link[aria-disabled='true'] { + opacity: 0.55; +} + +a.button-link:hover:not([aria-disabled='true']) { + background: var(--accent-hover); +} + +button.secondary { + background: transparent; + color: var(--fg); + border-color: var(--border); +} + +button.secondary:hover:not([disabled]) { + background: var(--surface-subtle); +} + +button.danger { + background: transparent; + color: var(--danger); + border-color: var(--danger-border); +} + +button.danger:hover:not([disabled]) { + border-color: var(--danger); + background: var(--danger-subtle); +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + padding: 0.25rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.icon-button:hover:not([disabled]) { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.icon-button--accent { + color: var(--on-accent); + background: var(--accent); +} + +.icon-button--accent:hover:not([disabled]) { + background: var(--accent-hover); +} + +.icon-button.danger { + color: var(--danger); +} + +.icon-button.danger:hover:not([disabled]) { + background: var(--danger-subtle); +} + +.icon-button.ghost { + border: none; + background: transparent; + color: var(--muted); + padding: 0.2rem; +} + +.icon-button.ghost:hover:not([disabled]) { + color: var(--danger); + background: transparent; +} diff --git a/frontend/src/styles/base/iconography.css b/frontend/src/styles/base/iconography.css new file mode 100644 index 0000000..fa84f2a --- /dev/null +++ b/frontend/src/styles/base/iconography.css @@ -0,0 +1,56 @@ +.with-icon { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.icon-inline { + width: 1rem; + height: 1rem; +} + +.icon { + width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +@keyframes icon-spin { + to { + transform: rotate(360deg); + } +} + +.icon--spin { + animation: icon-spin 0.9s linear infinite; +} + +.icon--flip-y { + transform: scaleX(-1); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.icon path { + stroke: currentColor; + stroke-width: 1.6; + fill: none; +} + +.icon--fill path { + stroke: none; + fill: currentColor; +} + + diff --git a/frontend/src/styles/base/text-button.css b/frontend/src/styles/base/text-button.css new file mode 100644 index 0000000..fceeabc --- /dev/null +++ b/frontend/src/styles/base/text-button.css @@ -0,0 +1,14 @@ +.text-button { + background: none; + border: none; + color: var(--accent); + font-weight: 500; + font-size: 0.8rem; + cursor: pointer; + padding: 0.1rem 0.25rem; +} + +.text-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/frontend/src/styles/base/theme.css b/frontend/src/styles/base/theme.css new file mode 100644 index 0000000..c1d8625 --- /dev/null +++ b/frontend/src/styles/base/theme.css @@ -0,0 +1,242 @@ +:root { + color-scheme: light dark; + + --neutral-hue: 29deg; + --surface-hue-shift: 6deg; + --foreground-hue-offset: 20deg; + --accent-hue-offset: 0deg; + --dark-hue-offset: 40deg; + --dark-foreground-hue-offset: 20deg; + --neutral-chroma: 0.44; + --neutral-chroma-min: 0.01; + --neutral-chroma-max: 0.20; + --neutral-chroma-range: calc(var(--neutral-chroma-max) - var(--neutral-chroma-min)); + --neutral-chroma-effective: calc(var(--neutral-chroma-min) + clamp(0, var(--neutral-chroma), 1) * var(--neutral-chroma-range)); + --neutral-contrast-delta: 0.033; + --neutral-chroma-scale: calc(clamp(0.0001, var(--neutral-chroma-effective), 1) / 0.1); + --tag-chip-default-l: 0.9; + --tag-chip-bg-lighten: 0.4; + --tag-chip-bg-chroma-scale: 0.75; + --tag-chip-outline-lighten: 0.12; + --tag-chip-outline-chroma-scale: 0.75; + --tag-chip-text-lighten: -0.5; + --tag-chip-text-chroma-scale: 0.4; + + /* --- Neutrals --- */ + --bg: oklch(calc(0.97 + var(--neutral-contrast-delta) * 0.12) calc(var(--neutral-chroma-effective) * 0.001) calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(calc(0.995 + var(--neutral-contrast-delta) * 0.1) calc(var(--neutral-chroma-effective) * 0.001) calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(calc(0.96 + var(--neutral-contrast-delta) * 0.16) calc(var(--neutral-chroma-effective) * 0.002) calc(var(--neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(calc(0.32 - var(--neutral-contrast-delta) * 0.18) calc(var(--neutral-chroma-effective) * 0.003) calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --muted: oklch(calc(0.54 - var(--neutral-contrast-delta) * 0.05) calc(var(--neutral-chroma-effective) * 0.005) calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --muted-subtle: color-mix(in oklch, var(--muted) 55%, var(--border)); + --sidebar-fg: oklch(calc(0.56 + var(--neutral-contrast-delta) * 0.18) calc(var(--neutral-chroma-effective) * 0.003) calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --border: oklch(calc(0.92 + var(--neutral-contrast-delta) * 0.15) calc(var(--neutral-chroma-effective) * 0.0015) calc(var(--neutral-hue) + var(--foreground-hue-offset))); + + /* --- Accent (primary) --- */ + --accent: oklch(0.61 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); + --accent-hover: oklch(0.70 calc(0.12 * var(--neutral-chroma-scale)) calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); + --on-accent: oklch(1 0 0); + + --folder-icon-back: color-mix(in oklch, var(--accent) 78%, black 8%); + --folder-icon-mid: color-mix(in oklch, var(--accent) 60%, white 30%); + --folder-icon-front: color-mix(in oklch, var(--accent) 42%, white 58%); + + --success-hue: 140deg; + --warning-hue: 85deg; + --danger-hue: 25deg; + + --accent-soft: color-mix(in oklch, var(--accent) 12%, transparent); + --accent-elevated: color-mix(in oklch, var(--accent) 16%, transparent); + --accent-elevated-strong: color-mix(in oklch, var(--accent) 22%, transparent); + --accent-outline: color-mix(in oklch, var(--accent) 45%, transparent); + --accent-outline-strong: color-mix(in oklch, var(--accent) 80%, transparent); + --accent-focus: color-mix(in oklch, var(--accent) 75%, transparent); + --surface-overlay: color-mix(in oklch, white 82%, transparent); + + /* -- Selection --- */ + --selection: oklch(0.61 0.01 var(--neutral-hue)); + + /* --- Derived accent states --- */ + --selection-soft: color-mix(in oklch, var(--selection) 12%, transparent); + + /* --- Shadows & overlays --- */ + --shadow-faint: color-mix(in oklch, black 6%, transparent); + --shadow-soft: color-mix(in oklch, black 12%, transparent); + --shadow-medium: color-mix(in oklch, black 18%, transparent); + --shadow-strong: color-mix(in oklch, black 28%, transparent); + --shadow-deep: color-mix(in oklch, black 55%, transparent); + --shadow-pop: color-mix(in oklch, black 25%, transparent); + --outline-subtle: color-mix(in oklch, black 6%, transparent); + --overlay-dark: color-mix(in oklch, black 55%, transparent); + --overlay-dim: color-mix(in oklch, oklch(0.28 0.01 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 20%, transparent); + --overlay-darker: color-mix(in oklch, black 75%, transparent); + --surface-danger-subtle: color-mix(in oklch, var(--danger) 12%, transparent); + --overlay-accent-subtle: color-mix(in oklch, var(--accent) 12%, transparent); + --border-strong: color-mix(in oklch, var(--fg) 24%, transparent); + --surface-hover: color-mix(in oklch, white 10%, transparent); + --overlay-accent-strong: color-mix(in oklch, var(--accent) 24%, transparent); + --overlay-backdrop: color-mix(in oklch, oklch(0.22 0.015 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 45%, transparent); + --overlay-shadow: color-mix(in oklch, oklch(0.22 0.015 calc(var(--neutral-hue) + var(--foreground-hue-offset) - 5deg)) 18%, transparent); + + /* --- Semantic colors --- */ + --success: oklch(0.68 0.16 var(--success-hue)); + --warning: oklch(0.76 0.17 var(--warning-hue)); + --danger: oklch(0.64 0.2 var(--danger-hue)); + + --success-subtle: color-mix(in oklch, var(--success) 12%, transparent); + --warning-subtle: color-mix(in oklch, var(--warning) 12%, transparent); + --danger-border: color-mix(in oklch, var(--danger) 45%, transparent); + --danger-soft: color-mix(in oklch, var(--danger) 12%, transparent); + --danger-subtle: color-mix(in oklch, var(--danger) 8%, transparent); + + /* --- Explorer states --- */ + --row-hover-bg: color-mix(in oklch, var(--selection) 6%, transparent); + --row-active-bg: color-mix(in oklch, var(--selection) 12%, transparent); + --sidebar-hover-bg: color-mix(in oklch, var(--selection) 8%, transparent); + --sidebar-active-bg: color-mix(in oklch, var(--selection) 16%, transparent); + --selection-ring: oklch(0.78 0.16 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 64%, transparent); + --link: var(--accent); + --link-visited: color-mix(in oklch, var(--accent) 75%, var(--fg) 25%); + --preview-nav-bg: oklch(0.18 0.05 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --preview-nav-bg-hover: oklch(0.14 0.05 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --preview-nav-fg: var(--on-accent); + --text-on-dark: color-mix(in oklch, white 90%, transparent); + --surface-ink-soft: color-mix(in oklch, var(--fg) 8%, transparent); + + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 100%; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + + --detail-panel-width: calc(100vw / 3); + --sidebar-width: 20em; + --documents-grid-title-size: 0.8rem; + --media-viewer-padding: 0.75rem; +} + +:root[data-theme='light'] { + color-scheme: light; +} + +:root[data-theme='dark'] { + color-scheme: dark; + + --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); + --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); + --bg: oklch(calc(0.18 - var(--neutral-contrast-delta) * 0.015) calc(var(--neutral-chroma-effective) * 0.1) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(calc(0.22 - var(--neutral-contrast-delta) * 0.012) calc(var(--neutral-chroma-effective) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(calc(0.26 - var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma-effective) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(calc(0.9 + var(--neutral-contrast-delta) * 0.02) calc(var(--neutral-chroma-effective) * 0.04) var(--dark-foreground-hue)); + --muted: oklch(calc(0.72 + var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma-effective) * 0.06) var(--dark-foreground-hue)); + --muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border)); + --sidebar-fg: oklch(calc(0.78 + var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma-effective) * 0.06) var(--dark-foreground-hue)); + --border: oklch(calc(0.33 - var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma-effective) * 0.03) var(--dark-foreground-hue)); + + --accent: oklch(0.75 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent-hover: oklch(0.82 calc(0.13 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); + + --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%); + --folder-icon-mid: color-mix(in oklch, var(--accent) 50%, white 24%); + --folder-icon-front: color-mix(in oklch, var(--accent) 30%, white 50%); + + --accent-soft: color-mix(in oklch, var(--accent) 22%, transparent); + --accent-elevated: color-mix(in oklch, var(--accent) 28%, transparent); + --accent-elevated-strong: color-mix(in oklch, var(--accent) 38%, transparent); + --accent-outline: color-mix(in oklch, var(--accent) 55%, transparent); + --accent-outline-strong: color-mix(in oklch, var(--accent) 80%, transparent); + --accent-focus: color-mix(in oklch, var(--accent) 72%, transparent); + --surface-overlay: color-mix(in oklch, black 60%, transparent); + + --selection: oklch(0.44 0.04 220deg); + --selection-soft: color-mix(in oklch, var(--selection) 18%, transparent); + + --shadow-faint: color-mix(in oklch, black 35%, transparent); + --shadow-soft: color-mix(in oklch, black 50%, transparent); + --shadow-medium: color-mix(in oklch, black 65%, transparent); + --shadow-strong: color-mix(in oklch, black 80%, transparent); + --shadow-deep: color-mix(in oklch, black 90%, transparent); + --shadow-pop: color-mix(in oklch, black 70%, transparent); + --outline-subtle: color-mix(in oklch, white 10%, transparent); + --overlay-dark: color-mix(in oklch, black 70%, transparent); + --overlay-dim: color-mix(in oklch, oklch(0.42 0.04 var(--dark-neutral-hue)) 35%, transparent); + --overlay-darker: color-mix(in oklch, black 85%, transparent); + --surface-danger-subtle: color-mix(in oklch, var(--danger) 26%, transparent); + --overlay-accent-subtle: color-mix(in oklch, var(--accent) 32%, transparent); + --border-strong: color-mix(in oklch, var(--fg) 30%, transparent); + --surface-hover: color-mix(in oklch, white 6%, transparent); + --overlay-accent-strong: color-mix(in oklch, var(--accent) 55%, transparent); + --overlay-backdrop: color-mix(in oklch, oklch(0.1 0.015 var(--dark-neutral-hue)) 70%, transparent); + --overlay-shadow: color-mix(in oklch, oklch(0.12 0.015 var(--dark-neutral-hue)) 55%, transparent); + + --tag-chip-default-l: 0.2; + --tag-chip-bg-lighten: -1.8; + --tag-chip-bg-chroma-scale: 0.95; + --tag-chip-outline-lighten: 0.8; + --tag-chip-outline-chroma-scale: 2.5; + --tag-chip-text-lighten: 0.5; + --tag-chip-text-chroma-scale: 0.9; + + --success: oklch(0.62 0.16 var(--success-hue)); + --warning: oklch(0.68 0.17 var(--warning-hue)); + --danger: oklch(0.60 0.2 var(--danger-hue)); + + --success-subtle: color-mix(in oklch, var(--success) 20%, transparent); + --warning-subtle: color-mix(in oklch, var(--warning) 20%, transparent); + --danger-border: color-mix(in oklch, var(--danger) 45%, transparent); + --danger-soft: color-mix(in oklch, var(--danger) 24%, transparent); + --danger-subtle: color-mix(in oklch, var(--danger) 18%, transparent); + + --row-hover-bg: color-mix(in oklch, white 4%, transparent); + --row-active-bg: color-mix(in oklch, var(--selection) 20%, transparent); + --sidebar-hover-bg: color-mix(in oklch, white 6%, transparent); + --sidebar-active-bg: color-mix(in oklch, var(--selection) 26%, transparent); + --selection-ring: oklch(0.68 0.16 var(--dark-foreground-hue)); + --sidebar-active-pill-border: color-mix(in oklch, var(--accent) 72%, transparent); + --link: color-mix(in oklch, var(--accent) 92%, transparent); + --link-visited: color-mix(in oklch, var(--accent) 70%, white 20%); + --preview-nav-bg: oklch(0.3 0.04 var(--dark-neutral-hue)); + --preview-nav-bg-hover: oklch(0.35 0.04 var(--dark-neutral-hue)); + --preview-nav-fg: var(--fg); + --text-on-dark: color-mix(in oklch, white 92%, transparent); + --surface-ink-soft: color-mix(in oklch, white 12%, transparent); +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + overflow: hidden; +} + +body.has-main-content { + background: var(--surface); +} + +a { + color: var(--accent); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; + transition: color 0.15s ease, text-decoration-color 0.15s ease; +} + +a:visited { + color: var(--link-visited); +} + +a:hover, +a:focus-visible { + color: var(--accent-hover); + outline: none; + text-decoration-color: currentColor; +} + +#app { + height: 100%; +} \ No newline at end of file diff --git a/frontend/src/styles/components/resize-handle.css b/frontend/src/styles/components/resize-handle.css new file mode 100644 index 0000000..e47b084 --- /dev/null +++ b/frontend/src/styles/components/resize-handle.css @@ -0,0 +1,45 @@ +.resize-handle { + position: absolute; + top: 0; + width: 1.2rem; + height: 100%; + border: none; + background: transparent; + padding: 0; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: col-resize; + z-index: 10; + touch-action: none; +} + +.resize-handle__line { + width: 3px; + height: 100%; + border-radius: 999px; + background: transparent; + transition: background 120ms ease; +} + +.resize-handle:hover .resize-handle__line, +.resize-handle:focus-visible .resize-handle__line, +.resize-handle.is-active .resize-handle__line { + background: var(--border); +} + +.resize-handle:focus-visible { + outline: 2px solid color-mix(in oklch, var(--border) 80%, transparent); + outline-offset: 2px; + border-radius: 999px; +} + +/* Modifiers for positioning */ +.resize-handle--right { + right: -0.6rem; +} + +.resize-handle--left { + left: -0.6rem; +} \ No newline at end of file diff --git a/frontend/src/styles/forms/elements.css b/frontend/src/styles/forms/elements.css new file mode 100644 index 0000000..d1c8ad1 --- /dev/null +++ b/frontend/src/styles/forms/elements.css @@ -0,0 +1,46 @@ +select { + font: inherit; + border-radius: 2px; + border: 1px solid var(--border); + padding: 0.4rem 0.5rem; + background: var(--surface); + color: inherit; + width: 100%; + box-sizing: border-box; +} + +input[type='text'], +input[type='email'], +input[type='password'], +input[type='search'], +input[type='number'], +input[type='url'], +input[type='tel'] { + font: inherit; + border-radius: 2px; + border: 1px solid var(--border); + padding: 0.4rem 0.5rem; + background: var(--surface); + color: inherit; + width: 100%; + box-sizing: border-box; +} + +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); + background: var(--surface); +} + +textarea { + resize: vertical; +} + +form.inline { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..89367b9 --- /dev/null +++ b/frontend/src/styles/index.css @@ -0,0 +1,24 @@ +@import './base/theme.css'; +@import './base/controls.css'; +@import './layout/panel-header-controls.css'; +@import './components/resize-handle.css'; +@import '../viewer/fullscreen-preview.css'; +@import './base/iconography.css'; +@import '../documents/styles/controls.css'; +@import './layout/structure.css'; +@import '../viewer/styles/viewer.css'; +@import '../documents/styles/drag-preview.css'; +@import '../documents/styles/tags-correspondents.css'; +@import '../documents/styles/panel-sections.css'; +@import '../sidebar/sidebar.css'; +@import '../documents/styles/listing.css'; +@import '../viewer/styles/detail-panels.css'; +@import './forms/elements.css'; +@import './modals/status-drop.css'; +@import '../settings/settings.css'; +@import './modals/panel-modals.css'; +@import './layout/responsive.css'; +@import '../login/login.css'; +@import '../documents/styles/shared-snippets.css'; +@import './uploads/overlay.css'; +@import './base/text-button.css'; \ No newline at end of file diff --git a/frontend/src/styles/layout/panel-header-controls.css b/frontend/src/styles/layout/panel-header-controls.css new file mode 100644 index 0000000..7846e7c --- /dev/null +++ b/frontend/src/styles/layout/panel-header-controls.css @@ -0,0 +1,43 @@ +.panel-header .icon-button, +.panel-header button, +.panel-header a.icon-button { + display: inline-flex; + align-items: center; + justify-content: flex-start; + border: none; + background: transparent; + color: var(--muted); + padding: 0.25rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + text-decoration: none; + text-align: left; +} + +.panel-header .icon-button:hover:not([disabled]), +.panel-header button:hover:not([disabled]), +.panel-header a.icon-button:hover { + background: var(--sidebar-hover-bg); + color: var(--fg); +} + +.panel-header .icon-button.active:hover:not([disabled]), +.panel-header button.active:hover:not([disabled]), +.panel-header a.icon-button.active:hover { + background: var(--accent-soft); + color: var(--fg); +} + +.panel-header .icon-button.ghost, +.panel-header button.icon-button.ghost { + color: var(--muted); + padding: 0.25rem; +} + +.panel-header .icon-button.ghost:hover:not([disabled]), +.panel-header button.icon-button.ghost:hover:not([disabled]) { + color: var(--fg); + background: var(--sidebar-hover-bg); +} + diff --git a/frontend/src/styles/layout/responsive.css b/frontend/src/styles/layout/responsive.css new file mode 100644 index 0000000..a3195d5 --- /dev/null +++ b/frontend/src/styles/layout/responsive.css @@ -0,0 +1,17 @@ +@media (max-width: 768px) { + .app-bar { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + .app-main { + grid-template-columns: 1fr; + } + .sidebar, + .documents-panel, + .detail-panel, + .tags-panel, + .correspondents-panel { + min-height: auto; + } +} diff --git a/frontend/src/styles/layout/structure.css b/frontend/src/styles/layout/structure.css new file mode 100644 index 0000000..6eefea0 --- /dev/null +++ b/frontend/src/styles/layout/structure.css @@ -0,0 +1,94 @@ +.app-shell { + height: 100dvh; + height: 100vh; + display: flex; + flex-direction: column; + color: var(--fg); +} + + +.documents-main { + height: 100%; + flex: 1; + display: flex; + width: 100%; + position: relative; +} + +.documents-main--sidebar-hidden { + position: relative; +} + +.main-content { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + min-width: 0; + margin: 0; + border-radius: 0; + background: var(--surface); + flex-grow: 1; + box-shadow: -12px 0 24px -12px var(--shadow-faint); +} + +.documents-main:not(.documents-main--sidebar-hidden) .main-content { + border-left: 1px solid var(--border); +} + +.main-content { + padding-right: 0; +} + +.documents-main--sidebar-hidden { + position: relative; + grid-template-columns: auto minmax(0, 1fr); +} + +.main-content__actions { + display: flex; + align-items: center; + gap: 0.6rem; +} + +.main-content__actions-divider { + display: inline-flex; + align-items: center; + color: var(--muted-subtle); +} + +.documents-main--overlay-detail .main-content .panel-header { + margin-right: var(--detail-panel-width); +} + +.main-content__breadcrumbs { + flex: 1 1 auto; + overflow: hidden; +} + +.panel-header__subtitle { + font-size: 0.9rem; + color: var(--muted); + font-weight: 400; + line-height: 1.2; + flex-shrink: 0; +} + +.panels-main { + flex: 1; + display: flex; + justify-content: center; + padding: 1.5rem; + overflow: auto; +} + +.panels-main > * { + flex: 0 1 720px; +} + +.preview-main { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} diff --git a/frontend/src/styles/modals/panel-modals.css b/frontend/src/styles/modals/panel-modals.css new file mode 100644 index 0000000..67f6791 --- /dev/null +++ b/frontend/src/styles/modals/panel-modals.css @@ -0,0 +1,44 @@ +.panel-modal__header { + padding: 1rem 1rem 1rem 0.5rem; + border-bottom: 1px solid var(--border); +} + +.panel-modal__body { + flex: 1; + overflow: auto; + padding: 0; +} + +.modal__body { + width: 100%; +} + +.modal h3 { + margin: 0; + font-size: 1.15rem; + font-weight: 600; +} + +.modal__form { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.modal__form label { + font-weight: 600; + font-size: 0.9rem; +} + +.modal__error { + margin: -0.45rem 0 0; + font-size: 0.85rem; + color: var(--danger); +} + +.modal__actions { + display: flex; + justify-content: flex-start; + padding: 0.25rem; + gap: 0.6rem; +} diff --git a/frontend/src/styles/modals/status-drop.css b/frontend/src/styles/modals/status-drop.css new file mode 100644 index 0000000..42e4686 --- /dev/null +++ b/frontend/src/styles/modals/status-drop.css @@ -0,0 +1,75 @@ +.status-banner { + padding: 0.45rem 0.8rem; + border-radius: 2px; + font-size: 0.85rem; +} + +.status-banner.info { + background: var(--surface-subtle); + color: var(--muted); +} + +.status-banner.success { + background: var(--success-subtle); + color: var(--success); +} + +.status-banner.error { + background: var(--danger-soft); + color: var(--danger); +} + +.drop-overlay { + position: fixed; + inset: 0; + background: var(--accent-soft); + backdrop-filter: blur(4px); + display: none; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.drop-overlay.active { + display: flex; +} + +.drop-overlay__content { + background: var(--surface); + color: var(--fg); + padding: 1.25rem 1.75rem; + border-radius: 2px; + text-align: center; + font-size: 0.95rem; + box-shadow: none; +} +.modal-backdrop { + position: fixed; + inset: 0; + background: var(--overlay-backdrop); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + z-index: 2000000; +} + +.modal { + background: var(--surface); + border-radius: 4px; + box-shadow: 0 18px 42px var(--overlay-shadow); + width: min(360px, 100%); + padding: 1.6rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal--panel { + width: 80vw; + height: 90vh; + padding: 0; + border-radius: 1rem; + overflow: hidden; + gap: 0; +} diff --git a/frontend/src/styles/status-toast.css b/frontend/src/styles/status-toast.css new file mode 100644 index 0000000..e37d929 --- /dev/null +++ b/frontend/src/styles/status-toast.css @@ -0,0 +1,86 @@ +.status-toast-container { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + z-index: 5000000; + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: center; + pointer-events: none; +} + +.status-toast-pill { + position: relative; + background: var(--surface); + color: var(--fg); + padding: 0.35rem 0.7rem; + border-radius: 2rem; + font-size: 0.875rem; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + pointer-events: auto; + animation: fadeIn 0.3s ease-out; + max-width: 90vw; + word-wrap: break-word; +} + +.status-toast-pill--success { + background: linear-gradient(var(--success-subtle), var(--success-subtle)), var(--surface); + color: var(--success); +} + +.status-toast-pill--info { + background: linear-gradient(var(--surface-subtle), var(--surface-subtle)), var(--surface); + color: var(--fg); +} + +.status-toast-pill--error { + background: linear-gradient(var(--danger-soft), var(--danger-soft)), var(--surface); + color: var(--danger); +} + +.status-toast-pill--exiting { + animation: fadeOut 0.3s ease-out forwards; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(1rem); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(1rem); + } +} + +/* Mobile responsiveness */ +@media (max-width: 640px) { + .status-toast-container { + bottom: 1rem; + width: calc(100% - 2rem); + left: 1rem; + transform: none; + } + + .status-toast-pill { + padding: 0.65rem 1rem; + font-size: 0.8125rem; + max-width: 100%; + } +} \ No newline at end of file diff --git a/frontend/src/styles/uploads/overlay.css b/frontend/src/styles/uploads/overlay.css new file mode 100644 index 0000000..3c3ec57 --- /dev/null +++ b/frontend/src/styles/uploads/overlay.css @@ -0,0 +1,161 @@ +.upload-queue-overlay { + position: fixed; + bottom: 24px; + right: calc(24px + var(--detail-panel-width, 0px)); + width: min(360px, calc(100vw - 32px)); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 18px 45px var(--shadow-pop); + z-index: 400; + display: flex; + flex-direction: column; + color: var(--fg); +} + + +.upload-queue-overlay__body { + padding: 0.6rem 0.9rem 0.9rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.upload-queue-overlay__controls { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.upload-queue-overlay__list { + list-style: none; + margin: 0; + padding: 0; + max-height: 260px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.upload-queue-overlay--collapsed .upload-queue-overlay__list { + display: none; +} + +.upload-queue-overlay__item { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.6rem; + padding: 0.15rem 0; +} + +.upload-queue-overlay__status { + width: 28px; + height: 28px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--surface-subtle); + color: var(--muted); +} + +.upload-queue-overlay__status--muted { + background: var(--surface-subtle); + color: var(--muted); +} + +.upload-queue-overlay__status--accent { + background: var(--accent-soft); + color: var(--accent); +} + +.upload-queue-overlay__status--success { + background: var(--success-subtle); + color: var(--success); +} + +.upload-queue-overlay__status--info { + background: var(--selection-soft); + color: var(--accent); +} + +.upload-queue-overlay__status--danger { + background: var(--danger-subtle); + color: var(--danger); +} + +.upload-queue-overlay__details { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.upload-queue-overlay__name { + font-size: 0.88rem; + font-weight: 500; + color: var(--fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upload-queue-overlay__name-link { + font-size: 0.88rem; + font-weight: 500; + color: inherit; + background: none; + border: none; + padding: 0; + text-align: left; + cursor: pointer; + text-decoration: none; +} + +.upload-queue-overlay__name-link:hover { + color: var(--accent); + text-decoration: underline; +} + +.upload-queue-overlay__meta-line { + font-size: 0.72rem; + color: var(--muted); + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.upload-queue-overlay__meta-id, +.upload-queue-overlay__meta-secondary { + color: inherit; +} + +.upload-queue-overlay__meta-link { + border: none; + background: none; + font: inherit; + cursor: pointer; + padding: 0; + color: inherit; + text-decoration: none; +} + +.upload-queue-overlay__meta-link:hover { + color: var(--accent); + text-decoration: underline; +} + +.upload-queue-overlay__meta-link:disabled { + cursor: default; + opacity: 0.5; +} + +.upload-queue-overlay__meta-error { + color: var(--danger); +} + +.upload-queue-overlay__item--error .upload-queue-overlay__name { + color: var(--danger); +} diff --git a/frontend/src/tags/TagsPanel.tsx b/frontend/src/tags/TagsPanel.tsx new file mode 100644 index 0000000..b137e5a --- /dev/null +++ b/frontend/src/tags/TagsPanel.tsx @@ -0,0 +1,353 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + getTagColorStyle, + HEX_COLOR_PATTERN, + generateRandomTagColor, +} from '../utils/colors'; + +function TagsPanel({ + tags, + onRefresh, + onCreateTag, + onUpdateTag, + onDeleteTag, + onNotify, +}) { + const [editingId, setEditingId] = useState(null); + const [draftLabel, setDraftLabel] = useState(''); + const [draftColor, setDraftColor] = useState(''); + const [saving, setSaving] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [createLabel, setCreateLabel] = useState(''); + const [createColor, setCreateColor] = useState(''); + const [creating, setCreating] = useState(false); + + const startEdit = useCallback((tag) => { + setEditingId(tag.id); + setDraftLabel(tag.label); + setDraftColor(tag.color ?? ''); + }, []); + + const cancelEdit = useCallback(() => { + setEditingId(null); + setDraftLabel(''); + setDraftColor(''); + setSaving(false); + }, []); + + const colorPickerValue = useMemo(() => { + if (!draftColor) { + return '#3366ff'; + } + const match = HEX_COLOR_PATTERN.exec(draftColor.trim()); + if (!match) { + return '#3366ff'; + } + return `#${match[1].toLowerCase()}`; + }, [draftColor]); + + const handleSave = useCallback(async () => { + if (!editingId) return; + + const trimmedLabel = draftLabel.trim(); + if (!trimmedLabel) { + onNotify?.('Tag label cannot be empty.', 'error'); + return; + } + + const trimmedColor = draftColor.trim(); + const colorPattern = /^#([0-9a-fA-F]{6})$/; + if (trimmedColor && !colorPattern.test(trimmedColor)) { + onNotify?.('Colors must use the #RRGGBB format.', 'error'); + return; + } + + setSaving(true); + try { + await onUpdateTag(editingId, { + label: trimmedLabel, + color: trimmedColor ? trimmedColor : null, + }); + cancelEdit(); + } catch (updateError) { + const message = updateError?.message || 'Failed to update tag.'; + onNotify?.(message, 'error'); + } finally { + setSaving(false); + } + }, [editingId, draftLabel, draftColor, onUpdateTag, cancelEdit, onNotify]); + + const handleKeyDown = useCallback( + (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSave(); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelEdit(); + } + }, + [handleSave, cancelEdit], + ); + + const handleDelete = useCallback( + async (tag) => { + if (!tag?.id || !onDeleteTag) { + return; + } + + setDeletingId(tag.id); + try { + await onDeleteTag(tag.id); + if (editingId === tag.id) { + cancelEdit(); + } + } catch (deleteError) { + const message = deleteError?.message || 'Failed to delete tag.'; + onNotify?.(message, 'error'); + } finally { + setDeletingId(null); + } + }, + [onDeleteTag, editingId, cancelEdit, onNotify], + ); + + const handleCreate = useCallback( + async (event) => { + event.preventDefault(); + if (!onCreateTag) { + return; + } + + const trimmedLabel = createLabel.trim(); + if (!trimmedLabel) { + onNotify?.('Tag label cannot be empty.', 'error'); + return; + } + + const trimmedColor = createColor.trim(); + const colorPattern = /^#([0-9a-fA-F]{6})$/; + if (trimmedColor && !colorPattern.test(trimmedColor)) { + onNotify?.('Colors must use the #RRGGBB format.', 'error'); + return; + } + + setCreating(true); + try { + await onCreateTag({ + label: trimmedLabel, + color: trimmedColor ? trimmedColor : null, + }); + setCreateLabel(''); + setCreateColor(''); + } catch (createError) { + const message = createError?.message || 'Failed to create tag.'; + onNotify?.(message, 'error'); + } finally { + setCreating(false); + } + }, + [createLabel, createColor, onCreateTag, onNotify], + ); + + return ( +
    +
    +
    +

    Tags

    +
    {tags.length} total
    +
    +
    +
    + setCreateLabel(event.target.value)} + disabled={creating} + /> + setCreateColor(event.target.value)} + disabled={creating} + aria-label="Tag color (optional)" + /> + + {createColor && ( + + )} + +
    + +
    +
    +
    + {tags.length === 0 ? ( +
    No tags created yet.
    + ) : ( +
    + + + + + + + + + + + {tags.map((tag) => { + const isEditing = editingId === tag.id; + return ( + + + + + + + ); + })} + +
    TagColor + Documents + + Actions +
    + {isEditing ? ( + setDraftLabel(event.target.value)} + onKeyDown={handleKeyDown} + disabled={saving || deletingId === tag.id} + autoFocus + /> + ) : ( + + {tag.label} + + )} + + {isEditing ? ( +
    + setDraftColor(event.target.value)} + disabled={saving || deletingId === tag.id} + aria-label="Pick tag color" + /> + + {draftColor && ( + + )} +
    + ) : tag.color ? ( + + ) : ( + + )} +
    {tag.usage_count ?? 0} + {isEditing ? ( +
    + + + +
    + ) : ( +
    + + +
    + )} +
    +
    + )} +
    +
    + ); +} + +export default TagsPanel; diff --git a/frontend/src/types/assets.ts b/frontend/src/types/assets.ts new file mode 100644 index 0000000..18d16a2 --- /dev/null +++ b/frontend/src/types/assets.ts @@ -0,0 +1,15 @@ +import type { Identifier } from './identifiers'; +import type { Download } from './common'; + +export interface ThumbnailMetadata { + width: number; + height: number; +} + +export interface Asset { + id?: Identifier; + asset_type?: string; + download?: Download | null; + metadata?: ThumbnailMetadata | Record | null; + [key: string]: unknown; +} diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts new file mode 100644 index 0000000..2837e68 --- /dev/null +++ b/frontend/src/types/common.ts @@ -0,0 +1,4 @@ +export interface Download { + url: string; + expires_at: number; +} diff --git a/frontend/src/types/documents.ts b/frontend/src/types/documents.ts new file mode 100644 index 0000000..2d02a50 --- /dev/null +++ b/frontend/src/types/documents.ts @@ -0,0 +1,92 @@ +import type { Identifier } from './identifiers'; +import type { Asset } from './assets'; +import type { Download } from './common'; + +export interface Tag { + id: Identifier; + label: string; + color: string | null; + usage_count: number; +} + +export interface Correspondent { + id: Identifier; + name: string; + usage_count: number; +} + +export interface DocumentVersion { + assets?: Record | Asset[] | null; + metadata?: Record & { page_count?: number } | null; + size_bytes?: number | null; + checksum?: string | null; + download?: Download | null; +} + +export interface MessageOptions { + showMessage?: boolean; +} + +export interface Document { + id?: Identifier; + title?: string | null; + original_name?: string | null; + filename?: string | null; + mime_type?: string | null; + + issued_at?: string | number | null; + created_at?: string | null; + uploaded_at?: string | null; + updated_at?: string | null; + + folder_id?: Identifier | null; + folder_name?: string; + folder_path?: string; + + tags?: Identifier[] | null; + correspondents?: Identifier[] | null; + + current_version?: DocumentVersion | null; + + // Allow for other properties as we unify loosely typed interfaces + [key: string]: unknown; +} + +export interface Folder { + id: Identifier | 'root'; + name: string; + parent_id?: Identifier | 'root' | null; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +type FolderEntry = { + type: 'folder'; + id: Identifier | 'root'; + key: string; + folder: Folder; +}; + +type DocumentEntry = { + type: 'document'; + id: Identifier; + key: string; + document: Document; +}; + +export type DocumentsListEntry = FolderEntry | DocumentEntry; + +/** + * Represents a folder node in the UI tree structure (flat map representation). + */ +export interface FolderNode { + id: Identifier | 'root'; + name?: string; + parentId?: Identifier | 'root' | null; + children: (Identifier | 'root')[]; + expanded?: boolean; + loaded?: boolean; + hasChildren?: boolean; + [key: string]: unknown; +} diff --git a/frontend/src/types/identifiers.ts b/frontend/src/types/identifiers.ts new file mode 100644 index 0000000..6755fbb --- /dev/null +++ b/frontend/src/types/identifiers.ts @@ -0,0 +1,12 @@ +// Common string-based identifiers used across the app. +export type Identifier = string; + +export type DocumentId = Identifier; +export type FolderId = Identifier; +export type FolderNodeId = FolderId | 'root'; +export type CapabilitySetId = Identifier; +export type CapabilityValue = Identifier; +export type TenantId = Identifier; +export type TagId = Identifier; +export type ApiTokenId = Identifier; +export type PasskeyId = Identifier; diff --git a/frontend/src/types/svg.d.ts b/frontend/src/types/svg.d.ts new file mode 100644 index 0000000..deedf98 --- /dev/null +++ b/frontend/src/types/svg.d.ts @@ -0,0 +1,6 @@ +declare module '*.svg' { + import type { FunctionComponent, SVGProps } from 'react'; + + const ReactComponent: FunctionComponent & { title?: string }>; + export default ReactComponent; +} diff --git a/frontend/src/utils/colors.ts b/frontend/src/utils/colors.ts new file mode 100644 index 0000000..f5cc949 --- /dev/null +++ b/frontend/src/utils/colors.ts @@ -0,0 +1,355 @@ +import { HEX_COLOR_PATTERN } from '../constants/colors'; + +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); + +const clampRange = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + +const hslToHex = (h: number, s: number, l: number): string => { + const normalizedH = ((h % 360) + 360) % 360; + const sat = clamp01(s); + const light = clamp01(l); + + const chroma = (1 - Math.abs(2 * light - 1)) * sat; + const hPrime = normalizedH / 60; + const x = chroma * (1 - Math.abs((hPrime % 2) - 1)); + + let r1 = 0; + let g1 = 0; + let b1 = 0; + + if (hPrime >= 0 && hPrime < 1) { + r1 = chroma; + g1 = x; + } else if (hPrime >= 1 && hPrime < 2) { + r1 = x; + g1 = chroma; + } else if (hPrime >= 2 && hPrime < 3) { + g1 = chroma; + b1 = x; + } else if (hPrime >= 3 && hPrime < 4) { + g1 = x; + b1 = chroma; + } else if (hPrime >= 4 && hPrime < 5) { + r1 = x; + b1 = chroma; + } else { + r1 = chroma; + b1 = x; + } + + const m = light - chroma / 2; + const r = clamp01(r1 + m); + const g = clamp01(g1 + m); + const b = clamp01(b1 + m); + + const sr = Math.round(r * 255); + const sg = Math.round(g * 255); + const sb = Math.round(b * 255); + return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`; +}; + +interface RgbColor { + r: number; + g: number; + b: number; +} + +const rgbToHsl = ({ r, g, b }: RgbColor) => { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const delta = max - min; + + let hue = 0; + if (delta !== 0) { + if (max === rn) { + hue = ((gn - bn) / delta) % 6; + } else if (max === gn) { + hue = (bn - rn) / delta + 2; + } else { + hue = (rn - gn) / delta + 4; + } + hue *= 60; + if (hue < 0) hue += 360; + } + + const lightness = (max + min) / 2; + let saturation = 0; + if (delta !== 0) { + saturation = delta / (1 - Math.abs(2 * lightness - 1)); + } + + return { h: hue, s: clamp01(saturation), l: clamp01(lightness) }; +}; + +const srgbChannelToLinear = (value: number) => { + const normalized = value / 255; + if (normalized <= 0.04045) { + return normalized / 12.92; + } + return ((normalized + 0.055) / 1.055) ** 2.4; +}; + +const hexToRgb = (input?: string): (RgbColor & { hex: string }) | null => { + if (!input) return null; + const match = HEX_COLOR_PATTERN.exec(input.trim()); + if (!match) return null; + const value = parseInt(match[1], 16); + return { + r: (value >> 16) & 0xff, + g: (value >> 8) & 0xff, + b: value & 0xff, + hex: `#${match[1].toLowerCase()}`, + }; +}; + +const hexToOklch = (input?: string) => { + const rgb = hexToRgb(input); + if (!rgb) { + return null; + } + const r = srgbChannelToLinear(rgb.r); + const g = srgbChannelToLinear(rgb.g); + const b = srgbChannelToLinear(rgb.b); + + const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + const lRoot = Math.cbrt(l); + const mRoot = Math.cbrt(m); + const sRoot = Math.cbrt(s); + + const L = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot; + const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot; + const bLab = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot; + + const chroma = Math.sqrt(a * a + bLab * bLab); + let hue = Math.atan2(bLab, a) * (180 / Math.PI); + if (hue < 0) { + hue += 360; + } + + return { + l: clamp01(L), + c: chroma, + h: hue, + }; +}; + +const formatCssNumber = (value: number) => { + if (!Number.isFinite(value)) { + return `${value}`; + } + const rounded = Number(value.toFixed(6)); + return `${rounded}`; +}; + +const buildTagStyle = (backgroundHex: string) => { + const oklch = hexToOklch(backgroundHex); + if (!oklch) { + return { + '--tag-chip-base': backgroundHex, + }; + } + return { + '--tag-chip-base': backgroundHex, + '--tag-chip-base-l': formatCssNumber(oklch.l), + '--tag-chip-base-c': formatCssNumber(oklch.c), + '--tag-chip-base-h': formatCssNumber(oklch.h), + }; +}; + +const relativeLuminance = ({ r, g, b }: RgbColor): number => { + const toLinear = (channel) => { + const normalized = channel / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }; + + const [red, green, blue] = [toLinear(r), toLinear(g), toLinear(b)]; + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +}; + +const contrastRatio = (lumA: number, lumB: number): number => { + const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA]; + return (lighter + 0.05) / (darker + 0.05); +}; + +const parseCandidateColor = (candidate: string) => { + const rgb = hexToRgb(candidate); + if (!rgb) return null; + return { + hex: rgb.hex, + luminance: relativeLuminance(rgb), + }; +}; + +const contrastForPair = (backgroundHex: string, textHex: string): number => { + const background = hexToRgb(backgroundHex); + const text = hexToRgb(textHex); + if (!background || !text) { + return 0; + } + return contrastRatio(relativeLuminance(background), relativeLuminance(text)); +}; + +const getReadableTextColor = ( + hex: string, + { light = '#1f1f1f', dark = '#ffffff', fallback = '#1f1f1f' }: { light?: string; dark?: string; fallback?: string } = {}, +): string => { + const background = hexToRgb(hex); + if (!background) return fallback; + + const backgroundLuminance = relativeLuminance(background); + const backgroundHsl = rgbToHsl(background); + + const hueShift = 180; + const textHue = (backgroundHsl.h + hueShift) % 360; + const targetSaturation = clampRange(backgroundHsl.s * 1.15, 0.4, 0.85); + const minLightness = 0.05; + const maxLightness = 0.95; + const sampleCount = 24; + + const buildCandidate = (lightness) => { + const light = clampRange(lightness, minLightness, maxLightness); + const hexValue = hslToHex(textHue, targetSaturation, light); + return parseCandidateColor(hexValue); + }; + + const candidates = new Map(); + + for (let index = 0; index < sampleCount; index += 1) { + const t = index / (sampleCount - 1); + const candidateLightness = minLightness + t * (maxLightness - minLightness); + const candidate = buildCandidate(candidateLightness); + if (candidate) { + candidates.set(candidate.hex, candidate); + } + } + + [light, dark].forEach((preset) => { + const parsed = parseCandidateColor(preset); + if (parsed) { + candidates.set(parsed.hex, parsed); + } + }); + + if (candidates.size === 0) { + return fallback; + } + + let best = null; + let bestRatio = -Infinity; + candidates.forEach((candidate) => { + const ratio = contrastRatio(backgroundLuminance, candidate.luminance); + if (ratio > bestRatio) { + bestRatio = ratio; + best = candidate; + } + }); + + const MIN_CONTRAST = 4.5; + if (bestRatio < MIN_CONTRAST) { + const extremeLight = buildCandidate(maxLightness); + const extremeDark = buildCandidate(minLightness); + const extremes = [extremeLight, extremeDark].filter(Boolean); + extremes.forEach((candidate) => { + const ratio = contrastRatio(backgroundLuminance, candidate.luminance); + if (ratio > bestRatio) { + bestRatio = ratio; + best = candidate; + } + }); + } + + return best?.hex || fallback; +}; + +export const getTagColorStyle = (hex) => { + const rgb = hexToRgb(hex); + if (!rgb) return null; + const baseHex = rgb.hex; + const baseHsl = rgbToHsl(rgb); + const baseText = getReadableTextColor(baseHex); + const baseRatio = contrastForPair(baseHex, baseText); + const TARGET_RATIO = 8; + const MIN_LIGHTNESS = 0.12; + const MAX_LIGHTNESS = 0.88; + const adjustments = [-0.18, -0.12, -0.08, -0.04, 0.04, 0.08, 0.12, 0.18]; + const seen = new Map(); + + const registerCandidate = (lightness) => { + const clamped = clampRange(lightness, MIN_LIGHTNESS, MAX_LIGHTNESS); + const hexValue = hslToHex(baseHsl.h, baseHsl.s, clamped); + if (!seen.has(hexValue)) { + seen.set(hexValue, clamped); + } + }; + + registerCandidate(baseHsl.l); + adjustments.forEach((delta) => registerCandidate(baseHsl.l + delta)); + + let bestBackground = baseHex; + let bestRatio = baseRatio; + + if (bestRatio >= TARGET_RATIO) { + return buildTagStyle(bestBackground); + } + + let compliantBackground = null; + let compliantDelta = Infinity; + let compliantRatio = -Infinity; + + seen.forEach((lightness, candidateHex) => { + const textHex = getReadableTextColor(candidateHex); + const ratio = contrastForPair(candidateHex, textHex); + const delta = Math.abs(lightness - baseHsl.l); + + if (ratio > bestRatio) { + bestBackground = candidateHex; + bestRatio = ratio; + } + + if (ratio >= TARGET_RATIO) { + const deltaEpsilon = 0.0025; + const ratioEpsilon = 0.01; + const isCloser = delta + deltaEpsilon < compliantDelta; + const isSimilarDistance = Math.abs(delta - compliantDelta) <= deltaEpsilon; + const improvesRatio = ratio > compliantRatio + ratioEpsilon; + if ( + compliantBackground === null + || isCloser + || (isSimilarDistance && improvesRatio) + ) { + compliantBackground = candidateHex; + compliantDelta = delta; + compliantRatio = ratio; + } + } + }); + + if (compliantBackground) { + return buildTagStyle(compliantBackground); + } + + return buildTagStyle(bestBackground); +}; + +export const generateRandomTagColor = () => { + const bucketCount = 8; + const bucketWidth = 360 / bucketCount; + const bucket = Math.floor(Math.random() * bucketCount); + const baseHue = bucket * bucketWidth; + const hueJitter = bucketWidth * 0.35; + const hue = baseHue + (Math.random() * 2 - 1) * hueJitter; + const saturation = 0.45 + Math.random() * 0.2; // 0.45 - 0.65 + const lightness = 0.55 + Math.random() * 0.1; // 0.55 - 0.65 + return hslToHex(hue, saturation, lightness); +}; + +export { HEX_COLOR_PATTERN }; diff --git a/frontend/src/utils/createSafeContext.ts b/frontend/src/utils/createSafeContext.ts new file mode 100644 index 0000000..156ca92 --- /dev/null +++ b/frontend/src/utils/createSafeContext.ts @@ -0,0 +1,25 @@ +import { createContext, useContext, type Context } from 'react'; + +/** + * Creates a React context with a paired hook that throws a helpful error + * if used outside of the provider. + * + * @example + * const [ApiContext, useApi] = createSafeContext('Api'); + * + * // In component: + * const api = useApi(); // throws if outside ApiProvider + */ +export function createSafeContext(name: string): [Context, () => T] { + const Context = createContext(null); + + const useContextHook = (): T => { + const ctx = useContext(Context); + if (!ctx) { + throw new Error(`use${name} must be used within a ${name}Provider`); + } + return ctx; + }; + + return [Context, useContextHook]; +} diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts new file mode 100644 index 0000000..62ae8c3 --- /dev/null +++ b/frontend/src/utils/date.ts @@ -0,0 +1,53 @@ +const ensureDate = (value: string | Date | null): Date | null => { + if (!value) { + return null; + } + const date = value instanceof Date ? new Date(value.getTime()) : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +interface FormatOptions { + fallback?: string; + locale?: Intl.LocalesArgument; + options?: Intl.DateTimeFormatOptions; +} + +export const formatDate = (value: string | Date | null, { fallback = '—', locale, options }: FormatOptions = {}): string => { + const date = ensureDate(value); + if (!date) { + return fallback; + } + return date.toLocaleDateString(locale, options); +}; + +export const formatDateTime = (value: string | Date | null, { fallback = '—', locale, options }: FormatOptions = {}): string => { + const date = ensureDate(value); + if (!date) { + return fallback; + } + return date.toLocaleString(locale, options); +}; + +export const toDateInputValue = (value: string | Date | null): string => { + const date = ensureDate(value); + if (!date) { + return ''; + } + const timezoneOffset = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - timezoneOffset * 60000); + return localDate.toISOString().slice(0, 10); +}; + +export const toIssuedTimestamp = (dateString: string | null, fallback: string | Date | null): string | null => { + if (!dateString) { + return null; + } + const base = ensureDate(fallback) || new Date(); + const [year, month, day] = dateString.split('-').map((part) => Number.parseInt(part, 10)); + if (!year || !month || !day) { + return null; + } + const candidate = new Date(base); + candidate.setUTCFullYear(year, month - 1, day); + return Number.isNaN(candidate.getTime()) ? null : candidate.toISOString(); +}; diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..e7be327 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,14 @@ +export const formatFileSize = (bytes: number): string => { + const sign = bytes < 0 ? '-' : ''; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; + let index = 0; + let amount = Math.abs(bytes); + + while (amount >= 1024 && index < units.length - 1) { + amount /= 1024; + index += 1; + } + + const decimals = index === 0 ? 0 : 2; + return `${sign}${amount.toFixed(decimals)} ${units[index]}`; +}; diff --git a/frontend/src/utils/math.ts b/frontend/src/utils/math.ts new file mode 100644 index 0000000..da25cc9 --- /dev/null +++ b/frontend/src/utils/math.ts @@ -0,0 +1,9 @@ +export const clamp = (value: number, min: number, max: number): number => { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; +}; diff --git a/frontend/src/utils/webauthn.ts b/frontend/src/utils/webauthn.ts new file mode 100644 index 0000000..2576ed9 --- /dev/null +++ b/frontend/src/utils/webauthn.ts @@ -0,0 +1,227 @@ +/* global PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions, BufferSource, PublicKeyCredentialUserEntity, PublicKeyCredentialDescriptor, AuthenticatorSelectionCriteria, AuthenticatorTransport, AuthenticationExtensionsClientOutputs */ + +type CreationChallengeResponse = { + publicKey?: PublicKeyCredentialCreationOptions & { + challenge?: string | BufferSource; + user?: PublicKeyCredentialUserEntity & { id?: string | BufferSource }; + excludeCredentials?: Array; + authenticatorSelection?: AuthenticatorSelectionCriteria & { requireResidentKey?: boolean }; + }; +}; + +type RequestChallengeResponse = { + publicKey?: PublicKeyCredentialRequestOptions & { + challenge?: string | BufferSource; + allowCredentials?: Array; + }; +}; + +type RegistrationCredential = PublicKeyCredential & { + response: AuthenticatorAttestationResponse & { + getTransports?: () => AuthenticatorTransport[]; + }; +}; + +type AuthenticationCredential = PublicKeyCredential & { + response: AuthenticatorAssertionResponse; +}; + +const base64urlToBase64 = (value: string = ''): string => { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalized.length % 4; + if (padding === 0) { + return normalized; + } + const padLength = 4 - padding; + return normalized + '='.repeat(padLength); +}; + +const base64ToBase64url = (value: string = ''): string => + value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + +const decodeBase64 = (value: string): string => window.atob(value); + +const encodeBase64 = (binary: string): string => window.btoa(binary); + +const base64urlToUint8Array = (value?: string | null): Uint8Array => { + const base64 = base64urlToBase64(value || ''); + const binary = decodeBase64(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +const base64urlToBufferSource = (value: string): ArrayBuffer => { + const bytes = base64urlToUint8Array(value); + const clone = new Uint8Array(bytes.length); + clone.set(bytes); + return clone.buffer; +}; + +const toUint8Array = (input?: ArrayBuffer | ArrayBufferView | ArrayLike | null): Uint8Array => { + if (!input) { + return new Uint8Array(); + } + if (input instanceof ArrayBuffer) { + return new Uint8Array(input as ArrayBuffer) as Uint8Array; + } + if (ArrayBuffer.isView(input)) { + const buffer = (input.buffer as ArrayBuffer).slice( + input.byteOffset, + input.byteOffset + input.byteLength, + ); + return new Uint8Array(buffer) as Uint8Array; + } + return Uint8Array.from(input as ArrayLike) as Uint8Array; +}; + +const arrayBufferToBase64url = ( + buffer?: ArrayBuffer | ArrayBufferView | ArrayLike | null, +): string => { + const bytes = toUint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + const base64 = encodeBase64(binary); + return base64ToBase64url(base64); +}; + +export const isWebAuthnAvailable = (): boolean => + Boolean(navigator.credentials?.create && navigator.credentials.get); + +export const preparePublicKeyCreationOptions = ( + challengeResponse: CreationChallengeResponse, +): PublicKeyCredentialCreationOptions => { + if (!challengeResponse || !challengeResponse.publicKey) { + throw new Error('Missing publicKey challenge options.'); + } + + const publicKey: PublicKeyCredentialCreationOptions = { ...challengeResponse.publicKey }; + + if (publicKey.challenge && typeof publicKey.challenge === 'string') { + publicKey.challenge = base64urlToBufferSource(publicKey.challenge); + } + + if (publicKey.user?.id) { + publicKey.user = { + ...publicKey.user, + id: typeof publicKey.user.id === 'string' + ? base64urlToBufferSource(publicKey.user.id) + : publicKey.user.id, + }; + } + + if (Array.isArray(publicKey.excludeCredentials)) { + publicKey.excludeCredentials = publicKey.excludeCredentials.map((descriptor) => ({ + ...descriptor, + id: typeof descriptor.id === 'string' + ? base64urlToBufferSource(descriptor.id) + : descriptor.id, + })); + } + + if (publicKey.authenticatorSelection?.residentKey === 'discouraged' && !publicKey.authenticatorSelection.requireResidentKey) { + delete publicKey.authenticatorSelection.requireResidentKey; + } + + return publicKey; +}; + +export const preparePublicKeyRequestOptions = ( + challengeResponse: RequestChallengeResponse, +): PublicKeyCredentialRequestOptions => { + if (!challengeResponse || !challengeResponse.publicKey) { + throw new Error('Missing publicKey request options.'); + } + + const publicKey: PublicKeyCredentialRequestOptions = { ...challengeResponse.publicKey }; + + if (publicKey.challenge && typeof publicKey.challenge === 'string') { + publicKey.challenge = base64urlToBufferSource(publicKey.challenge); + } + + if (Array.isArray(publicKey.allowCredentials)) { + publicKey.allowCredentials = publicKey.allowCredentials.map((descriptor) => ({ + ...descriptor, + id: typeof descriptor.id === 'string' + ? base64urlToBufferSource(descriptor.id) + : descriptor.id, + })); + } + + return publicKey; +}; + +export const serializeRegistrationCredential = ( + credential?: PublicKeyCredential | null, +): { + id: string; + type: PublicKeyCredential['type']; + rawId: string; + response: { + clientDataJSON: string; + attestationObject: string; + transports?: AuthenticatorTransport[]; + }; + clientExtensionResults: AuthenticationExtensionsClientOutputs; +} | null => { + if (!credential) { + return null; + } + + const response = credential.response as RegistrationCredential['response']; + const transports = response?.getTransports?.(); + + return { + id: credential.id, + type: credential.type, + rawId: arrayBufferToBase64url(credential.rawId), + response: { + clientDataJSON: arrayBufferToBase64url(response.clientDataJSON), + attestationObject: arrayBufferToBase64url(response.attestationObject), + transports: transports && transports.length + ? (Array.from(transports) as AuthenticatorTransport[]) + : undefined, + }, + clientExtensionResults: credential.getClientExtensionResults?.() || {}, + }; +}; + +export const serializeAuthenticationCredential = ( + credential?: PublicKeyCredential | null, +): { + id: string; + type: PublicKeyCredential['type']; + rawId: string; + response: { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle?: string; + }; + clientExtensionResults: AuthenticationExtensionsClientOutputs; +} | null => { + if (!credential) { + return null; + } + + const response = credential.response as AuthenticationCredential['response']; + + return { + id: credential.id, + type: credential.type, + rawId: arrayBufferToBase64url(credential.rawId), + response: { + clientDataJSON: arrayBufferToBase64url(response.clientDataJSON), + authenticatorData: arrayBufferToBase64url(response.authenticatorData), + signature: arrayBufferToBase64url(response.signature), + userHandle: response.userHandle + ? arrayBufferToBase64url(response.userHandle) + : undefined, + }, + clientExtensionResults: credential.getClientExtensionResults?.() || {}, + }; +}; diff --git a/frontend/src/viewer/DocumentViewerLayout.tsx b/frontend/src/viewer/DocumentViewerLayout.tsx new file mode 100644 index 0000000..807650f --- /dev/null +++ b/frontend/src/viewer/DocumentViewerLayout.tsx @@ -0,0 +1,121 @@ +import { useCallback, useMemo, useRef } from 'react'; +import type { JSX } from 'react'; +import DocumentInfoPanel from './components/DocumentInfoPanel'; +import UnifiedDocumentViewer from './UnifiedDocumentViewer'; +import { resolveDocumentDownloadHref } from '../documents/documentActions'; + +import type { Document } from '../types/documents'; + +interface ContentTabConfig { + id?: string; + label?: string; + enabled?: boolean; + forceDisplay?: boolean; + loadContent?: (options?: { signal?: AbortSignal }) => unknown | Promise; + loadingMessage?: string; + emptyMessage?: string; + unavailableMessage?: string; + errorMessage?: string; + [key: string]: unknown; +} + +type LayoutMode = 'split' | 'stacked' | (string & {}); + +interface DocumentViewerLayoutProps { + document?: Document | null; + summaryProps?: Record; + metadataPayload?: unknown; + contentTabConfig?: ContentTabConfig | null; + resetKey?: string | null; + classNamePrefix?: string; + defaultTabId?: string; + infoPanelProps?: Record; + previewLoadingMessage?: string; + layoutMode?: LayoutMode; +} +const DocumentViewerLayout = ({ + document, + summaryProps = {}, + metadataPayload, + contentTabConfig, + resetKey, + classNamePrefix = 'document-viewer', + defaultTabId = 'details', + infoPanelProps = {}, + previewLoadingMessage = 'Preparing preview…', + layoutMode = 'split', +}: DocumentViewerLayoutProps): JSX.Element => { + const isStacked = layoutMode === 'stacked'; + const viewportRef = useRef(null); + + const previewContent = useMemo(() => ( + + ), [document, viewportRef]); + + const renderViewportPane = useCallback(() => ( +
    + {!resolveDocumentDownloadHref(document) ? ( +
    {previewLoadingMessage}
    + ) : ( + previewContent + )} +
    + ), [previewContent, document, previewLoadingMessage, viewportRef]); + + const viewportPane = renderViewportPane(); + + const stackedLeadingTabs = useMemo(() => ( + isStacked + ? [ + { + id: 'preview', + label: 'Preview', + render: () => renderViewportPane(), + }, + ] + : [] + ), [isStacked, renderViewportPane]); + + const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId; + const summaryPlacement = 'tabs'; + const tabsPlacement = 'bottom'; + const summaryLayout = 'compact'; + + const detailsPane = ( +
    +
    + +
    +
    + ); + + if (isStacked) { + return detailsPane; + } + + return ( + <> + {detailsPane} + {viewportPane} + + ); +}; + +export default DocumentViewerLayout; diff --git a/frontend/src/viewer/DocumentViewerPanel.tsx b/frontend/src/viewer/DocumentViewerPanel.tsx new file mode 100644 index 0000000..3facf06 --- /dev/null +++ b/frontend/src/viewer/DocumentViewerPanel.tsx @@ -0,0 +1,429 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; +import type { ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + CloseIcon, + IconZoomInArea, + WindowMaximizeIcon, +} from '../components/icons'; +import { + buildCorrespondentOptions, + sortCorrespondents, +} from './components/DocumentSummarySection'; +import DocumentDownloadLink from '../documents/components/DocumentDownloadLink'; +import { useFullscreenPreviewContext } from './FullscreenPreviewContext'; +import type { DocumentSummarySectionProps } from './components/DocumentSummarySection'; +import { extractDocumentMetadataPayload } from './logic/documentSummary'; +import { resolveDocumentAssetUrl } from '../lib/assets/AssetManager'; +import PanelHeader from '../components/PanelHeader'; +import BreadcrumbTrail from '../components/BreadcrumbTrail'; +import DocumentViewerLayout from './DocumentViewerLayout'; +import { useViewerLayoutMode } from './useViewerLayoutMode'; +import { usePanelResizeBindings } from '../app/PanelManagerContext'; +import type { DocumentId, FolderId } from '../types/identifiers'; +import type { Document } from '../types/documents'; +import type { Asset } from '../types/assets'; + +type SidebarMode = 'overlay' | 'inline'; + +interface DocumentViewerPanelProps extends DocumentSummarySectionProps { + document: Document | null; + + ensureAssetUrl?: (documentId: DocumentId, asset: Asset, options?: { force?: boolean }) => Promise; + getDocumentAsset?: (doc: Document | null, type: string) => Asset | null; + ensurePreviewData?: (docId: DocumentId, options?: { signal?: AbortSignal }) => Promise; + notifyApiError?: (error: unknown, fallbackMessage?: string) => void; + sidebarToggle?: ReactNode; + onClose?: () => void; + resolveFolderPath?: (doc: Document | null) => Array<{ id?: string; name?: string }>; + variant?: 'viewer' | 'sidebar'; + onMaximize?: (args: { documentIds: Array }) => void; + sidebarMode?: SidebarMode; +} + +const createDocumentViewerHeaderActions = ({ + document, +}) => { + if (!document) { + return null; + } + + return ( + <> + + + ); +}; + +const DocumentViewerPanel: React.FC = ({ + document, + tagLookupById, + tagOptions, + onTagAdd, + onTagRemove, + correspondents, + correspondentLookupById, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + ensureAssetUrl, + getDocumentAsset, + ensurePreviewData, + sidebarToggle = null, + onClose, + resolveFolderPath, + variant = 'viewer', + onMaximize, + sidebarMode = 'overlay', +}) => { + const navigate = useNavigate(); + const isSidebarVariant = variant === 'sidebar'; + const sortedCorrespondents = useMemo( + () => sortCorrespondents(document?.correspondents || []), + [document], + ); + + const correspondentOptions = useMemo( + () => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []), + [correspondents], + ); + + const metadataPayload = useMemo( + () => extractDocumentMetadataPayload(document), + [document], + ); + + const hasOcr = useMemo(() => { + if (!document || !getDocumentAsset) { + return false; + } + return Boolean(getDocumentAsset(document, 'text-content')); + }, [document, getDocumentAsset]); + + useEffect(() => { + if (!document?.id || !ensurePreviewData) { + return; + } + const download = document.current_version?.download; + if (download?.expires_at && download.expires_at <= Date.now()) { + ensurePreviewData(document.id).catch((error) => { + console.warn('Failed to refresh expired document', error); + }); + } + }, [document, ensurePreviewData]); + + const navigateToFolder = useCallback( + (folderId: FolderId | null) => { + const target = folderId == null + ? '/documents' + : `/documents/folder/${folderId}`; + navigate(target); + }, + [navigate], + ); + + const summaryProps = useMemo( + () => ({ + tagLookupById, + tagOptions, + onTagAdd, + onTagRemove, + correspondents: sortedCorrespondents, + correspondentLookupById, + correspondentOptions, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + onFolderNavigate: navigateToFolder, + }), + [ + tagLookupById, + tagOptions, + onTagAdd, + onTagRemove, + sortedCorrespondents, + correspondentLookupById, + correspondentOptions, + onCorrespondentAdd, + onCorrespondentRemove, + onUpdateTitle, + onUpdateIssued, + navigateToFolder, + ], + ); + + const infoPanelProps = useMemo(() => ({ + tagLookupById, + correspondentLookupById, + }), [tagLookupById, correspondentLookupById]); + + const loadOcrContent = useCallback(async ({ signal }: { signal?: AbortSignal } = {}) => { + if (!document || !hasOcr || !getDocumentAsset) { + return ''; + } + + const updateUrl = () => + resolveDocumentAssetUrl(document, 'text-content', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + + const asset = getDocumentAsset(document, 'text-content'); + let url = updateUrl(); + + if (!url && document.id && asset?.id && ensureAssetUrl) { + await ensureAssetUrl(document.id, asset, { force: true }); + if (signal?.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + url = updateUrl(); + } + + if (!url) { + return ''; + } + + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'omit', + signal, + }); + + if (!response.ok) { + throw new Error(`Unexpected status: ${response.status}`); + } + + return response.text(); + }, [document, hasOcr, getDocumentAsset, ensureAssetUrl]); + + const contentTabConfig = useMemo( + () => ({ + enabled: hasOcr, + id: 'content', + label: 'Content', + loadContent: loadOcrContent, + loadingMessage: 'Loading text content…', + emptyMessage: 'No text content available.', + unavailableMessage: 'No text content available.', + errorMessage: 'Failed to load text content.', + }), + [hasOcr, loadOcrContent], + ); + + const { openFullscreenPreview } = useFullscreenPreviewContext(); + + const handleZoomOpen = useCallback(() => { + if (!document) { + return; + } + openFullscreenPreview(document); + }, [document, openFullscreenPreview]); + + const panelRef = useRef(null); + const isStackedLayout = useViewerLayoutMode(panelRef, document?.id); + + const { + panelStyle: managedDetailPanelStyle, + handleProps: managedResizeHandleProps, + isPanelResizing, + } = usePanelResizeBindings('detail', { enabled: isSidebarVariant, panelRef }); + const detailPanelStyle = isSidebarVariant ? managedDetailPanelStyle : undefined; + const resizeHandleProps = isSidebarVariant ? managedResizeHandleProps : {}; + + const viewerClassName = isStackedLayout + ? 'document-viewer document-viewer--stacked' + : 'document-viewer'; + + const breadcrumbs = useMemo(() => { + if (!document || !resolveFolderPath) { + return []; + } + const folderSegments = resolveFolderPath(document.folder_id); + const normalizedSegments = Array.isArray(folderSegments) + ? folderSegments + .filter((segment) => segment && segment.id && segment.name) + .map((segment) => ({ id: segment.id, name: segment.name })) + : []; + + return [ + ...normalizedSegments, + { id: document.id, name: document.title }, + ]; + }, [document, resolveFolderPath]); + + const breadcrumbTrailEntries = useMemo(() => { + if (!breadcrumbs.length) { + return []; + } + const lastIndex = breadcrumbs.length - 1; + return breadcrumbs.map((crumb, index) => ({ + id: crumb.id, + label: crumb.name, + onClick: index < lastIndex ? () => navigateToFolder(crumb.id) : null, + })); + }, [breadcrumbs, navigateToFolder]); + + const headerActions = createDocumentViewerHeaderActions({ + document, + }); + + const maximizeButton = isSidebarVariant && onMaximize + ? ( + + ) + : null; + + const closeButton = onClose + ? ( + + ) + : null; + + const previewZoomButton = ( + + ); + + const headerLeadingButtons = [ + sidebarToggle ? {sidebarToggle} : null, + closeButton ? {closeButton} : null, + maximizeButton ? {maximizeButton} : null, + previewZoomButton ? {previewZoomButton} : null, + ].filter(Boolean); + const headerLeadingContent = headerLeadingButtons.length ? headerLeadingButtons : null; + + const resizeHandle = isSidebarVariant ? ( + + ) : null; + + const loadingSection = ( +
    +
    +
    +
    +
    Loading document…
    +
    +
    +
    +
    Preparing preview…
    +
    +
    +
    + ); + + const viewerSection = document ? ( +
    +
    + +
    +
    + ) : loadingSection; + + const headerTitle = breadcrumbTrailEntries.length ? ( +
    + +
    + ) : ( + document?.title || 'Document preview' + ); + + if (isSidebarVariant) { + const sidebarClass = `detail-panel panel${sidebarMode === 'inline' ? ' detail-panel--inline' : ''}${isPanelResizing ? ' detail-panel--resizing' : ''}`; + return ( + <> + + + ); + } + + return ( + <> +
    + + {viewerSection} +
    + + ); +}; + +export default DocumentViewerPanel; diff --git a/frontend/src/viewer/FullscreenPreviewContext.tsx b/frontend/src/viewer/FullscreenPreviewContext.tsx new file mode 100644 index 0000000..429d5b6 --- /dev/null +++ b/frontend/src/viewer/FullscreenPreviewContext.tsx @@ -0,0 +1,63 @@ +import React, { useState, useCallback, useMemo, useRef } from 'react'; +import FullscreenPreviewOverlay from './components/FullscreenPreviewOverlay'; +import type { Document } from '../types/documents'; +import type { Identifier } from '../types/identifiers'; +import { createSafeContext } from '../utils/createSafeContext'; + +interface FullscreenPreviewContextType { + openFullscreenPreview: (doc: Document) => void; + closeFullscreenPreview: () => void; +} + +const [FullscreenPreviewContext, useFullscreenPreviewContext] = createSafeContext('FullscreenPreview'); + +interface FullscreenPreviewProviderProps { + children: React.ReactNode; + onNavigate?: (documentId: Identifier) => void; +} + +export const FullscreenPreviewProvider: React.FC = ({ children, onNavigate }) => { + const [previewDoc, setPreviewDoc] = useState(null); + const lastFocusedElement = useRef(null); + + const openFullscreenPreview = useCallback((doc: Document) => { + if (!lastFocusedElement.current) { + lastFocusedElement.current = document.activeElement as HTMLElement; + } + setPreviewDoc(doc); + }, []); + + const closeFullscreenPreview = useCallback(() => { + setPreviewDoc(null); + if (lastFocusedElement.current) { + lastFocusedElement.current.focus(); + lastFocusedElement.current = null; + } + }, []); + + const handleMaximize = useCallback(() => { + if (previewDoc && onNavigate) { + onNavigate(previewDoc.id); + closeFullscreenPreview(); + } + }, [previewDoc, onNavigate, closeFullscreenPreview]); + + const value = useMemo(() => ({ + openFullscreenPreview, + closeFullscreenPreview, + }), [openFullscreenPreview, closeFullscreenPreview]); + + return ( + + {children} + + + ); +}; + +export { useFullscreenPreviewContext }; diff --git a/frontend/src/viewer/MediaViewer.tsx b/frontend/src/viewer/MediaViewer.tsx new file mode 100644 index 0000000..2a6b9ca --- /dev/null +++ b/frontend/src/viewer/MediaViewer.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { DownloadIcon } from '../components/icons'; +import { AUDIO_EXTENSIONS, VIDEO_EXTENSIONS } from '../constants/preview'; + +interface MediaViewerProps { + src: string; + mimeType?: string; + filename?: string; + alt?: string; + className?: string; + style?: React.CSSProperties; + onLoad?: (event: React.SyntheticEvent) => void; + onClick?: (event: React.MouseEvent) => void; + mediaRef?: React.Ref; + draggable?: boolean; +} + +const getFileExtension = (filename?: string | null) => { + if (!filename) { + return ''; + } + const match = filename.toLowerCase().match(/\.([a-z0-9]+)$/); + return match ? match[1] : ''; +}; + +const MediaViewer: React.FC = ({ + src, + mimeType = '', + filename = '', + alt = 'Media preview', + className, + style, + onLoad, + onClick, + mediaRef, + draggable = false, +}) => { + const normalizedMimeType = mimeType.toLowerCase(); + const fileExtension = getFileExtension(filename); + + const isImage = normalizedMimeType.startsWith('image/'); + const isAudio = normalizedMimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(fileExtension); + const isVideo = normalizedMimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(fileExtension); + + const viewerClasses = ['document-viewer__object', className].filter(Boolean).join(' '); + + if (isImage) { + return ( + {alt} + ); + } + + if (isAudio) { + return ( +