From 51321e30cc8b5e057778d85cbe63ecf30ad552d8 Mon Sep 17 00:00:00 2001 From: Parker Close Date: Mon, 5 Oct 2026 13:24:20 -0700 Subject: [PATCH 1/5] Fixed run script --- package-lock.json | 494 ++++++++++++++++++++++++++-------------------- package.json | 8 +- scripts/dev.sh | 8 +- 3 files changed, 291 insertions(+), 219 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1a66989..c812d1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@prisma/client": "^7.10.0", + "glob": "^13.0.6", "next": "^16.3.5", "react": "^19.3.0", "react-dom": "^19.3.0" @@ -18,15 +19,16 @@ "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", + "@types/react": "19.3.0", "@vitest/coverage-v8": "^4.1.11", "dotenv": "^17.4.2", "eslint": "^9.39.5", - "eslint-config-next": "^16.3.6", + "eslint-config-next": "^16.3.8", "eslint-config-prettier": "^10.1.8", "jsdom": "^27.4.0", "prettier": "^3.9.9", "prisma": "^7.10.0", - "testcontainers": "^11.14.0", + "testcontainers": "^12.2.0", "typescript": "5.9", "vitest": "^4.1.11" }, @@ -1535,15 +1537,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", - "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.6.tgz", - "integrity": "sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz", + "integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==", "dev": true, "license": "MIT", "dependencies": { @@ -1584,9 +1586,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", - "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "cpu": [ "arm64" ], @@ -1600,9 +1602,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", - "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "cpu": [ "x64" ], @@ -1616,9 +1618,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", - "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "cpu": [ "arm64" ], @@ -1635,9 +1637,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", - "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "cpu": [ "arm64" ], @@ -1654,9 +1656,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", - "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "cpu": [ "x64" ], @@ -1673,9 +1675,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", - "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "cpu": [ "x64" ], @@ -1692,9 +1694,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", - "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "cpu": [ "arm64" ], @@ -1708,9 +1710,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", - "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "cpu": [ "x64" ], @@ -2877,17 +2879,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", - "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/type-utils": "8.70.1", - "@typescript-eslint/utils": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2900,15 +2902,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.1", + "@typescript-eslint/parser": "^8.71.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", - "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -2916,16 +2918,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", - "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3" }, "engines": { @@ -2941,14 +2943,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", - "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.1", - "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", "debug": "^4.4.3" }, "engines": { @@ -2963,14 +2965,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", - "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1" + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2981,9 +2983,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", - "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", "dev": true, "license": "MIT", "engines": { @@ -2998,15 +3000,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", - "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3023,9 +3025,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", - "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", "dev": true, "license": "MIT", "engines": { @@ -3037,16 +3039,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", - "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.1", - "@typescript-eslint/tsconfig-utils": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/visitor-keys": "8.70.1", + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3104,16 +3106,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", - "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.1", - "@typescript-eslint/types": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1" + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3128,13 +3130,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", - "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/types": "8.71.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3934,6 +3936,78 @@ "node": ">= 14" } }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4356,9 +4430,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.25", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", - "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4471,9 +4545,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", - "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -4491,10 +4565,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.23", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.427", - "node-releases": "^2.0.55", + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { @@ -4649,9 +4723,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "funding": [ { "type": "opencollective", @@ -5375,9 +5449,9 @@ } }, "node_modules/dockerode": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", - "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", + "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5386,11 +5460,10 @@ "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", - "tar-fs": "^2.1.4", - "uuid": "^10.0.0" + "tar-fs": "^2.1.4" }, "engines": { - "node": ">= 8.0" + "node": ">= 14.17" } }, "node_modules/dockerode/node_modules/readable-stream": { @@ -5505,9 +5578,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.433", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", - "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", "dev": true, "license": "ISC" }, @@ -5862,13 +5935,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.6.tgz", - "integrity": "sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz", + "integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.6", + "@next/eslint-plugin-next": "16.3.8", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -6304,9 +6377,9 @@ } }, "node_modules/exsolve": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", - "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", "devOptional": true, "license": "MIT" }, @@ -6679,13 +6752,13 @@ } }, "node_modules/get-port": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", - "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6754,22 +6827,17 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6788,27 +6856,37 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "dev": true, + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8418,7 +8496,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -8538,12 +8615,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", - "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.5", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -8557,14 +8634,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.5", - "@next/swc-darwin-x64": "16.3.5", - "@next/swc-linux-arm64-gnu": "16.3.5", - "@next/swc-linux-arm64-musl": "16.3.5", - "@next/swc-linux-x64-gnu": "16.3.5", - "@next/swc-linux-x64-musl": "16.3.5", - "@next/swc-win32-arm64-msvc": "16.3.5", - "@next/swc-win32-x64-msvc": "16.3.5", + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { @@ -8620,9 +8697,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.56", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", - "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, "license": "MIT", "engines": { @@ -8923,28 +9000,29 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/pathe": { "version": "2.0.3", @@ -10338,9 +10416,9 @@ } }, "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { @@ -10481,9 +10559,9 @@ } }, "node_modules/testcontainers": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.14.0.tgz", - "integrity": "sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg==", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.2.0.tgz", + "integrity": "sha512-Pd0c2eU0DdIuK+Pb3c+vmBUdKjDLgxZmPecQGESFAKyn5LTqSaSw8l7htfMlLwabb/QsUILe6XkxMJw8Ly/ThA==", "dev": true, "license": "MIT", "dependencies": { @@ -10493,15 +10571,18 @@ "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.4.3", - "docker-compose": "^1.4.2", - "dockerode": "^4.0.10", - "get-port": "^7.2.0", + "docker-compose": "^1.5.0", + "dockerode": "^5.0.1", + "get-port": "^5.1.1", "proper-lockfile": "^4.1.2", "properties-reader": "^3.0.1", "ssh-remote-port-forward": "^1.0.4", - "tar-fs": "^3.1.2", - "tmp": "^0.2.5", - "undici": "^7.24.5" + "tar-fs": "^3.1.3", + "tmp": "^0.2.7", + "undici": "^8.10.2" + }, + "engines": { + "node": ">= 22.22" } }, "node_modules/text-decoder": { @@ -10815,16 +10896,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", - "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.1", - "@typescript-eslint/parser": "8.70.1", - "@typescript-eslint/typescript-estree": "8.70.1", - "@typescript-eslint/utils": "8.70.1" + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10858,13 +10939,13 @@ } }, "node_modules/undici": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", - "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { @@ -10960,21 +11041,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/valibot": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", diff --git a/package.json b/package.json index 2016e72..ac6dc25 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@prisma/client": "^7.10.0", + "glob": "^13.0.6", "next": "^16.3.5", "react": "^19.3.0", "react-dom": "^19.3.0" @@ -38,24 +39,25 @@ "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", + "@types/react": "19.3.0", "@vitest/coverage-v8": "^4.1.11", "dotenv": "^17.4.2", "eslint": "^9.39.5", - "eslint-config-next": "^16.3.6", + "eslint-config-next": "^16.3.8", "eslint-config-prettier": "^10.1.8", "jsdom": "^27.4.0", "prettier": "^3.9.9", "prisma": "^7.10.0", - "testcontainers": "^11.14.0", + "testcontainers": "^12.2.0", "typescript": "5.9", "vitest": "^4.1.11" }, "allowScripts": { "cpu-features@0.0.10": true, - "prisma@7.10.0": true, "protobufjs@7.6.6": true, "ssh2@1.17.0": true, "unrs-resolver@1.12.2": true, + "prisma@7.10.0": true, "@prisma/engines@7.10.0": true } } diff --git a/scripts/dev.sh b/scripts/dev.sh index fc89d0e..af95f3d 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -6,5 +6,9 @@ cd "$project_root" npm ci npm run docker:up -exec npm run dev -npm run docker:down \ No newline at end of file + +server_status=0 +npm run dev || server_status=$? +npm run docker:down + +exit "$server_status" From da4f735d8a0cebbbe58a7d28384a9491a654959e Mon Sep 17 00:00:00 2001 From: Parker Close Date: Mon, 5 Oct 2026 14:37:16 -0700 Subject: [PATCH 2/5] Login/Register Pages used codex to setup frame for login and register pages TODO: 1 - handle form input errors to display error to user, and not clear inputs 2 - make Navbar dynamic show that login page is no longer in the navbar after logging in, instead being a logout page --- .gitignore | 1 + README.md | 6 +- app/api/auth/[...nextauth]/route.ts | 3 + app/globals.css | 102 +++++- app/layout.tsx | 2 +- app/login/actions.ts | 32 ++ app/login/page.tsx | 42 +++ app/register/actions.ts | 43 +++ app/register/page.tsx | 54 ++++ auth.ts | 41 +++ lib/auth/password.ts | 70 +++++ lib/auth/rate-limit.ts | 23 ++ lib/auth/validation.ts | 41 +++ lib/prisma.ts | 23 ++ next-env.d.ts | 4 +- package-lock.json | 296 +++++++++++++++++- package.json | 5 + .../migration.sql | 14 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 15 +- tests/unit/account-pages.test.tsx | 32 ++ tests/unit/auth-credentials.test.ts | 49 +++ 22 files changed, 890 insertions(+), 11 deletions(-) create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/login/actions.ts create mode 100644 app/login/page.tsx create mode 100644 app/register/actions.ts create mode 100644 app/register/page.tsx create mode 100644 auth.ts create mode 100644 lib/auth/password.ts create mode 100644 lib/auth/rate-limit.ts create mode 100644 lib/auth/validation.ts create mode 100644 lib/prisma.ts create mode 100644 prisma/migrations/20261005212439_add_user_accounts/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 tests/unit/account-pages.test.tsx create mode 100644 tests/unit/auth-credentials.test.ts diff --git a/.gitignore b/.gitignore index 9bb52d4..52b61b9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ coverage/ playwright-report/ test-results/ *.tsbuildinfo +generated/ # Environment and secrets .env diff --git a/README.md b/README.md index de2afe5..5dbab8f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Status -This repository contains the development foundation and initial App Router shell for a public-facing Next.js application. The root landing page, layout, and global styles are ready for feature development; authentication, database models, migrations, API routes, and browser workflows have **not** been created yet. The approved product decisions are recorded in [docs/product-spec.md](docs/product-spec.md) and [docs/implementation-plan.md](docs/implementation-plan.md). +This repository contains the development foundation and initial App Router shell for a public-facing Next.js application. Members can register and sign in with a display name, email address, and password. The approved product decisions are recorded in [docs/product-spec.md](docs/product-spec.md) and [docs/implementation-plan.md](docs/implementation-plan.md). ## Repository map @@ -21,7 +21,7 @@ This repository contains the development foundation and initial App Router shell ## Getting Started 1. Install [Git](https://git-scm.com/downloads), [Node.js 26.8.2](https://nodejs.org/en/download), and [Docker Desktop](https://www.docker.com/products/docker-desktop/) (or Docker Engine with the Compose plugin). Node 26 is the project’s active-LTS line; npm 11 is bundled with it. -2. Copy the local configuration template. It contains only safe local service credentials; replace `AUTH_SECRET` before authentication work begins. +2. Copy the local configuration template. It contains only safe local service credentials. Generate a unique `AUTH_SECRET` for your local environment with `npx auth secret`, then keep it out of version control. ```sh cp .env.example .env @@ -62,7 +62,7 @@ To install dependencies, start local services, and launch the development server ## Database migrations -Prisma is configured for PostgreSQL and reads `DATABASE_URL` from `.env`. No product schema exists yet. Once a model is approved, create and review a development migration with: +Prisma is configured for PostgreSQL and reads `DATABASE_URL` from `.env`. The initial account migration creates the `User` table with a unique email address and password hash. Once a model is approved, create and review a development migration with: ```sh npm run prisma:migrate -- --name describe_the_change diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c52987a --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '../../../../auth' + +export const { GET, POST } = handlers diff --git a/app/globals.css b/app/globals.css index 931371d..42a2656 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,7 +4,7 @@ --ink: #20251f; --muted: #5d675d; --line: #cdd5c8; - --accent: #476a45; + --accent: #3cb4bd; --accent-soft: #dfe9d9; } @@ -132,6 +132,102 @@ h3 { color: var(--muted); } +.account-page { + display: grid; + min-height: calc(90vh - 7rem); + place-items: center; + padding: 1rem 1.5rem 5rem; +} + +.account-panel { + width: min(100%, 31rem); + padding: 2rem; + border: 1px solid var(--line); + background: #fffdf8; +} + +.account-panel__back { + display: inline-block; + margin-bottom: 3rem; + color: var(--muted); + font-size: 0.875rem; +} + +.account-panel h2 { + max-width: 14ch; + margin-top: 0.5rem; + font-size: 2rem; +} + +.account-panel__intro { + margin-top: .25rem; + color: var(--muted); +} + +.account-form { + display: grid; + gap: 1.25rem; + margin-top: 2rem; +} + +.form-field { + display: grid; + gap: 0.5rem; +} + +.form-field label { + font-size: 0.875rem; + font-weight: 700; +} + +.form-field input { + width: 100%; + min-height: 2.5rem; + padding: 0.425rem 0.75rem; + border: 1px solid var(--line); + border-radius: 0; + color: var(--ink); + background: #fffdf8; + font: inherit; +} + +.form-field input:focus-visible, +.account-form button:focus-visible, +.account-panel a:focus-visible { + outline: 3px solid #236e69; + outline-offset: 3px; +} + +.form-field__hint { + color: var(--muted); + font-size: 0.875rem; +} + +.account-form button { + min-height: 2.75rem; + border: 1px solid var(--accent); + border-radius: 0; + color: #fff; + background: var(--accent); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.account-form button:hover { + background: #468a7b; +} + +.account-panel__switch { + margin-top: 1.75rem; + color: var(--muted); +} + +.account-panel__switch a { + color: var(--accent); + font-weight: 700; +} + @media (min-width: 48rem) { .hero { padding: 5rem 1.5rem; @@ -160,4 +256,8 @@ h3 { .principles__list span { grid-row: auto; } + + .account-panel { + padding: 2rem; + } } diff --git a/app/layout.tsx b/app/layout.tsx index e9505b6..dc22d59 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,7 +14,7 @@ export const metadata: Metadata = { const navLinks = [ { text: 'SideCause', url: '/' }, { text: 'Posts', url: '/' }, - { text: 'Login', url: '/' } + { text: 'Login', url: '/login' } ]; export default function RootLayout({ diff --git a/app/login/actions.ts b/app/login/actions.ts new file mode 100644 index 0000000..eefb12c --- /dev/null +++ b/app/login/actions.ts @@ -0,0 +1,32 @@ +'use server' + +import { AuthError } from 'next-auth' +import { redirect } from 'next/navigation' + +import { signIn } from '../../auth' +import { validateLogin } from '../../lib/auth/validation' + +export async function loginAction(formData: FormData) { + let credentials: { email: string; password: string } + + try { + credentials = validateLogin({ + email: String(formData.get('email') ?? ''), + password: String(formData.get('password') ?? ''), + }) + } catch (error) { + if (error instanceof Error) { + redirect('/login?error=invalid') + } + throw error + } + + try { + await signIn('credentials', { ...credentials, redirectTo: '/' }) + } catch (error) { + if (error instanceof AuthError) { + redirect('/login?error=invalid') + } + throw error + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..8d356ea --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,42 @@ +import Link from 'next/link' + +import { loginAction } from './actions' + +export default function LoginPage() { + return ( +
+
+

Welcome back!

+

Log In

+ +
+
+ + +
+
+ + +
+ +
+ +

+ New to SideCause? Create an account +

+
+
+ ) +} diff --git a/app/register/actions.ts b/app/register/actions.ts new file mode 100644 index 0000000..5b10a6d --- /dev/null +++ b/app/register/actions.ts @@ -0,0 +1,43 @@ +'use server' + +import { Prisma } from '../../generated/prisma/client' +import { redirect } from 'next/navigation' + +import { signIn } from '../../auth' +import { assertWithinAuthRateLimit } from '../../lib/auth/rate-limit' +import { hashPassword } from '../../lib/auth/password' +import { validateRegistration } from '../../lib/auth/validation' +import { prisma } from '../../lib/prisma' + +export async function registerAction(formData: FormData) { + let input: { displayName: string; email: string; password: string } + + try { + input = validateRegistration({ + displayName: String(formData.get('displayName') ?? ''), + email: String(formData.get('email') ?? ''), + password: String(formData.get('password') ?? ''), + }) + } catch { + redirect('/register?error=invalid') + } + + const { displayName, email, password } = input + assertWithinAuthRateLimit(`register:${email}`) + + try { + await prisma.user.create({ + data: { displayName, email, passwordHash: await hashPassword(password) }, + }) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + redirect('/register?error=email') + } + throw error + } + + await signIn('credentials', { email, password, redirectTo: '/' }) +} diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..b0a1ed9 --- /dev/null +++ b/app/register/page.tsx @@ -0,0 +1,54 @@ +import Link from 'next/link' + +import { registerAction } from './actions' + +export default function RegisterPage() { + return ( +
+
+

Join the cause

+

Create an Account

+ +
+
+ + +
+
+ + +
+
+ + +

Use at least 8 characters.

+
+ +
+ +

+ Already have an account? Log in +

+
+
+ ) +} diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..94e10a2 --- /dev/null +++ b/auth.ts @@ -0,0 +1,41 @@ +import NextAuth from 'next-auth' +import Credentials from 'next-auth/providers/credentials' + +import { prisma } from './lib/prisma' +import { + assertWithinAuthRateLimit, + clearAuthRateLimit, +} from './lib/auth/rate-limit' +import { verifyPassword } from './lib/auth/password' +import { validateLogin } from './lib/auth/validation' + +export const { handlers, signIn, signOut, auth } = NextAuth({ + session: { strategy: 'jwt' }, + providers: [ + Credentials({ + credentials: { + email: { type: 'email' }, + password: { type: 'password' }, + }, + async authorize(credentials) { + try { + const { email, password } = validateLogin({ + email: String(credentials.email ?? ''), + password: String(credentials.password ?? ''), + }) + assertWithinAuthRateLimit(`login:${email}`) + + const user = await prisma.user.findUnique({ where: { email } }) + if (!user || !(await verifyPassword(password, user.passwordHash))) { + return null + } + + clearAuthRateLimit(`login:${email}`) + return { id: user.id, name: user.displayName, email: user.email } + } catch { + return null + } + }, + }), + ], +}) diff --git a/lib/auth/password.ts b/lib/auth/password.ts new file mode 100644 index 0000000..cab82d2 --- /dev/null +++ b/lib/auth/password.ts @@ -0,0 +1,70 @@ +import { argon2, randomBytes, timingSafeEqual } from 'node:crypto' + +const memory = 19 * 1024 +const passes = 2 +const parallelism = 1 +const tagLength = 32 + +function derivePasswordKey(password: string, nonce: Buffer): Promise { + return new Promise((resolve, reject) => { + argon2( + 'argon2id', + { message: password, nonce, parallelism, tagLength, memory, passes }, + (error, derivedKey) => (error ? reject(error) : resolve(derivedKey)), + ) + }) +} + +export async function hashPassword(password: string): Promise { + const nonce = randomBytes(16) + const derivedKey = await derivePasswordKey(password, nonce) + + return `argon2id$${memory}$${passes}$${parallelism}$${nonce.toString('base64url')}$${derivedKey.toString('base64url')}` +} + +export async function verifyPassword( + password: string, + storedHash: string, +): Promise { + const [ + algorithm, + storedMemory, + storedPasses, + storedParallelism, + nonceValue, + keyValue, + ] = storedHash.split('$') + + if ( + algorithm !== 'argon2id' || + !storedMemory || + !storedPasses || + !storedParallelism || + !nonceValue || + !keyValue + ) { + return false + } + + const nonce = Buffer.from(nonceValue, 'base64url') + const expectedKey = Buffer.from(keyValue, 'base64url') + const actualKey = await new Promise((resolve, reject) => { + argon2( + 'argon2id', + { + message: password, + nonce, + parallelism: Number(storedParallelism), + tagLength: expectedKey.length, + memory: Number(storedMemory), + passes: Number(storedPasses), + }, + (error, derivedKey) => (error ? reject(error) : resolve(derivedKey)), + ) + }) + + return ( + actualKey.length === expectedKey.length && + timingSafeEqual(actualKey, expectedKey) + ) +} diff --git a/lib/auth/rate-limit.ts b/lib/auth/rate-limit.ts new file mode 100644 index 0000000..b33812e --- /dev/null +++ b/lib/auth/rate-limit.ts @@ -0,0 +1,23 @@ +const attempts = new Map() +const windowMs = 15 * 60 * 1000 +const maximumAttempts = 10 + +export function assertWithinAuthRateLimit(key: string) { + const now = Date.now() + const current = attempts.get(key) + + if (!current || current.resetAt <= now) { + attempts.set(key, { count: 1, resetAt: now + windowMs }) + return + } + + if (current.count >= maximumAttempts) { + throw new Error('Too many attempts. Please try again later.') + } + + current.count += 1 +} + +export function clearAuthRateLimit(key: string) { + attempts.delete(key) +} diff --git a/lib/auth/validation.ts b/lib/auth/validation.ts new file mode 100644 index 0000000..144ddda --- /dev/null +++ b/lib/auth/validation.ts @@ -0,0 +1,41 @@ +const registrationError = + 'Please enter a display name, valid email address, and password of at least 8 characters.' + +export type RegistrationInput = { + displayName: string + email: string + password: string +} + +export function validateRegistration( + input: RegistrationInput, +): RegistrationInput { + const displayName = input.displayName.trim() + const email = input.email.trim().toLowerCase() + const password = input.password + + if ( + displayName.length < 1 || + displayName.length > 80 || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || + email.length > 254 || + password.length < 8 || + password.length > 128 + ) { + throw new Error(registrationError) + } + + return { displayName, email, password } +} + +export function validateLogin( + input: Pick, +) { + const email = input.email.trim().toLowerCase() + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || input.password.length < 1) { + throw new Error('Invalid email or password.') + } + + return { email, password: input.password } +} diff --git a/lib/prisma.ts b/lib/prisma.ts new file mode 100644 index 0000000..473d1fd --- /dev/null +++ b/lib/prisma.ts @@ -0,0 +1,23 @@ +import { PrismaPg } from '@prisma/adapter-pg' + +import { PrismaClient } from '../generated/prisma/client' + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +function createPrismaClient() { + const connectionString = process.env.DATABASE_URL + + if (!connectionString) { + throw new Error('DATABASE_URL is required.') + } + + return new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) +} + +export const prisma = globalForPrisma.prisma ?? createPrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma +} diff --git a/next-env.d.ts b/next-env.d.ts index 304d663..ca6651b 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,8 +1,8 @@ /// /// /// -import './.next/dev/types/routes.d.ts' -import './.next/dev/types/root-params.d.ts' +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index c812d1b..93a7536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "sidecause", "version": "0.1.0", "dependencies": { + "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", "glob": "^13.0.6", "next": "^16.3.5", + "next-auth": "^5.0.0-beta.32", + "pg": "^8.23.1", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -19,6 +22,7 @@ "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", + "@types/pg": "^8.23.1", "@types/react": "19.3.0", "@vitest/coverage-v8": "^4.1.11", "dotenv": "^17.4.2", @@ -106,6 +110,35 @@ "dev": true, "license": "MIT" }, + "node_modules/@auth/core": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7 || ^8.0.5" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1783,6 +1816,15 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1810,6 +1852,18 @@ "node": ">=20" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", + "integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.10.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", @@ -1857,7 +1911,6 @@ "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz", "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/dev": { @@ -1891,6 +1944,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz", + "integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.0" + } + }, "node_modules/@prisma/engines": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz", @@ -2825,12 +2887,22 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.9.0" } }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -7784,6 +7856,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8667,6 +8748,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7 || ^8.0.5", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -8716,6 +8824,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9038,6 +9155,104 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9157,6 +9372,64 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10110,6 +10383,15 @@ "dev": true, "license": "ISC" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -10952,7 +11234,6 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -11589,6 +11870,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index ac6dc25..b0d03c2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "next dev", + "postinstall": "prisma generate", "build": "next build", "start": "next start", "format": "prettier --write .", @@ -28,9 +29,12 @@ "verify": "npm run format:check && npm run lint && npm run typecheck && npm run coverage" }, "dependencies": { + "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", "glob": "^13.0.6", "next": "^16.3.5", + "next-auth": "^5.0.0-beta.32", + "pg": "^8.23.1", "react": "^19.3.0", "react-dom": "^19.3.0" }, @@ -39,6 +43,7 @@ "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", + "@types/pg": "^8.23.1", "@types/react": "19.3.0", "@vitest/coverage-v8": "^4.1.11", "dotenv": "^17.4.2", diff --git a/prisma/migrations/20261005212439_add_user_accounts/migration.sql b/prisma/migrations/20261005212439_add_user_accounts/migration.sql new file mode 100644 index 0000000..0f81541 --- /dev/null +++ b/prisma/migrations/20261005212439_add_user_accounts/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a215472..1e426b2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,4 +1,17 @@ -// Product models belong to the application implementation phase. +generator client { + provider = "prisma-client" + output = "../generated/prisma" +} + datasource db { provider = "postgresql" } + +model User { + id String @id @default(cuid()) + email String @unique + displayName String + passwordHash String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/tests/unit/account-pages.test.tsx b/tests/unit/account-pages.test.tsx new file mode 100644 index 0000000..e18f599 --- /dev/null +++ b/tests/unit/account-pages.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../app/login/actions', () => ({ loginAction: vi.fn() })) +vi.mock('../../app/register/actions', () => ({ registerAction: vi.fn() })) + +import LoginPage from '../../app/login/page' +import RegisterPage from '../../app/register/page' + +afterEach(cleanup) + +describe('account page templates', () => { + it('links the login page to account creation', () => { + render() + + expect( + screen + .getByRole('link', { name: 'Create an account' }) + .getAttribute('href'), + ).toBe('/register') + }) + + it('links the account-creation page back to login', () => { + render() + + expect( + screen.getByRole('link', { name: 'Log in' }).getAttribute('href'), + ).toBe('/login') + }) +}) diff --git a/tests/unit/auth-credentials.test.ts b/tests/unit/auth-credentials.test.ts new file mode 100644 index 0000000..1e6e5f8 --- /dev/null +++ b/tests/unit/auth-credentials.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { hashPassword, verifyPassword } from '../../lib/auth/password' +import { validateRegistration } from '../../lib/auth/validation' + +describe('registration validation', () => { + it('normalizes a valid registration input', () => { + expect( + validateRegistration({ + displayName: ' Maya Chen ', + email: ' MAYA@example.com ', + password: 'correct-horse-battery-staple', + }), + ).toEqual({ + displayName: 'Maya Chen', + email: 'maya@example.com', + password: 'correct-horse-battery-staple', + }) + }) + + it('rejects malformed credentials before they reach persistence', () => { + expect(() => + validateRegistration({ + displayName: '', + email: 'not-an-email', + password: 'short', + }), + ).toThrow( + 'Please enter a display name, valid email address, and password of at least 8 characters.', + ) + }) +}) + +describe('password hashing', () => { + it('verifies the original password without storing it as plaintext', async () => { + const hash = await hashPassword('correct-horse-battery-staple') + + expect(hash).not.toContain('correct-horse-battery-staple') + await expect( + verifyPassword('correct-horse-battery-staple', hash), + ).resolves.toBe(true) + }) + + it('rejects an incorrect password', async () => { + const hash = await hashPassword('correct-horse-battery-staple') + + await expect(verifyPassword('wrong-password', hash)).resolves.toBe(false) + }) +}) From 530733547ac7ec65c2066c2776dff3dc4666fa30 Mon Sep 17 00:00:00 2001 From: Parker Close Date: Mon, 5 Oct 2026 16:19:39 -0700 Subject: [PATCH 3/5] Dynamic Navbar navbar will now change after a user logs in --- app/Navbar.css | 67 ++++++++++++++++++++++++-------------- app/Navbar.tsx | 66 ++++++++++++++++++++++++------------- app/layout.tsx | 19 ++++------- tests/unit/navbar.test.tsx | 29 +++++++++++++++++ 4 files changed, 122 insertions(+), 59 deletions(-) create mode 100644 tests/unit/navbar.test.tsx diff --git a/app/Navbar.css b/app/Navbar.css index 39a3770..aca85b9 100644 --- a/app/Navbar.css +++ b/app/Navbar.css @@ -1,25 +1,42 @@ -nav { - background-color: rgba(75, 207, 141, 0.473); - color: black; -} - -ul { - list-style-type: none; - margin: 1rem; - padding: 0; - display: flex; - justify-content: space-around; -} - -li { - padding: 15px; -} - -a { - color: black; - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} \ No newline at end of file +nav { + background-color: rgba(75, 207, 141, 0.473); + color: black; +} + +ul { + list-style-type: none; + margin: 1rem; + padding: 0; + display: flex; + justify-content: space-around; +} + +li { + padding: 15px; +} + +a { + color: black; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +nav form { + margin: 0; +} + +nav button { + padding: 0; + border: 0; + background: none; + color: inherit; + cursor: pointer; + font: inherit; +} + +nav button:hover { + text-decoration: underline; +} diff --git a/app/Navbar.tsx b/app/Navbar.tsx index a13d528..0de4baa 100644 --- a/app/Navbar.tsx +++ b/app/Navbar.tsx @@ -1,22 +1,44 @@ -import React from 'react'; -import './Navbar.css'; - -type navbarProps = { - links: { text: string; url: string }[]; -}; - -const Navbar: React.FC = ({ links }) => { - return ( - - ); -}; - -export default Navbar; +import Link from 'next/link' +import { signOut } from '../auth' + +import './Navbar.css' + +type NavbarProps = { + session: { user?: { name?: string | null } } | null +} + +export default function Navbar({ session }: NavbarProps) { + return ( + + ) +} diff --git a/app/layout.tsx b/app/layout.tsx index dc22d59..ba38dfb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,28 +3,23 @@ import type { ReactNode } from 'react' import './globals.css' import Navbar from './Navbar' +import { auth } from '../auth' export const metadata: Metadata = { title: 'SideCause', - description: - 'Complete community service projects and level up!', + description: 'Complete community service projects and level up!', } - -const navLinks = [ - { text: 'SideCause', url: '/' }, - { text: 'Posts', url: '/' }, - { text: 'Login', url: '/login' } -]; - -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: ReactNode }>) { + const session = await auth() + return ( - +
- +
{children} diff --git a/tests/unit/navbar.test.tsx b/tests/unit/navbar.test.tsx new file mode 100644 index 0000000..de01583 --- /dev/null +++ b/tests/unit/navbar.test.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../auth', () => ({ signOut: vi.fn() })) + +import Navbar from '../../app/Navbar' + +afterEach(cleanup) + +describe('Navbar', () => { + it('shows Login to signed-out visitors', () => { + render() + + expect( + screen.getByRole('link', { name: 'Login' }).getAttribute('href'), + ).toBe('/login') + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull() + }) + + it('replaces Login with the member name and Log out for signed-in members', () => { + render() + + expect(screen.getByText('Ada Lovelace')).not.toBeNull() + expect(screen.getByRole('button', { name: 'Log out' })).not.toBeNull() + expect(screen.queryByRole('link', { name: 'Login' })).toBeNull() + }) +}) From d91853c76fd907a145db252fed6a3f8484cb713b Mon Sep 17 00:00:00 2001 From: Parker Close Date: Mon, 5 Oct 2026 16:27:35 -0700 Subject: [PATCH 4/5] remove next-env.d.ts from tracking this file is generated edach time on server run, and does not need to be tracked. --- .gitignore | 1 + next-env.d.ts | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 next-env.d.ts diff --git a/.gitignore b/.gitignore index 52b61b9..c55999a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Dependencies and build output node_modules/ .next/ +next-env.d.ts out/ dist/ diff --git a/next-env.d.ts b/next-env.d.ts deleted file mode 100644 index ca6651b..0000000 --- a/next-env.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -/// -/// -/// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From c9957a88adf78319a93811b91ccf02740d0e7bdf Mon Sep 17 00:00:00 2001 From: Parker Close Date: Wed, 7 Oct 2026 12:55:17 -0700 Subject: [PATCH 5/5] Added Login/Register Forms users can create an account and login/logout --- app/globals.css | 13 ++++++ app/login/LoginForm.tsx | 49 +++++++++++++++++++++++ app/login/actions.ts | 28 +++++++++---- app/login/page.tsx | 26 +----------- app/register/RegisterForm.tsx | 66 +++++++++++++++++++++++++++++++ app/register/actions.ts | 57 ++++++++++++++++++++++---- app/register/page.tsx | 38 +----------------- lib/auth/form-state.ts | 19 +++++++++ tests/unit/account-pages.test.tsx | 64 ++++++++++++++++++++++++++++-- 9 files changed, 281 insertions(+), 79 deletions(-) create mode 100644 app/login/LoginForm.tsx create mode 100644 app/register/RegisterForm.tsx create mode 100644 lib/auth/form-state.ts diff --git a/app/globals.css b/app/globals.css index 42a2656..d95cb3d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -170,6 +170,14 @@ h3 { margin-top: 2rem; } +.form-error { + margin: 0; + padding: 0.75rem; + border-left: 4px solid #9b2c2c; + color: #6e2020; + background: #fff1f1; +} + .form-field { display: grid; gap: 0.5rem; @@ -218,6 +226,11 @@ h3 { background: #468a7b; } +.account-form button:disabled { + cursor: wait; + opacity: 0.7; +} + .account-panel__switch { margin-top: 1.75rem; color: var(--muted); diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx new file mode 100644 index 0000000..268cc7c --- /dev/null +++ b/app/login/LoginForm.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useActionState, useState } from 'react' + +import { initialLoginFormState } from '../../lib/auth/form-state' +import { loginAction } from './actions' + +export function LoginForm() { + const [state, formAction, isPending] = useActionState( + loginAction, + initialLoginFormState, + ) + const [email, setEmail] = useState(initialLoginFormState.values.email) + + return ( +
+ {state.error && ( +

+ {state.error} +

+ )} +
+ + setEmail(event.target.value)} + required + /> +
+
+ + +
+ +
+ ) +} diff --git a/app/login/actions.ts b/app/login/actions.ts index eefb12c..4c90b97 100644 --- a/app/login/actions.ts +++ b/app/login/actions.ts @@ -1,32 +1,44 @@ 'use server' import { AuthError } from 'next-auth' -import { redirect } from 'next/navigation' import { signIn } from '../../auth' +import { + initialLoginFormState, + type LoginFormState, +} from '../../lib/auth/form-state' import { validateLogin } from '../../lib/auth/validation' -export async function loginAction(formData: FormData) { +export async function loginAction( + _previousState: LoginFormState, + formData: FormData, +): Promise { + const values = { email: String(formData.get('email') ?? '').trim() } let credentials: { email: string; password: string } try { credentials = validateLogin({ - email: String(formData.get('email') ?? ''), + email: values.email, password: String(formData.get('password') ?? ''), }) - } catch (error) { - if (error instanceof Error) { - redirect('/login?error=invalid') + } catch { + return { + error: 'Enter a valid email address and password.', + values, } - throw error } try { await signIn('credentials', { ...credentials, redirectTo: '/' }) } catch (error) { if (error instanceof AuthError) { - redirect('/login?error=invalid') + return { + error: 'The email address or password is incorrect.', + values, + } } throw error } + + return initialLoginFormState } diff --git a/app/login/page.tsx b/app/login/page.tsx index 8d356ea..ed7d4a3 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' -import { loginAction } from './actions' +import { LoginForm } from './LoginForm' export default function LoginPage() { return ( @@ -9,29 +9,7 @@ export default function LoginPage() {

Welcome back!

Log In

-
-
- - -
-
- - -
- -
+

New to SideCause? Create an account diff --git a/app/register/RegisterForm.tsx b/app/register/RegisterForm.tsx new file mode 100644 index 0000000..3240009 --- /dev/null +++ b/app/register/RegisterForm.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useActionState, useState } from 'react' + +import { initialRegisterFormState } from '../../lib/auth/form-state' +import { registerAction } from './actions' + +export function RegisterForm() { + const [state, formAction, isPending] = useActionState( + registerAction, + initialRegisterFormState, + ) + const [displayName, setDisplayName] = useState( + initialRegisterFormState.values.displayName, + ) + const [email, setEmail] = useState(initialRegisterFormState.values.email) + + return ( +

+ {state.error && ( +

+ {state.error} +

+ )} +
+ + setDisplayName(event.target.value)} + required + /> +
+
+ + setEmail(event.target.value)} + required + /> +
+
+ + +

Use at least 8 characters.

+
+ +
+ ) +} diff --git a/app/register/actions.ts b/app/register/actions.ts index 5b10a6d..de0c056 100644 --- a/app/register/actions.ts +++ b/app/register/actions.ts @@ -1,29 +1,53 @@ 'use server' import { Prisma } from '../../generated/prisma/client' -import { redirect } from 'next/navigation' +import { AuthError } from 'next-auth' import { signIn } from '../../auth' +import { + initialRegisterFormState, + type RegisterFormState, +} from '../../lib/auth/form-state' import { assertWithinAuthRateLimit } from '../../lib/auth/rate-limit' import { hashPassword } from '../../lib/auth/password' import { validateRegistration } from '../../lib/auth/validation' import { prisma } from '../../lib/prisma' -export async function registerAction(formData: FormData) { +export async function registerAction( + _previousState: RegisterFormState, + formData: FormData, +): Promise { + const values = { + displayName: String(formData.get('displayName') ?? '').trim(), + email: String(formData.get('email') ?? '').trim(), + } let input: { displayName: string; email: string; password: string } try { input = validateRegistration({ - displayName: String(formData.get('displayName') ?? ''), - email: String(formData.get('email') ?? ''), + displayName: values.displayName, + email: values.email, password: String(formData.get('password') ?? ''), }) } catch { - redirect('/register?error=invalid') + return { + error: + 'Enter a display name, valid email address, and password of at least 8 characters.', + values, + } } const { displayName, email, password } = input - assertWithinAuthRateLimit(`register:${email}`) + const normalizedValues = { displayName, email } + + try { + assertWithinAuthRateLimit(`register:${email}`) + } catch { + return { + error: 'Too many account creation attempts. Please try again later.', + values: normalizedValues, + } + } try { await prisma.user.create({ @@ -34,10 +58,27 @@ export async function registerAction(formData: FormData) { error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' ) { - redirect('/register?error=email') + return { + error: + 'An account already exists with this email address. Log in instead.', + values: normalizedValues, + } + } + throw error + } + + try { + await signIn('credentials', { email, password, redirectTo: '/' }) + } catch (error) { + if (error instanceof AuthError) { + return { + error: + 'Your account was created, but we could not log you in. Please log in.', + values: normalizedValues, + } } throw error } - await signIn('credentials', { email, password, redirectTo: '/' }) + return initialRegisterFormState } diff --git a/app/register/page.tsx b/app/register/page.tsx index b0a1ed9..7e89410 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' -import { registerAction } from './actions' +import { RegisterForm } from './RegisterForm' export default function RegisterPage() { return ( @@ -9,41 +9,7 @@ export default function RegisterPage() {

Join the cause

Create an Account

-
-
- - -
-
- - -
-
- - -

Use at least 8 characters.

-
- -
+

Already have an account? Log in diff --git a/lib/auth/form-state.ts b/lib/auth/form-state.ts new file mode 100644 index 0000000..cca7dbc --- /dev/null +++ b/lib/auth/form-state.ts @@ -0,0 +1,19 @@ +export type LoginFormState = { + error: string | null + values: { email: string } +} + +export const initialLoginFormState: LoginFormState = { + error: null, + values: { email: '' }, +} + +export type RegisterFormState = { + error: string | null + values: { displayName: string; email: string } +} + +export const initialRegisterFormState: RegisterFormState = { + error: null, + values: { displayName: '', email: '' }, +} diff --git a/tests/unit/account-pages.test.tsx b/tests/unit/account-pages.test.tsx index e18f599..1806653 100644 --- a/tests/unit/account-pages.test.tsx +++ b/tests/unit/account-pages.test.tsx @@ -1,10 +1,15 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -vi.mock('../../app/login/actions', () => ({ loginAction: vi.fn() })) -vi.mock('../../app/register/actions', () => ({ registerAction: vi.fn() })) +const { loginAction, registerAction } = vi.hoisted(() => ({ + loginAction: vi.fn(), + registerAction: vi.fn(), +})) + +vi.mock('../../app/login/actions', () => ({ loginAction })) +vi.mock('../../app/register/actions', () => ({ registerAction })) import LoginPage from '../../app/login/page' import RegisterPage from '../../app/register/page' @@ -29,4 +34,57 @@ describe('account page templates', () => { screen.getByRole('link', { name: 'Log in' }).getAttribute('href'), ).toBe('/login') }) + + it('keeps the email address and explains a failed login', async () => { + loginAction.mockResolvedValueOnce({ + error: 'The email address or password is incorrect.', + values: { email: 'maya@example.com' }, + }) + render() + + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: 'maya@example.com' }, + }) + fireEvent.submit( + screen.getByRole('button', { name: 'Log in' }).form!, + ) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'The email address or password is incorrect.', + ) + expect(screen.getByLabelText('Email address').value).toBe( + 'maya@example.com', + ) + }) + + it('keeps non-sensitive registration fields and explains a duplicate email', async () => { + registerAction.mockResolvedValueOnce({ + error: + 'An account already exists with this email address. Log in instead.', + values: { displayName: 'Maya Chen', email: 'maya@example.com' }, + }) + render() + + fireEvent.change(screen.getByLabelText('Display name'), { + target: { value: 'Maya Chen' }, + }) + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: 'maya@example.com' }, + }) + fireEvent.submit( + screen.getByRole('button', { + name: 'Create account', + }).form!, + ) + + expect((await screen.findByRole('alert')).textContent).toBe( + 'An account already exists with this email address. Log in instead.', + ) + expect(screen.getByLabelText('Display name').value).toBe( + 'Maya Chen', + ) + expect(screen.getByLabelText('Email address').value).toBe( + 'maya@example.com', + ) + }) })