From 423a33200ebbab069eecfbba65e434c6f55b892f Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 27 Jul 2026 17:03:09 +0200 Subject: [PATCH] chore: improve tooling, linting, testing, and CI - Add LICENSE file (CC BY-NC-SA 4.0) - Add .nvmrc pinning Node 22 - Add Renovate config with daily schedule and Gitea Actions workflow - Reduce ESLint max-warnings from 1000 to 50 - Re-enable Biome a11y/security recommended rules - Fix Biome lint issues (a11y, hook deps, SVG labels, checkbox semantics) - Improve CI: run on pushes to feat/fix branches, add pnpm audit - Add Vitest coverage with v8 provider and thresholds - Add E2E tests (auth, admin, navigation specs) - Add admin-maintenance server action test - Install @vitest/coverage-v8 - Ignore coverage/ directory --- .gitea/workflows/ci.yaml | 8 +- .gitea/workflows/renovate.yaml | 22 + .gitignore | 3 + .nvmrc | 1 + LICENSE | 438 ++++++++++++++++++ biome.json | 5 +- e2e/admin.spec.ts | 14 + e2e/auth.spec.ts | 32 ++ e2e/navigation.spec.ts | 24 + package.json | 7 +- pnpm-lock.yaml | 121 ++++- public/sw.js | 4 +- renovate.json | 117 +++++ src/actions/admin-maintenance.test.ts | 182 ++++++++ .../import/furni/import-furni-client.tsx | 1 + src/app/client/client-view.tsx | 4 +- src/app/globals.css | 2 +- .../admin/catalog-manager/inline-editor.tsx | 1 + .../admin/catalog-manager/sortable-tree.tsx | 3 +- src/components/admin/catalog-tree.tsx | 1 - src/components/auth/home-login-form.tsx | 3 +- src/components/auth/login-form.tsx | 3 +- src/components/auth/register-form.tsx | 68 +-- src/components/pwa-register.tsx | 2 +- src/components/top-header.tsx | 2 + src/components/ui/label.tsx | 4 +- src/lib/auth.ts | 5 +- vitest.config.ts | 19 + 28 files changed, 1016 insertions(+), 80 deletions(-) create mode 100644 .gitea/workflows/renovate.yaml create mode 100644 .nvmrc create mode 100644 LICENSE create mode 100644 e2e/admin.spec.ts create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/navigation.spec.ts create mode 100644 renovate.json create mode 100644 src/actions/admin-maintenance.test.ts diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 795de59e..4379c9c6 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,5 +1,10 @@ name: CI on: + push: + branches: + - main + - "feat/**" + - "fix/**" pull_request: branches: - main @@ -8,7 +13,7 @@ jobs: check: runs-on: shell steps: - - name: Typecheck, lint, and test + - name: Typecheck, lint, security audit, and test run: | set -e WORK="$(mktemp -d /var/tmp/epicnext-ci.XXXXXX)" @@ -35,6 +40,7 @@ jobs: export BCRYPT_ROUNDS=4 pnpm install --frozen-lockfile pnpm prisma:generate + pnpm audit --audit-level=high || echo "WARNING: pnpm audit found high/critical vulnerabilities" pnpm biome:lint pnpm typecheck pnpm test diff --git a/.gitea/workflows/renovate.yaml b/.gitea/workflows/renovate.yaml new file mode 100644 index 00000000..2f37a6b6 --- /dev/null +++ b/.gitea/workflows/renovate.yaml @@ -0,0 +1,22 @@ +name: Renovate +on: + schedule: + - cron: "0 5 * * *" # every day at 05:00 + workflow_dispatch: # manual trigger + +jobs: + renovate: + runs-on: shell + steps: + - name: Self-hosted Renovate + run: | + set -e + docker run --rm \ + -e RENOVATE_TOKEN="${{ secrets.RENOVATE_TOKEN }}" \ + -e RENOVATE_AUTODISCOVER=false \ + -e RENOVATE_REPOSITORIES="${{ gitea.repository }}" \ + -e RENOVATE_ONBOARDING=false \ + -e RENOVATE_CONFIG_FILE='{"extends":["config:recommended"]}' \ + -e LOG_LEVEL=info \ + -v /var/tmp/renovate-cache:/tmp/renovate-cache \ + ghcr.io/renovatebot/renovate:latest diff --git a/.gitignore b/.gitignore index 182f7fbe..3691cfca 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ gitea # Runtime uploaded media (persistent, outside public/) storage/ + +# Test coverage reports +coverage/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..625524a6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,438 @@ +Attribution-NonCommercial-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information provided on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International +Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-NonCommercial-ShareAlike 4.0 International Public License +("Public License"). To the extent this Public License may be +interpreted as a contract, You are granted the Licensed Rights in +consideration of Your acceptance of these terms and conditions, and the +Licensor grants You such rights in consideration of benefits the +Licensor receives from making the Licensed Material available under +these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-NC-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution, NonCommercial, and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. NonCommercial means not primarily intended for or directed towards + commercial advantage or monetary compensation. For purposes of + this Public License, the exchange of the Licensed Material for + other material subject to Copyright and Similar Rights by digital + file-sharing or similar means is NonCommercial provided there is + no payment of monetary compensation in connection with the + exchange. + + l. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + m. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + n. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part, for NonCommercial purposes only; and + + b. produce, reproduce, and Share Adapted Material for + NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties, including when + the Licensed Material is used other than for NonCommercial + purposes. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-NC-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database for NonCommercial purposes + only; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + + including for purposes of Section 3(b); and + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + FULLY ALLOWED IN PART OR IN WHOLE, THIS DISCLAIMER MAY NOT APPLY + TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT FULLY ALLOWED IN + PART OR IN WHOLE, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the "Licensor." The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including +without limitation in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/biome.json b/biome.json index c24a9b21..6b7b2391 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!setup", "!public", "!*.cjs"] + "includes": ["**", "!setup", "!*.cjs", "!coverage"] }, "formatter": { "enabled": true, @@ -23,9 +23,6 @@ "performance": { "noImgElement": "off" }, - "a11y": { - "noLabelWithoutControl": "off" - }, "style": { "noDescendingSpecificity": "off" } diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts new file mode 100644 index 00000000..7197950d --- /dev/null +++ b/e2e/admin.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Admin panel", () => { + test("admin login page redirects unauthenticated users", async ({ page }) => { + await page.goto("/admin"); + await expect(page).toHaveURL(/login/); + }); + + test("admin page has login form", async ({ page }) => { + await page.goto("/admin"); + await expect(page.locator('input[name="username"]')).toBeVisible(); + await expect(page.locator('input[name="password"]')).toBeVisible(); + }); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 00000000..7c9b00b9 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Authentication flows", () => { + test("login form validates required fields", async ({ page }) => { + await page.goto("/login"); + await page.click('button[type="submit"]'); + await expect(page.locator("text=required")).toBeVisible({ timeout: 5000 }); + }); + + test("login with invalid credentials shows error", async ({ page }) => { + await page.goto("/login"); + await page.fill('input[name="username"]', "nonexistent"); + await page.fill('input[name="password"]', "wrongpassword"); + await page.click('button[type="submit"]'); + await expect(page.locator("text=invalid")).toBeVisible({ timeout: 5000 }); + }); + + test("register page has password confirmation field", async ({ page }) => { + await page.goto("/register"); + await expect(page.locator('input[name="confirmPassword"]')).toBeVisible(); + }); + + test("register form validates password match", async ({ page }) => { + await page.goto("/register"); + await page.fill('input[name="password"]', "Password123!"); + await page.fill('input[name="confirmPassword"]', "DifferentPass123!"); + await page.click('button[type="submit"]'); + await expect(page.locator("text=match|komen overeen|kloppen")).toBeVisible({ + timeout: 5000, + }); + }); +}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 00000000..1402064a --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Public navigation", () => { + test("homepage loads with expected elements", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("nav")).toBeVisible(); + await expect(page.locator("footer")).toBeVisible(); + }); + + test("community page loads", async ({ page }) => { + await page.goto("/community"); + await expect(page).toHaveTitle(/community/i); + }); + + test("shop page loads", async ({ page }) => { + await page.goto("/shop"); + await expect(page.locator("h1, h2").first()).toBeVisible(); + }); + + test("404 page for unknown routes", async ({ page }) => { + const response = await page.goto("/this-page-does-not-exist"); + expect(response?.status()).toBe(404); + }); +}); diff --git a/package.json b/package.json index 0c6a0a2d..e879620d 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "analyze": "ANALYZE=true pnpm build", "test": "vitest run", "test:e2e": "playwright test", - "lint": "eslint . --ext .ts,.tsx --max-warnings=1000", - "lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=1000", + "lint": "eslint . --ext .ts,.tsx --max-warnings=50", + "lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50", "lhci:collect": "lhci collect", "lhci:assert": "lhci assert", "lhci:server": "lhci server", @@ -32,7 +32,7 @@ "lint-staged": { "*.{js,jsx,ts,tsx}": [ "biome check --write", - "eslint --fix --max-warnings=1000" + "eslint --fix --max-warnings=50" ], "*.{json,md,css,scss,html}": [ "biome format --write" @@ -98,6 +98,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/sanitize-html": "^2.16.1", + "@vitest/coverage-v8": "^4.1.10", "dotenv": "^17.0.0", "eslint": "^10.7.0", "eslint-plugin-unused-imports": "^4.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96bb25d7..c2359fbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,7 +33,7 @@ importers: version: 7.9.0 '@prisma/client': specifier: ^7.9.0 - version: 7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) + version: 7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) '@sentry/nextjs': specifier: ^10.67.0 version: 10.67.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(webpack@5.108.4(esbuild@0.28.1)(postcss@8.5.21)) @@ -185,6 +185,9 @@ importers: '@types/sanitize-html': specifier: ^2.16.1 version: 2.16.1 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) dotenv: specifier: ^17.0.0 version: 17.4.2 @@ -208,7 +211,7 @@ importers: version: 8.5.21 prisma: specifier: ^7.9.0 - version: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + version: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -226,7 +229,7 @@ importers: version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) packages: @@ -357,6 +360,10 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.5.5': resolution: {integrity: sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==} engines: {node: '>=14.21.3'} @@ -2697,6 +2704,15 @@ packages: '@visx/vendor@4.0.0-alpha.0': resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -2874,6 +2890,9 @@ packages: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -3933,6 +3952,18 @@ packages: isomorphic-fetch@3.0.0: resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -3955,6 +3986,9 @@ packages: resolution: {integrity: sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA==} engines: {node: '>=12'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4170,10 +4204,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + mariadb@3.4.5: resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==} engines: {node: '>= 14'} @@ -5113,6 +5154,10 @@ packages: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -5740,6 +5785,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.5.5': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.5 @@ -6627,16 +6674,16 @@ snapshots: '@prisma/client-runtime-utils@7.9.0': {} - '@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@prisma/client-runtime-utils': 7.9.0 optionalDependencies: - prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + prisma: 7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) typescript: 5.9.3 - '@prisma/config@7.9.0': + '@prisma/config@7.9.0(magicast@0.5.3)': dependencies: - c12: 3.3.4 + c12: 3.3.4(magicast@0.5.3) deepmerge-ts: 7.1.5 effect: 3.20.0 empathic: 2.0.0 @@ -7736,6 +7783,20 @@ snapshots: d3-time-format: 4.1.0 internmap: 2.0.3 + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -7941,6 +8002,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astring@1.9.0: {} atomic-sleep@1.0.0: {} @@ -8040,7 +8107,7 @@ snapshots: bytes@3.1.2: {} - c12@3.3.4: + c12@3.3.4(magicast@0.5.3): dependencies: chokidar: 5.0.0 confbox: 0.2.4 @@ -8054,6 +8121,8 @@ snapshots: perfect-debounce: 2.1.0 pkg-types: 2.3.1 rc9: 3.0.1 + optionalDependencies: + magicast: 0.5.3 call-bind-apply-helpers@1.0.2: dependencies: @@ -9027,6 +9096,19 @@ snapshots: transitivePeerDependencies: - encoding + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jest-worker@27.5.1: dependencies: '@types/node': 26.1.1 @@ -9043,6 +9125,8 @@ snapshots: js-library-detector@6.7.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@3.15.0: @@ -9259,10 +9343,20 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + make-dir@3.1.0: dependencies: semver: 6.3.1 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + mariadb@3.4.5: dependencies: '@types/geojson': 7946.0.16 @@ -9725,9 +9819,9 @@ snapshots: prelude-ls@1.2.1: {} - prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(magicast@0.5.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: - '@prisma/config': 7.9.0 + '@prisma/config': 7.9.0(magicast@0.5.3) '@prisma/dev': 0.24.14(typescript@5.9.3) '@prisma/engines': 7.9.0 '@prisma/studio-core': 0.33.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -10294,6 +10388,10 @@ snapshots: dependencies: has-flag: 3.0.0 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@8.1.1: dependencies: has-flag: 4.0.0 @@ -10523,7 +10621,7 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -10548,6 +10646,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 26.1.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/public/sw.js b/public/sw.js index 7ec6eaa6..b747c319 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,9 @@ self.addEventListener("activate", (event) => { .keys() .then((keys) => Promise.all( - keys.filter((k) => k !== CACHE && k !== API_CACHE).map((k) => caches.delete(k)), + keys + .filter((k) => k !== CACHE && k !== API_CACHE) + .map((k) => caches.delete(k)), ), ) .then(() => self.clients.claim()), diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..8de40887 --- /dev/null +++ b/renovate.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":separateMajorMinor", + ":pinAllExceptPeerDependencies", + "helpers:disableTypesNodeMajor" + ], + "labels": ["dependencies"], + "rangeStrategy": "bump", + "dependencyDashboard": true, + "dependencyDashboardTitle": "Dependency Dashboard", + "commitMessagePrefix": "chore(deps):", + "commitMessageAction": "update", + "schedule": ["before 6am every day"], + "timezone": "Europe/Amsterdam", + "rebaseWhen": "conflicted", + "prCreation": "immediate", + "prConcurrentLimit": 5, + "packageRules": [ + { + "description": "Group Next.js core and tooling", + "matchPackageNames": ["next", "@next/bundle-analyzer", "next-auth", "next-intl", "next-view-transitions"], + "groupName": "Next.js", + "groupSlug": "nextjs" + }, + { + "description": "Group Prisma packages", + "matchPackageNames": ["prisma", "@prisma/client", "@prisma/adapter-mariadb"], + "groupName": "Prisma", + "groupSlug": "prisma" + }, + { + "description": "Group React core and types", + "matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"], + "groupName": "React", + "groupSlug": "react" + }, + { + "description": "Group Tailwind CSS packages", + "matchPackageNames": ["tailwindcss", "@tailwindcss/postcss", "@tailwindcss/forms", "@tailwindcss/typography", "tailwindcss-animate", "tailwind-merge"], + "groupName": "Tailwind CSS", + "groupSlug": "tailwind" + }, + { + "description": "Group DnD kit packages", + "matchPackageNames": ["@dnd-kit/core", "@dnd-kit/sortable", "@dnd-kit/utilities"], + "groupName": "DnD Kit", + "groupSlug": "dnd-kit" + }, + { + "description": "Group Biome with ESLint tooling", + "matchPackageNames": ["@biomejs/biome", "eslint", "@eslint/js", "eslint-plugin-unused-imports", "typescript-eslint"], + "groupName": "Linting", + "groupSlug": "linting" + }, + { + "description": "Group TypeScript and types", + "matchPackageNames": ["typescript", "typescript-eslint"], + "groupName": "TypeScript", + "groupSlug": "typescript" + }, + { + "description": "Group Vitest and testing packages", + "matchPackageNames": ["vitest", "@vitest/coverage-v8", "vite", "@playwright/test"], + "groupName": "Testing", + "groupSlug": "testing" + }, + { + "description": "Group Sentry packages", + "matchPackageNames": ["@sentry/nextjs"], + "groupName": "Sentry", + "groupSlug": "sentry" + }, + { + "description": "Group hashing/crypto packages", + "matchPackageNames": ["bcrypt", "@types/bcrypt", "hash-wasm", "otplib"], + "groupName": "Cryptography", + "groupSlug": "crypto" + }, + { + "description": "Group hook form packages", + "matchPackageNames": ["react-hook-form", "@hookform/resolvers"], + "groupName": "React Hook Form", + "groupSlug": "react-hook-form" + }, + { + "description": "Automerge minor and patch updates for production deps (already >=1.0.0)", + "matchUpdateTypes": ["minor", "patch"], + "matchCurrentVersion": ">=1.0.0", + "automerge": true + }, + { + "description": "Automerge all devDependency updates", + "matchDepTypes": ["devDependencies"], + "automerge": true + }, + { + "description": "Major updates need manual review", + "matchUpdateTypes": ["major"], + "labels": ["dependencies", "major"], + "automerge": false, + "assignees": [], + "reviewers": [] + }, + { + "description": "Disable updates for engine pins", + "matchPackageNames": ["node", "pnpm"], + "enabled": false + }, + { + "description": "Disable server-only (abandoned, pinned at 0.0.1)", + "matchPackageNames": ["server-only"], + "enabled": false + } + ] +} diff --git a/src/actions/admin-maintenance.test.ts b/src/actions/admin-maintenance.test.ts new file mode 100644 index 00000000..7c21e59e --- /dev/null +++ b/src/actions/admin-maintenance.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } = + vi.hoisted(() => ({ + mockUpsert: vi.fn(), + mockRequirePermission: vi.fn(), + mockReload: vi.fn(), + mockRevalidatePath: vi.fn(), + })); + +vi.mock("@/lib/permissions", () => ({ + PERMS: { + ADMIN_DASHBOARD: "admin.dashboard", + SETTINGS_VIEW: "admin.settings.view", + SETTINGS_EDIT: "admin.settings.edit", + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + websiteSetting: { upsert: mockUpsert }, + }, +})); + +vi.mock("@/lib/admin/guard", () => ({ + requirePermission: mockRequirePermission, +})); + +vi.mock("@/lib/services/site-settings", () => ({ + siteSettings: { reload: mockReload }, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: mockRevalidatePath, +})); + +import { saveMaintenance } from "./admin-maintenance"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("saveMaintenance", () => { + it("enables maintenance mode with message and min rank", async () => { + mockRequirePermission.mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + }); + + const fd = new FormData(); + fd.set("enabled", "on"); + fd.set("message", "We will be back soon!"); + fd.set("min_rank", "3"); + + await saveMaintenance(fd); + + expect(mockRequirePermission).toHaveBeenCalled(); + + expect(mockUpsert).toHaveBeenCalledTimes(3); + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: "maintenance_enabled" }, + update: { value: "1" }, + create: expect.objectContaining({ + key: "maintenance_enabled", + value: "1", + }), + }), + ); + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: "maintenance_message" }, + update: { value: "We will be back soon!" }, + create: expect.objectContaining({ + key: "maintenance_message", + value: "We will be back soon!", + }), + }), + ); + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: "min_maintenance_login_rank" }, + update: { value: "3" }, + create: expect.objectContaining({ + key: "min_maintenance_login_rank", + value: "3", + }), + }), + ); + + expect(mockReload).toHaveBeenCalledOnce(); + expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance"); + }); + + it("disables maintenance mode", async () => { + mockRequirePermission.mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + }); + + const fd = new FormData(); + fd.set("message", ""); + fd.set("min_rank", ""); + + await saveMaintenance(fd); + + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: "maintenance_enabled" }, + update: { value: "0" }, + }), + ); + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: "min_maintenance_login_rank" }, + update: { value: "5" }, + }), + ); + }); + + it("defaults min_rank to 5 when input is empty", async () => { + mockRequirePermission.mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + }); + + const fd = new FormData(); + fd.set("enabled", "on"); + fd.set("message", ""); + fd.set("min_rank", ""); + + await saveMaintenance(fd); + + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: "min_maintenance_login_rank" }, + update: { value: "5" }, + }), + ); + }); + + it("defaults min_rank to 5 when input is negative", async () => { + mockRequirePermission.mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + }); + + const fd = new FormData(); + fd.set("enabled", "on"); + fd.set("message", ""); + fd.set("min_rank", "-1"); + + await saveMaintenance(fd); + + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { key: "min_maintenance_login_rank" }, + update: { value: "5" }, + }), + ); + }); + + it("calls requirePermission with SETTINGS_EDIT", async () => { + mockRequirePermission.mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + }); + + const fd = new FormData(); + fd.set("message", ""); + fd.set("min_rank", ""); + + await saveMaintenance(fd); + + expect(mockRequirePermission).toHaveBeenCalledWith("admin.settings.edit"); + }); +}); diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx index 8b66ec15..f5e55a8b 100644 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ b/src/app/admin/import/furni/import-furni-client.tsx @@ -262,6 +262,7 @@ export function ImportFurniClient() { } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); + // biome-ignore lint/correctness/useExhaustiveDependencies: toggleSelectAll is a state setter, stable }, [toggleSelectAll]); // Derived: unique categories from loaded items diff --git a/src/app/client/client-view.tsx b/src/app/client/client-view.tsx index 54dd1246..deb72da9 100644 --- a/src/app/client/client-view.tsx +++ b/src/app/client/client-view.tsx @@ -220,11 +220,13 @@ export function ClientView({ window.removeEventListener("touchmove", onTouchMove); window.removeEventListener("touchend", onEnd); }; - }, [dragging]); + // biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a useCallback used intentionally here + }, [dragging, snapPos]); return ( <> {pos ? ( + // biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
summary .details-open\:rotate-180 { /* Input focus glow */ .input-glow:focus { outline: none; - border-color: var(--color-primary) !important; + border-color: var(--color-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent), 0 0 20px color-mix(in srgb, var(--color-primary) 10%, transparent); diff --git a/src/components/admin/catalog-manager/inline-editor.tsx b/src/components/admin/catalog-manager/inline-editor.tsx index 371cb9fe..d95efb69 100644 --- a/src/components/admin/catalog-manager/inline-editor.tsx +++ b/src/components/admin/catalog-manager/inline-editor.tsx @@ -144,6 +144,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) { } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); + // biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent }, [canEdit, isDirty, saving, page, handleSave]); const loadPage = useCallback( diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index 5f273f1c..b46ba78e 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -187,7 +187,7 @@ export function SortableTree({ setFilter("all"); dispatch({ type: "COLLAPSE_ALL" }); void loadTabTree(); - }, [activeTabId, loadTabTree]); + }, [activeTabId, loadTabTree, dispatch]); // Debounced search useEffect(() => { @@ -325,7 +325,6 @@ export function SortableTree({ // ── Keyboard navigation ───────────────────────────────────── const handleDeleteRef = useRef<(id: number) => Promise>(async () => {}); - // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { function onKeyDown(e: KeyboardEvent) { const tag = (e.target as HTMLElement)?.tagName; diff --git a/src/components/admin/catalog-tree.tsx b/src/components/admin/catalog-tree.tsx index ff4e1278..15f7adc3 100644 --- a/src/components/admin/catalog-tree.tsx +++ b/src/components/admin/catalog-tree.tsx @@ -200,7 +200,6 @@ export function CatalogTree({ }, []); // Debounced search - // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { if (!searchQuery.trim()) { setSearchResults(null); diff --git a/src/components/auth/home-login-form.tsx b/src/components/auth/home-login-form.tsx index 1f439dd5..556a932e 100644 --- a/src/components/auth/home-login-form.tsx +++ b/src/components/auth/home-login-form.tsx @@ -155,7 +155,6 @@ export function HomeLoginForm({ placeholder="Enter your 2FA code" inputMode="numeric" autoComplete="one-time-code" - autoFocus className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none" style={{ backgroundColor: "var(--color-background)", @@ -195,6 +194,8 @@ export function HomeLoginForm({ className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none" + role="img" + aria-label="Loading" >
- setTermsAccepted(e.target.checked)} + className="w-5 h-5 rounded border-2 accent-(--color-primary) shrink-0 cursor-pointer" /> - setTermsAccepted(!termsAccepted)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setTermsAccepted(!termsAccepted); } }} - className="font-semibold cursor-pointer select-none" - style={{ color: "var(--color-text-readable)" }} - onClick={() => setTermsAccepted(!termsAccepted)} > {t("termsAccept", { hotel: hotelName })} - +
@@ -372,11 +338,13 @@ export function RegisterForm({ > {isPending ? ( - + {}); + navigator.serviceWorker.register(`/sw.js?${SW_VERSION}`).catch(() => {}); }, []); return null; } diff --git a/src/components/top-header.tsx b/src/components/top-header.tsx index 7ad13fe1..2268bcb6 100644 --- a/src/components/top-header.tsx +++ b/src/components/top-header.tsx @@ -220,6 +220,8 @@ export async function TopHeader({ session }: { session: Session | null }) { strokeLinecap="round" strokeLinejoin="round" style={{ color: textColor }} + role="img" + aria-label="Notifications" > diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx index d8bdc027..5c5cd6cf 100644 --- a/src/components/ui/label.tsx +++ b/src/components/ui/label.tsx @@ -4,10 +4,12 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; -function Label({ className, ...props }: React.ComponentProps<"label">) { +function Label({ className, htmlFor, ...props }: React.ComponentProps<"label">) { return ( + // biome-ignore lint/a11y/noLabelWithoutControl: reusable component, input is associated via htmlFor at usage site