diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5d9c700 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck server + run: npm run typecheck + + - name: Run tests + run: npm run test:run + + - name: Build frontend + run: npm run build + + - name: Lint (informational - repo has pre-existing lint issues) + run: npm run lint + continue-on-error: true + + - name: Typecheck frontend (informational - unverified) + run: npx tsc --noEmit -p tsconfig.app.json + continue-on-error: true + + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t clientflow . diff --git a/Dockerfile b/Dockerfile index dc342e0..cc45d1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,8 +43,13 @@ EXPOSE 3001 ENV NODE_ENV=production ENV API_PORT=3001 ENV DATABASE_PATH=/app/data/app.db -ENV JWT_SECRET=change-me-in-production -ENV CORS_ORIGIN=* +# JWT_SECRET is REQUIRED at runtime - the operator must supply a strong, +# unique value (e.g. `docker run -e JWT_SECRET=...`). There is no baked-in +# default: the server refuses to start in production without one (see +# server/index.ts). Do not set a placeholder value here. +# CORS_ORIGIN must be supplied by the operator (comma-separated list of +# trusted origins). It intentionally has no default here - defaulting to +# "*" would allow requests from any origin. # Start the server using tsx CMD ["npx", "tsx", "server/index.ts"] diff --git a/package-lock.json b/package-lock.json index 2407071..b94647f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", "express": "^5.2.1", + "express-rate-limit": "^7.5.1", "input-otp": "^1.4.2", "jsonwebtoken": "^9.0.3", "lucide-react": "^0.462.0", @@ -81,6 +82,7 @@ "@types/node": "^22.16.5", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", + "@types/supertest": "^6.0.3", "@types/uuid": "^10.0.0", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", @@ -90,11 +92,13 @@ "globals": "^15.15.0", "lovable-tagger": "^1.1.13", "postcss": "^8.5.6", + "supertest": "^7.1.4", "tailwindcss": "^3.4.17", "tsx": "^4.21.0", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", - "vite": "^5.4.19" + "vite": "^5.4.19", + "vitest": "^2.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -1661,9 +1665,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1676,6 +1680,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1711,6 +1728,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4294,6 +4321,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -4424,6 +4458,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -4529,6 +4570,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -4817,6 +4882,119 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -4947,6 +5125,30 @@ "node": ">=10" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -5208,6 +5410,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -5277,6 +5489,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5293,6 +5522,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5461,6 +5700,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -5470,6 +5722,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5571,6 +5833,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -5788,6 +6057,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -5804,6 +6083,16 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5828,6 +6117,17 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5962,6 +6262,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5974,6 +6281,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -6209,6 +6532,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6243,6 +6576,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -6286,6 +6629,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6344,6 +6702,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-parser": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", @@ -6477,6 +6842,64 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6721,10 +7144,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7190,6 +7629,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lovable-tagger": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/lovable-tagger/-/lovable-tagger-1.1.13.tgz", @@ -7650,6 +8096,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7689,6 +8145,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7702,6 +8168,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -8143,6 +8622,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9104,6 +9600,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9180,6 +9683,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9189,6 +9699,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -9349,6 +9866,42 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9484,6 +10037,50 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10340,6 +10937,95 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10355,6 +11041,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index cc594cd..9e7e132 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "lint": "eslint .", "preview": "vite preview", "server": "tsx server/index.ts", - "db:reset": "rm -f data/app.db && npm run server" + "db:reset": "rm -f data/app.db && npm run server", + "test": "vitest", + "test:run": "vitest run", + "typecheck": "tsc --noEmit -p tsconfig.server.json" }, "dependencies": { "@hookform/resolvers": "^3.10.0", @@ -56,6 +59,7 @@ "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", "express": "^5.2.1", + "express-rate-limit": "^7.5.1", "input-otp": "^1.4.2", "jsonwebtoken": "^9.0.3", "lucide-react": "^0.462.0", @@ -88,6 +92,7 @@ "@types/node": "^22.16.5", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", + "@types/supertest": "^6.0.3", "@types/uuid": "^10.0.0", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", @@ -97,10 +102,12 @@ "globals": "^15.15.0", "lovable-tagger": "^1.1.13", "postcss": "^8.5.6", + "supertest": "^7.1.4", "tailwindcss": "^3.4.17", "tsx": "^4.21.0", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", - "vite": "^5.4.19" + "vite": "^5.4.19", + "vitest": "^2.1.9" } } diff --git a/server/__tests__/auth.test.ts b/server/__tests__/auth.test.ts new file mode 100644 index 0000000..da53d05 --- /dev/null +++ b/server/__tests__/auth.test.ts @@ -0,0 +1,146 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login, SEEDED_ADMIN_EMAIL, SEEDED_ADMIN_PASSWORD } from './helpers.js'; + +let app: Express; + +beforeAll(async () => { + app = await createTestApp(); +}); + +describe('auth', () => { + it('logs in the seeded admin with the correct password', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ email: SEEDED_ADMIN_EMAIL, password: SEEDED_ADMIN_PASSWORD }); + + expect(res.status).toBe(200); + expect(res.body.token).toBeTruthy(); + expect(res.body.user?.email).toBe(SEEDED_ADMIN_EMAIL); + }); + + it('rejects login with the wrong password', async () => { + const res = await request(app) + .post('/api/auth/login') + .send({ email: SEEDED_ADMIN_EMAIL, password: 'definitely-wrong-password' }); + + expect(res.status).toBe(401); + }); + + it('rejects POST /api/auth/users without auth', async () => { + const res = await request(app) + .post('/api/auth/users') + .send({ email: 'no-auth@example.com', full_name: 'No Auth' }); + + expect(res.status).toBe(401); + }); + + it('lets an admin create a user without a password and returns an invite_token', async () => { + const token = await login(app); + + const res = await request(app) + .post('/api/auth/users') + .set('Authorization', `Bearer ${token}`) + .send({ email: 'invitee@example.com', full_name: 'Invitee' }); + + expect(res.status).toBe(200); + expect(res.body.user?.email).toBe('invitee@example.com'); + expect(res.body.invite_token).toBeTruthy(); + }); + + it('refuses login for an invited-but-not-activated user, regardless of password (no passwordless takeover)', async () => { + const token = await login(app); + + await request(app) + .post('/api/auth/users') + .set('Authorization', `Bearer ${token}`) + .send({ email: 'not-activated@example.com', full_name: 'Not Activated' }); + + const res = await request(app) + .post('/api/auth/login') + .send({ email: 'not-activated@example.com', password: 'any-password-at-all' }); + + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/not activated/i); + }); + + it('accepts a valid invite token and returns a session token', async () => { + const token = await login(app); + + const createRes = await request(app) + .post('/api/auth/users') + .set('Authorization', `Bearer ${token}`) + .send({ email: 'accepter@example.com', full_name: 'Accepter' }); + + const inviteToken = createRes.body.invite_token; + expect(inviteToken).toBeTruthy(); + + const acceptRes = await request(app) + .post('/api/auth/accept-invite') + .send({ token: inviteToken, password: 'chosen-password-1' }); + + expect(acceptRes.status).toBe(200); + expect(acceptRes.body.token).toBeTruthy(); + }); + + // Regression guard for a real bug: the accept-invite (and change-password) + // handlers originally ran `UPDATE ... updated_at = datetime("now") ...` with a + // double-quoted "now". better-sqlite3 builds SQLite with SQLITE_DQS=0, so a + // double-quoted literal is parsed as an identifier ("no such column: now") and + // the whole statement throws; execute() swallowed the error, so the password + // was never persisted and login afterward always failed with "not activated". + // Fixed by using single-quoted datetime('now'). This test proves the full + // invite -> accept -> login round-trip works. + it('lets a user log in with the password chosen via accept-invite', async () => { + const token = await login(app); + + const createRes = await request(app) + .post('/api/auth/users') + .set('Authorization', `Bearer ${token}`) + .send({ email: 'accepter2@example.com', full_name: 'Accepter Two' }); + const inviteToken = createRes.body.invite_token; + + await request(app) + .post('/api/auth/accept-invite') + .send({ token: inviteToken, password: 'chosen-password-1' }); + + const loginRes = await request(app) + .post('/api/auth/login') + .send({ email: 'accepter2@example.com', password: 'chosen-password-1' }); + + expect(loginRes.status).toBe(200); + expect(loginRes.body.token).toBeTruthy(); + }); + + it('makes the invite token single-use (cannot be redeemed twice)', async () => { + const token = await login(app); + + const createRes = await request(app) + .post('/api/auth/users') + .set('Authorization', `Bearer ${token}`) + .send({ email: 'accepter3@example.com', full_name: 'Accepter Three' }); + const inviteToken = createRes.body.invite_token; + + const first = await request(app) + .post('/api/auth/accept-invite') + .send({ token: inviteToken, password: 'chosen-password-1' }); + expect(first.status).toBe(200); + + const second = await request(app) + .post('/api/auth/accept-invite') + .send({ token: inviteToken, password: 'another-password-2' }); + expect(second.status).toBe(400); + }); + + it('rejects accept-invite with a bogus token', async () => { + const res = await request(app) + .post('/api/auth/accept-invite') + .send({ token: 'this-token-does-not-exist', password: 'whatever-1' }); + + expect(res.status).toBe(400); + }); +}); diff --git a/server/__tests__/helpers.ts b/server/__tests__/helpers.ts new file mode 100644 index 0000000..12808f4 --- /dev/null +++ b/server/__tests__/helpers.ts @@ -0,0 +1,53 @@ +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import request from 'supertest'; +import type { Express } from 'express'; + +export const SEEDED_ADMIN_EMAIL = 'admin@localhost'; +export const SEEDED_ADMIN_PASSWORD = 'admin123'; + +/** + * Creates a unique, isolated environment (temp DATABASE_PATH + UPLOAD_DIR) + * and dynamically imports the Express app so that `initializeDatabase()` + * runs against the freshly-set env vars. The dynamic import must happen + * AFTER the env vars are assigned, which is why the import lives inside + * this function rather than as a static top-level import. + * + * The database is a singleton keyed off DATABASE_PATH at first + * `getDatabase()` call, so every test file must call this (once, in its + * own `beforeAll`) with its own random temp dir to avoid cross-file state + * leakage. + */ +export async function createTestApp(): Promise { + const tempDir = path.join( + os.tmpdir(), + `cff-test-${process.pid}-${crypto.randomBytes(6).toString('hex')}`, + ); + fs.mkdirSync(tempDir, { recursive: true }); + + process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = 'test-secret-key'; + process.env.DATABASE_PATH = path.join(tempDir, 'test.db'); + process.env.UPLOAD_DIR = path.join(tempDir, 'uploads'); + + const { default: app } = await import('../index.js'); + return app; +} + +/** + * Logs in (defaults to the seeded admin) and returns the bearer token. + * Throws if the login does not succeed, so test setup fails loudly. + */ +export async function login( + app: Express, + email: string = SEEDED_ADMIN_EMAIL, + password: string = SEEDED_ADMIN_PASSWORD, +): Promise { + const res = await request(app).post('/api/auth/login').send({ email, password }); + if (res.status !== 200 || !res.body?.token) { + throw new Error(`login() failed for ${email}: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body.token as string; +} diff --git a/server/__tests__/numbering.test.ts b/server/__tests__/numbering.test.ts new file mode 100644 index 0000000..5c32466 --- /dev/null +++ b/server/__tests__/numbering.test.ts @@ -0,0 +1,183 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login } from './helpers.js'; +import type { allocateInvoiceNumber as AllocateInvoiceNumberFn } from '../utils/numbering.js'; +import type { queryOne as QueryOneFn } from '../db/database.js'; + +let app: Express; +let adminToken: string; +let clientId: string; +let allocateInvoiceNumber: typeof AllocateInvoiceNumberFn; +let queryOne: typeof QueryOneFn; + +function numericSuffix(value: string): number { + return parseInt(value.replace(/\D/g, ''), 10); +} + +beforeAll(async () => { + // createTestApp() sets DATABASE_PATH and dynamically imports the app so that + // `initializeDatabase()` runs against a fresh temp DB. server/db/database.ts + // freezes its DB_PATH constant at module-evaluation time, so ../db/database.js + // (and anything that imports it, like ../utils/numbering.js) must ALSO be + // imported dynamically, and only after createTestApp() has run - a static + // top-level `import` here would be hoisted ahead of the env var assignment + // and permanently bind to the wrong (non-temp) database path. + app = await createTestApp(); + ({ allocateInvoiceNumber } = await import('../utils/numbering.js')); + ({ queryOne } = await import('../db/database.js')); + + adminToken = await login(app); + + const clientRes = await request(app) + .post('/api/clients') + .set('Authorization', `Bearer ${adminToken}`) + .send({ name: 'Numbering Test Client' }); + expect(clientRes.status).toBe(201); + clientId = clientRes.body.id; +}); + +describe('invoice/job numbering', () => { + it('allocateInvoiceNumber() returns sequential numbers and advances invoice_next_number', () => { + const before = queryOne<{ invoice_next_number: number }>( + 'SELECT invoice_next_number FROM company_settings LIMIT 1' + ).data; + expect(before).toBeTruthy(); + + const first = allocateInvoiceNumber(); + const second = allocateInvoiceNumber(); + + expect(first).not.toBe(second); + expect(numericSuffix(second)).toBe(numericSuffix(first) + 1); + + const after = queryOne<{ invoice_next_number: number }>( + 'SELECT invoice_next_number FROM company_settings LIMIT 1' + ).data; + expect(after!.invoice_next_number).toBe(before!.invoice_next_number + 2); + }); + + it('does not reuse an invoice number after the invoice using it is deleted', async () => { + const numA = allocateInvoiceNumber(); + + const createRes = await request(app) + .post('/api/invoices') + .set('Authorization', `Bearer ${adminToken}`) + .send({ invoice_number: numA, client_id: clientId, due_date: '2026-08-01' }); + expect(createRes.status).toBe(201); + const invoiceId = createRes.body.id; + + const deleteRes = await request(app) + .delete(`/api/invoices/${invoiceId}`) + .set('Authorization', `Bearer ${adminToken}`); + expect(deleteRes.status).toBe(204); + + // With the old COUNT(*)-based scheme, deleting the only invoice would drop + // COUNT(*) back down and the "next" number would collide with numA. The + // counter-based allocator must not be affected by row deletions at all. + const numB = allocateInvoiceNumber(); + expect(numB).not.toBe(numA); + expect(numericSuffix(numB)).toBeGreaterThan(numericSuffix(numA)); + }); + + it('POST /api/functions/allocate-invoice-number returns distinct numbers across calls', async () => { + const res1 = await request(app) + .post('/api/functions/allocate-invoice-number') + .set('Authorization', `Bearer ${adminToken}`) + .send({}); + expect(res1.status).toBe(200); + expect(res1.body.invoice_number).toBeTruthy(); + + const res2 = await request(app) + .post('/api/functions/allocate-invoice-number') + .set('Authorization', `Bearer ${adminToken}`) + .send({}); + expect(res2.status).toBe(200); + expect(res2.body.invoice_number).toBeTruthy(); + + expect(res1.body.invoice_number).not.toBe(res2.body.invoice_number); + }); + + it('POST /api/functions/allocate-job-number returns distinct numbers across calls', async () => { + const res1 = await request(app) + .post('/api/functions/allocate-job-number') + .set('Authorization', `Bearer ${adminToken}`) + .send({}); + expect(res1.status).toBe(200); + expect(res1.body.job_number).toBeTruthy(); + + const res2 = await request(app) + .post('/api/functions/allocate-job-number') + .set('Authorization', `Bearer ${adminToken}`) + .send({}); + expect(res2.status).toBe(200); + expect(res2.body.job_number).toBeTruthy(); + + expect(res1.body.job_number).not.toBe(res2.body.job_number); + }); + + it('the external API assigns distinct, non-colliding invoice_numbers, even across a delete (regression for the old COUNT(*) scheme)', async () => { + const keyRes = await request(app) + .post('/api/auth/api-keys') + .set('Authorization', `Bearer ${adminToken}`) + .send({ name: 'numbering-test-key', scopes: ['*'] }); + expect(keyRes.status).toBe(200); + const rawKey = keyRes.body.key; + expect(rawKey).toBeTruthy(); + + const create1 = await request(app) + .post('/api/external/invoices') + .set('Authorization', `Bearer ${rawKey}`) + .send({ client_id: clientId, due_date: '2026-08-01' }); + expect(create1.status).toBe(201); + const number1 = create1.body.data.invoice_number; + const id1 = create1.body.data.id; + expect(number1).toBeTruthy(); + + const create2 = await request(app) + .post('/api/external/invoices') + .set('Authorization', `Bearer ${rawKey}`) + .send({ client_id: clientId, due_date: '2026-08-01' }); + expect(create2.status).toBe(201); + const number2 = create2.body.data.invoice_number; + expect(number2).toBeTruthy(); + expect(number2).not.toBe(number1); + + // Delete the first invoice: COUNT(*) would now drop back down under the old + // scheme, but the allocator must not reissue a number already handed out. + const delRes = await request(app) + .delete(`/api/external/invoices/${id1}`) + .set('Authorization', `Bearer ${rawKey}`); + expect(delRes.status).toBe(204); + + const create3 = await request(app) + .post('/api/external/invoices') + .set('Authorization', `Bearer ${rawKey}`) + .send({ client_id: clientId, due_date: '2026-08-01' }); + expect(create3.status).toBe(201); + const number3 = create3.body.data.invoice_number; + + expect(number3).not.toBe(number1); + expect(number3).not.toBe(number2); + }); + + it('rejects inserting two invoices with the same explicit invoice_number (UNIQUE backstop)', async () => { + const dupNumber = 'DUP-TEST-0001'; + + const first = await request(app) + .post('/api/invoices') + .set('Authorization', `Bearer ${adminToken}`) + .send({ invoice_number: dupNumber, client_id: clientId, due_date: '2026-08-01' }); + expect(first.status).toBe(201); + + const second = await request(app) + .post('/api/invoices') + .set('Authorization', `Bearer ${adminToken}`) + .send({ invoice_number: dupNumber, client_id: clientId, due_date: '2026-08-01' }); + + expect(second.status).toBeGreaterThanOrEqual(400); + expect(second.body.error).toBeTruthy(); + }); +}); diff --git a/server/__tests__/security-active.test.ts b/server/__tests__/security-active.test.ts new file mode 100644 index 0000000..8ed9d5e --- /dev/null +++ b/server/__tests__/security-active.test.ts @@ -0,0 +1,56 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login } from './helpers.js'; + +let app: Express; +let adminToken: string; + +beforeAll(async () => { + app = await createTestApp(); + adminToken = await login(app); +}); + +describe('security: deactivating a user immediately revokes their existing token', () => { + it('invites + activates a user, confirms their token works, then admin disables them and the token stops working', async () => { + const createRes = await request(app) + .post('/api/auth/users') + .set('Authorization', `Bearer ${adminToken}`) + .send({ email: 'gets-disabled@example.com', full_name: 'Gets Disabled' }); + + expect(createRes.status).toBe(200); + const userId = createRes.body.user.id; + const inviteToken = createRes.body.invite_token; + expect(inviteToken).toBeTruthy(); + + const acceptRes = await request(app) + .post('/api/auth/accept-invite') + .send({ token: inviteToken, password: 'temp-password-1' }); + expect(acceptRes.status).toBe(200); + const userToken = acceptRes.body.token; + expect(userToken).toBeTruthy(); + + // The user's own token works on a protected route before being disabled. + const beforeRes = await request(app) + .get('/api/auth/session') + .set('Authorization', `Bearer ${userToken}`); + expect(beforeRes.status).toBe(200); + + // Admin disables the account. + const patchRes = await request(app) + .patch(`/api/profiles/${userId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ is_active: false }); + expect(patchRes.status).toBe(200); + + // The previously-issued token must now be rejected. + const afterRes = await request(app) + .get('/api/auth/session') + .set('Authorization', `Bearer ${userToken}`); + expect(afterRes.status).toBe(401); + expect(afterRes.body.error).toMatch(/disabled/i); + }); +}); diff --git a/server/__tests__/security-sqli.test.ts b/server/__tests__/security-sqli.test.ts new file mode 100644 index 0000000..354805e --- /dev/null +++ b/server/__tests__/security-sqli.test.ts @@ -0,0 +1,75 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login } from './helpers.js'; + +let app: Express; +let token: string; + +beforeAll(async () => { + app = await createTestApp(); + token = await login(app); +}); + +describe('security: crud column/filter validation blocks SQL injection attempts', () => { + it('rejects an insert whose payload contains a malicious "column" key, and leaves the table intact', async () => { + const res = await request(app) + .post('/api/clients') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'ok', 'x = 1); DROP TABLE clients; --': 'y' }); + + expect(res.status).toBe(400); + + // The table must still exist and be queryable afterwards. + const listRes = await request(app).get('/api/clients').set('Authorization', `Bearer ${token}`); + expect(listRes.status).toBe(200); + expect(Array.isArray(listRes.body)).toBe(true); + }); + + it('rejects a bogus select column', async () => { + const res = await request(app) + .get('/api/clients') + .query({ select: 'definitely_not_a_column' }) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(400); + }); + + it('rejects a bogus filter column', async () => { + const res = await request(app) + .get('/api/clients') + .query({ definitely_not_a_column: 'eq.1' }) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(400); + }); + + it('still allows valid select/relation/filter/order queries (positive controls)', async () => { + const createRes = await request(app) + .post('/api/clients') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Acme Co' }); + expect(createRes.status).toBe(201); + + const selectStar = await request(app) + .get('/api/clients') + .query({ select: '*' }) + .set('Authorization', `Bearer ${token}`); + expect(selectStar.status).toBe(200); + + const selectWithRelation = await request(app) + .get('/api/clients') + .query({ select: '*,locations(*)' }) + .set('Authorization', `Bearer ${token}`); + expect(selectWithRelation.status).toBe(200); + + const filteredAndOrdered = await request(app) + .get('/api/clients') + .query({ is_active: 'eq.true', order: 'name.asc' }) + .set('Authorization', `Bearer ${token}`); + expect(filteredAndOrdered.status).toBe(200); + }); +}); diff --git a/server/__tests__/security-storage.test.ts b/server/__tests__/security-storage.test.ts new file mode 100644 index 0000000..644b2e6 --- /dev/null +++ b/server/__tests__/security-storage.test.ts @@ -0,0 +1,55 @@ +process.env.NODE_ENV = 'test'; +process.env.JWT_SECRET = 'test-secret-key'; + +import { describe, it, expect, beforeAll } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { createTestApp, login } from './helpers.js'; + +let app: Express; +let token: string; + +beforeAll(async () => { + app = await createTestApp(); + token = await login(app); +}); + +describe('security: storage path traversal guards', () => { + it('does not serve files outside the bucket via an encoded traversal path', async () => { + const res = await request(app).get('/api/storage/images/..%2F..%2F..%2Fpackage.json'); + + expect(res.status).not.toBe(200); + expect([400, 404]).toContain(res.status); + // Make sure we didn't accidentally leak the repo's package.json contents. + expect(res.text || '').not.toContain('vite_react_shadcn_ts'); + }); + + it('rejects an upload whose custom path tries to escape the bucket', async () => { + const res = await request(app) + .post('/api/storage/images') + .set('Authorization', `Bearer ${token}`) + .field('path', '../escape.txt') + .attach('file', Buffer.from('malicious payload'), { + filename: 'note.txt', + contentType: 'text/plain', + }); + + expect(res.status).toBe(400); + }); + + it('sanity: a normal upload succeeds and the file can be fetched back', async () => { + const uploadRes = await request(app) + .post('/api/storage/images') + .set('Authorization', `Bearer ${token}`) + .attach('file', Buffer.from('hello world'), { + filename: 'photo.txt', + contentType: 'text/plain', + }); + + expect(uploadRes.status).toBe(200); + expect(uploadRes.body.url).toBeTruthy(); + + const fetchRes = await request(app).get(uploadRes.body.url); + expect(fetchRes.status).toBe(200); + }); +}); diff --git a/server/db/columns.ts b/server/db/columns.ts new file mode 100644 index 0000000..7a65501 --- /dev/null +++ b/server/db/columns.ts @@ -0,0 +1,31 @@ +import { getDatabase } from './database.js'; + +const columnCache = new Map>(); + +/** Real column names for a table, cached. Empty set if table unknown. */ +export function getTableColumns(table: string): Set { + const cached = columnCache.get(table); + if (cached) return cached; + try { + const rows = getDatabase().pragma(`table_info(${table})`) as Array<{ name: string }>; + const set = new Set(rows.map(r => r.name)); + if (set.size > 0) columnCache.set(table, set); + return set; + } catch { + return new Set(); + } +} + +/** True if every column in `cols` is a real column of `table`. */ +export function areValidColumns(table: string, cols: string[]): boolean { + const valid = getTableColumns(table); + return cols.length > 0 && cols.every(c => valid.has(c)); +} + +/** Throwing guard for write paths. */ +export class InvalidColumnError extends Error {} +export function assertValidColumns(table: string, cols: string[]): void { + const valid = getTableColumns(table); + const bad = cols.filter(c => !valid.has(c)); + if (bad.length) throw new InvalidColumnError(`Invalid column(s): ${bad.join(', ')}`); +} diff --git a/server/db/database.ts b/server/db/database.ts index c5fac6b..158bcf8 100644 --- a/server/db/database.ts +++ b/server/db/database.ts @@ -196,6 +196,76 @@ function runMigrations(database: Database.Database): void { } catch (error) { console.error('Migration error (populate default_rental_rate):', error); } + + // Migration: Add invite_token and invite_expires_at to profiles (for invite-based onboarding) + try { + const profileColumns = database.pragma('table_info(profiles)') as Array<{ name: string }>; + const profileColumnNames = profileColumns.map(c => c.name); + + if (!profileColumnNames.includes('invite_token')) { + database.exec("ALTER TABLE profiles ADD COLUMN invite_token TEXT"); + console.log('Migration: Added invite_token column to profiles'); + } + if (!profileColumnNames.includes('invite_expires_at')) { + database.exec("ALTER TABLE profiles ADD COLUMN invite_expires_at TEXT"); + console.log('Migration: Added invite_expires_at column to profiles'); + } + } catch (error) { + console.error('Migration error (profiles invite columns):', error); + } + + // Migration: Add job_prefix and job_next_number to company_settings + // (mirrors invoice_prefix/invoice_next_number, used by allocateJobNumber()) + try { + const settingsColumns = database.pragma('table_info(company_settings)') as Array<{ name: string }>; + const settingsColumnNames = settingsColumns.map(c => c.name); + + if (!settingsColumnNames.includes('job_prefix')) { + database.exec("ALTER TABLE company_settings ADD COLUMN job_prefix TEXT DEFAULT 'JOB-'"); + console.log('Migration: Added job_prefix column to company_settings'); + } + if (!settingsColumnNames.includes('job_next_number')) { + database.exec("ALTER TABLE company_settings ADD COLUMN job_next_number INTEGER DEFAULT 1"); + console.log('Migration: Added job_next_number column to company_settings'); + } + } catch (error) { + console.error('Migration error (company_settings job numbering columns):', error); + } + + // Migration: Add a UNIQUE index on invoices.invoice_number, if it's safe to do so. + // Older databases may already contain duplicate invoice numbers (a symptom of the + // COUNT(*)-based generation bug this migration accompanies the fix for). Creating + // a UNIQUE index over existing duplicates would throw and break server boot, so we + // check first and skip (with a warning) rather than fail startup. NULLs are fine: + // SQLite unique indexes allow any number of NULLs. + try { + const dupInvoice = database.prepare( + `SELECT invoice_number FROM invoices WHERE invoice_number IS NOT NULL GROUP BY invoice_number HAVING COUNT(*) > 1 LIMIT 1` + ).get(); + if (dupInvoice) { + console.warn('Migration: Skipped UNIQUE index on invoices.invoice_number - existing duplicates found'); + } else { + database.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_invoice_number ON invoices(invoice_number)'); + console.log('Migration: Ensured UNIQUE index idx_invoices_invoice_number'); + } + } catch (error) { + console.error('Migration error (idx_invoices_invoice_number):', error); + } + + // Migration: Add a UNIQUE index on jobs.job_number, same safety approach as above. + try { + const dupJob = database.prepare( + `SELECT job_number FROM jobs WHERE job_number IS NOT NULL GROUP BY job_number HAVING COUNT(*) > 1 LIMIT 1` + ).get(); + if (dupJob) { + console.warn('Migration: Skipped UNIQUE index on jobs.job_number - existing duplicates found'); + } else { + database.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_job_number ON jobs(job_number)'); + console.log('Migration: Ensured UNIQUE index idx_jobs_job_number'); + } + } catch (error) { + console.error('Migration error (idx_jobs_job_number):', error); + } } export function closeDatabase(): void { diff --git a/server/db/schema.sql b/server/db/schema.sql index dc860be..d648614 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -802,6 +802,8 @@ CREATE TABLE IF NOT EXISTS company_settings ( default_role_id TEXT REFERENCES roles(id), invoice_prefix TEXT DEFAULT 'INV-', invoice_next_number INTEGER DEFAULT 1, + job_prefix TEXT DEFAULT 'JOB-', + job_next_number INTEGER DEFAULT 1, setup_completed INTEGER DEFAULT 0, user_id TEXT, created_at TEXT DEFAULT (datetime('now')), diff --git a/server/index.ts b/server/index.ts index 2a11158..0904fc2 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,5 +1,6 @@ import express from 'express'; import cors from 'cors'; +import rateLimit from 'express-rate-limit'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { readFileSync, existsSync } from 'fs'; @@ -15,9 +16,46 @@ import mailRoutes from './routes/mail.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// Refuse to boot in production with a missing or well-known placeholder +// JWT secret - continuing would mean anyone could forge valid session tokens. +const PLACEHOLDER_JWT_SECRETS = new Set([ + 'change-me-in-production', + 'local-dev-secret-change-in-production', + 'your-secret-key', + 'your-secure-secret-key-here', +]); + +if (process.env.NODE_ENV === 'production') { + const jwtSecret = process.env.JWT_SECRET; + if (!jwtSecret || PLACEHOLDER_JWT_SECRETS.has(jwtSecret)) { + console.error( + 'FATAL: JWT_SECRET is missing or set to a known placeholder value. ' + + 'Set a strong, unique JWT_SECRET before starting in production.' + ); + process.exit(1); + } + + if (!process.env.CORS_ORIGIN || process.env.CORS_ORIGIN === '*') { + console.warn( + 'WARNING: CORS_ORIGIN is not set (or is "*") in production. ' + + 'This allows requests from any origin. Set CORS_ORIGIN to your trusted origin(s).' + ); + } +} + const app = express(); const PORT = process.env.API_PORT || 3001; +// Rate limit authentication endpoints to slow down credential stuffing / +// brute-force attempts against login and invite acceptance. +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.NODE_ENV === 'test', +}); + const allowedOrigins = (process.env.CORS_ORIGIN ?? '*') .split(',') .map(origin => origin.trim()) @@ -53,6 +91,11 @@ app.get('/api/health', (req, res) => { }); // Routes +// Rate limit only the sensitive auth actions (credential submission / invite +// acceptance). The session check is polled on every app load and focus, so +// throttling the whole /api/auth router would sign active users out. +app.use('/api/auth/login', authLimiter); +app.use('/api/auth/accept-invite', authLimiter); app.use('/api/auth', authRoutes); app.use('/api/storage', storageRoutes); app.use('/api/bill-import', billImportRoutes); @@ -106,27 +149,29 @@ app.use((err: any, req: express.Request, res: express.Response, next: express.Ne }); }); -// Start server -const server = app.listen(PORT, () => { - console.log(`API server running on http://localhost:${PORT}`); - console.log(`Database location: ${process.env.DATABASE_PATH || join(process.cwd(), 'data', 'app.db')}`); -}); +// Start server (skipped under test, where supertest drives the app directly) +if (process.env.NODE_ENV !== 'test') { + const server = app.listen(PORT, () => { + console.log(`API server running on http://localhost:${PORT}`); + console.log(`Database location: ${process.env.DATABASE_PATH || join(process.cwd(), 'data', 'app.db')}`); + }); -// Graceful shutdown -process.on('SIGTERM', () => { - console.log('SIGTERM received, shutting down...'); - server.close(() => { - closeDatabase(); - process.exit(0); + // Graceful shutdown + process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down...'); + server.close(() => { + closeDatabase(); + process.exit(0); + }); }); -}); -process.on('SIGINT', () => { - console.log('SIGINT received, shutting down...'); - server.close(() => { - closeDatabase(); - process.exit(0); + process.on('SIGINT', () => { + console.log('SIGINT received, shutting down...'); + server.close(() => { + closeDatabase(); + process.exit(0); + }); }); -}); +} export default app; diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 9f9e0b9..c67b77b 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -30,9 +30,20 @@ export function verifyToken(token: string): AuthUser | null { } } +// Re-loads the user from the DB and confirms the account still exists and is active. +// Single indexed lookup by primary key, so it's cheap to run on every request. +function loadActiveUser(userId: string): boolean { + const result = queryOne<{ id: string; is_active: number }>( + 'SELECT id, is_active FROM profiles WHERE id = ?', + [userId] + ); + + return !result.error && !!result.data && !!result.data.is_active; +} + export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; - + if (!authHeader || !authHeader.startsWith('Bearer ')) { res.status(401).json({ error: 'No token provided' }); return; @@ -46,21 +57,26 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti return; } + if (!loadActiveUser(user.id)) { + res.status(401).json({ error: 'Account is disabled' }); + return; + } + req.user = user; next(); } export function optionalAuthMiddleware(req: AuthRequest, res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; - + if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7); const user = verifyToken(token); - if (user) { + if (user && loadActiveUser(user.id)) { req.user = user; } } - + next(); } diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 087e242..c8a9a63 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -3,10 +3,12 @@ import bcrypt from 'bcryptjs'; import { createHash, randomBytes } from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { queryOne, execute } from '../db/database.js'; -import { generateToken, authMiddleware, AuthRequest } from '../middleware/auth.js'; +import { generateToken, authMiddleware, AuthRequest, requirePermission, getUserPermission } from '../middleware/auth.js'; const router = Router(); +const INVITE_TOKEN_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours + interface Profile { id: string; email: string; @@ -15,6 +17,11 @@ interface Profile { is_active: number; } +interface ProfileWithInvite extends Profile { + invite_token: string | null; + invite_expires_at: string | null; +} + // Login router.post('/login', async (req: Request, res: Response) => { const { email, password } = req.body; @@ -46,17 +53,18 @@ router.post('/login', async (req: Request, res: Response) => { return; } - // For first-time setup or if no password is set, allow setting a password + // Accounts without a password hash have not been activated yet (invite pending). + // Do NOT auto-set a password here - that would let anyone who knows the email + // claim the account. They must go through /auth/accept-invite instead. if (!user.password_hash) { - // Hash and save the password - const hash = await bcrypt.hash(password, 10); - execute('UPDATE profiles SET password_hash = ? WHERE id = ?', [hash, user.id]); - } else { - const valid = await bcrypt.compare(password, user.password_hash); - if (!valid) { - res.status(401).json({ error: 'Invalid credentials' }); - return; - } + res.status(401).json({ error: 'Account not activated. Use your invitation link to set a password.' }); + return; + } + + const valid = await bcrypt.compare(password, user.password_hash); + if (!valid) { + res.status(401).json({ error: 'Invalid credentials' }); + return; } const token = generateToken({ @@ -118,103 +126,183 @@ router.post('/change-password', authMiddleware, async (req: AuthRequest, res: Re } const hash = await bcrypt.hash(newPassword, 10); - execute('UPDATE profiles SET password_hash = ?, updated_at = datetime("now") WHERE id = ?', [hash, req.user!.id]); + execute(`UPDATE profiles SET password_hash = ?, updated_at = datetime('now') WHERE id = ?`, [hash, req.user!.id]); res.json({ success: true }); }); -// Create user (admin only) +// Create user (requires team:write permission) router.post('/users', authMiddleware, async (req: AuthRequest, res: Response) => { - const { email, full_name, password, role_id } = req.body; + const requireWrite = requirePermission('team', 'write'); + await requireWrite(req, res, async () => { + const { email, full_name, password, role_id } = req.body; + + if (!email) { + res.status(400).json({ error: 'Email is required' }); + return; + } + + // Check if email already exists + const existing = queryOne('SELECT id FROM profiles WHERE email = ?', [email.toLowerCase()]); + if (existing.data) { + res.status(400).json({ error: 'Email already exists' }); + return; + } + + const id = uuidv4(); + const hash = password ? await bcrypt.hash(password, 10) : null; + + // If no password was provided, generate an invite token so the user can + // activate their account via /auth/accept-invite instead of relying on + // a passwordless first login. + let inviteToken: string | null = null; + let inviteExpiresAt: string | null = null; + if (!hash) { + inviteToken = randomBytes(32).toString('hex'); + inviteExpiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MS).toISOString(); + } + + execute( + `INSERT INTO profiles (id, email, password_hash, full_name, is_active, invite_token, invite_expires_at) + VALUES (?, ?, ?, ?, 1, ?, ?)`, + [id, email.toLowerCase(), hash, full_name || null, inviteToken, inviteExpiresAt] + ); + + // Assign role if provided + if (role_id) { + execute( + `INSERT INTO user_roles (id, user_id, role_id, role) VALUES (?, ?, ?, 'staff')`, + [uuidv4(), id, role_id] + ); + } + + res.json({ + user: { + id, + email: email.toLowerCase(), + full_name: full_name || null + }, + ...(inviteToken ? { invite_token: inviteToken } : {}) + }); + }); +}); - if (!email) { - res.status(400).json({ error: 'Email is required' }); +// Accept an invitation: set a password using a valid invite token, then auto-login. +router.post('/accept-invite', async (req: Request, res: Response) => { + const { token, password } = req.body; + + if (!token || !password) { + res.status(400).json({ error: 'Token and password are required' }); + return; + } + + if (password.length < 6) { + res.status(400).json({ error: 'Password must be at least 6 characters' }); return; } - // Check if email already exists - const existing = queryOne('SELECT id FROM profiles WHERE email = ?', [email.toLowerCase()]); - if (existing.data) { - res.status(400).json({ error: 'Email already exists' }); + const result = queryOne( + 'SELECT id, email, password_hash, full_name, is_active, invite_token, invite_expires_at FROM profiles WHERE invite_token = ?', + [token] + ); + + if (result.error || !result.data) { + res.status(400).json({ error: 'Invalid or expired invitation' }); return; } - const id = uuidv4(); - const hash = password ? await bcrypt.hash(password, 10) : null; + const profile = result.data; + if (!profile.invite_expires_at || new Date(profile.invite_expires_at).getTime() < Date.now()) { + res.status(400).json({ error: 'Invalid or expired invitation' }); + return; + } + + const hash = await bcrypt.hash(password, 10); execute( - `INSERT INTO profiles (id, email, password_hash, full_name, is_active) - VALUES (?, ?, ?, ?, 1)`, - [id, email.toLowerCase(), hash, full_name || null] + `UPDATE profiles SET password_hash = ?, invite_token = NULL, invite_expires_at = NULL, updated_at = datetime('now') WHERE id = ?`, + [hash, profile.id] ); - // Assign role if provided - if (role_id) { - execute( - `INSERT INTO user_roles (id, user_id, role_id, role) VALUES (?, ?, ?, 'staff')`, - [uuidv4(), id, role_id] - ); - } + const authToken = generateToken({ + id: profile.id, + email: profile.email, + full_name: profile.full_name + }); res.json({ + token: authToken, user: { - id, - email: email.toLowerCase(), - full_name: full_name || null + id: profile.id, + email: profile.email, + full_name: profile.full_name } }); }); // Create API key (server-side key generation) router.post('/api-keys', authMiddleware, async (req: AuthRequest, res: Response) => { - const { name, scopes, expires_at, user_id } = req.body; + const requireWrite = requirePermission('settings', 'write'); + await requireWrite(req, res, async () => { + const { name, scopes, expires_at, user_id } = req.body; - if (!name) { - res.status(400).json({ error: 'Key name is required' }); - return; - } - - // Generate a secure random key - const prefix = 'sk_live_'; - const randomPart = randomBytes(24).toString('base64url'); - const rawKey = prefix + randomPart; - - // Hash the key for storage - const keyHash = createHash('sha256').update(rawKey).digest('hex'); - const keyPrefix = rawKey.substring(0, 12); + if (!name) { + res.status(400).json({ error: 'Key name is required' }); + return; + } - const id = uuidv4(); - const keyUserId = user_id || req.user!.id; + // Generate a secure random key + const prefix = 'sk_live_'; + const randomPart = randomBytes(24).toString('base64url'); + const rawKey = prefix + randomPart; + + // Hash the key for storage + const keyHash = createHash('sha256').update(rawKey).digest('hex'); + const keyPrefix = rawKey.substring(0, 12); + + const id = uuidv4(); + + // Never trust body.user_id blindly - it would let any authenticated user + // mint a key for someone else. Only honor it if the requester has + // team:write permission; otherwise the key is always scoped to themselves. + let keyUserId = req.user!.id; + if (user_id && user_id !== req.user!.id) { + const teamPermission = await getUserPermission(req.user!.id, 'team'); + if (teamPermission === 'write') { + keyUserId = user_id; + } + } - try { - execute( - `INSERT INTO api_keys (id, name, key_hash, key_prefix, scopes, expires_at, user_id, created_by, is_active) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`, - [ + try { + execute( + `INSERT INTO api_keys (id, name, key_hash, key_prefix, scopes, expires_at, user_id, created_by, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`, + [ + id, + name, + keyHash, + keyPrefix, + JSON.stringify(scopes || ['*']), + expires_at || null, + keyUserId, + req.user!.id + ] + ); + + // Return the raw key - this is the only time it will be shown + res.json({ id, name, - keyHash, - keyPrefix, - JSON.stringify(scopes || ['*']), - expires_at || null, - keyUserId, - req.user!.id - ] - ); - - // Return the raw key - this is the only time it will be shown - res.json({ - id, - name, - key: rawKey, - key_prefix: keyPrefix, - scopes: scopes || ['*'], - expires_at: expires_at || null, - user_id: keyUserId - }); - } catch (error: any) { - res.status(500).json({ error: error.message || 'Failed to create API key' }); - } + key: rawKey, + key_prefix: keyPrefix, + scopes: scopes || ['*'], + expires_at: expires_at || null, + user_id: keyUserId + }); + } catch (error: any) { + res.status(500).json({ error: error.message || 'Failed to create API key' }); + } + }); }); export default router; diff --git a/server/routes/crud.ts b/server/routes/crud.ts index 345a701..7fd8a7d 100644 --- a/server/routes/crud.ts +++ b/server/routes/crud.ts @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { queryAll, queryOne, execute } from '../db/database.js'; import { authMiddleware, optionalAuthMiddleware, AuthRequest, requirePermission } from '../middleware/auth.js'; import { logActivity, getEntityName } from '../utils/activityLogger.js'; +import { getTableColumns, assertValidColumns, areValidColumns, InvalidColumnError } from '../db/columns.js'; import type { ParsedQs } from 'qs'; const router = Router(); @@ -217,6 +218,23 @@ function normalizePayload(table: string, payload: Record) { return normalized; } +/** + * Validates that every key in `columns` is a real column of `table`. + * On failure, writes a 400 response and returns false so the caller can bail out. + */ +function validateColumns(res: Response, table: string, columns: string[]): boolean { + try { + assertValidColumns(table, columns); + return true; + } catch (err) { + if (err instanceof InvalidColumnError) { + res.status(400).json({ error: 'Invalid column name' }); + return false; + } + throw err; + } +} + function buildWhereClause(query: Record, tableName: string): { clause: string; params: any[] } { const conditions: string[] = []; const params: any[] = []; @@ -259,6 +277,9 @@ function buildWhereClause(query: Record, tableName: string): { clau }; const buildCondition = (field: string, operator: string, value: string, negated: boolean) => { + if (!getTableColumns(tableName).has(field)) { + throw new InvalidColumnError(`Invalid column: ${field}`); + } const column = `${tableName}.${field}`; switch (operator) { @@ -338,6 +359,9 @@ function buildWhereClause(query: Record, tableName: string): { clau continue; } + if (!getTableColumns(tableName).has(key)) { + throw new InvalidColumnError(`Invalid column: ${key}`); + } conditions.push(`${tableName}.${key} = ?`); params.push(value); } @@ -473,7 +497,23 @@ router.get('/:table', (req: AuthRequest, res: Response) => { fields.length = 0; requiredFields.forEach(field => fields.push(field)); } - const { clause, params } = buildWhereClause(req.query as Record, tableParam); + + if (!fields.includes('*') && !areValidColumns(tableParam, fields)) { + res.status(400).json({ error: 'Invalid column name' }); + return; + } + + let clause: string; + let params: any[]; + try { + ({ clause, params } = buildWhereClause(req.query as Record, tableParam)); + } catch (err) { + if (err instanceof InvalidColumnError) { + res.status(400).json({ error: 'Invalid column name' }); + return; + } + throw err; + } let orderClause = ''; const orderValue = normalizeQueryValue(order); @@ -482,6 +522,7 @@ router.get('/:table', (req: AuthRequest, res: Response) => { .split(',') .map(part => part.trim()) .filter(Boolean) + .filter(part => getTableColumns(tableParam).has(part.split('.')[0])) .map(part => { const [field, dir] = part.split('.'); return `${tableParam}.${field} ${dir === 'desc' ? 'DESC' : 'ASC'}`; @@ -533,6 +574,12 @@ router.get('/:table/:id', (req: AuthRequest, res: Response) => { withAuth(req, res, tableParam, async () => { const selectValue = normalizeQueryValue(req.query.select as any) || '*'; const { fields, relations } = parseSelect(selectValue); + + if (!fields.includes('*') && !areValidColumns(tableParam, fields)) { + res.status(400).json({ error: 'Invalid column name' }); + return; + } + const fieldList = fields.includes('*') ? '*' : fields.join(', '); const result = queryOne(`SELECT ${fieldList} FROM ${tableParam} WHERE id = ?`, [idParam]); @@ -569,6 +616,7 @@ router.post('/:table', (req: AuthRequest, res: Response) => { } const columns = Object.keys(data); + if (!validateColumns(res, tableParam, columns)) return; const placeholders = columns.map(() => '?').join(', '); const values = Object.values(data); @@ -614,6 +662,7 @@ router.post('/:table', (req: AuthRequest, res: Response) => { } const columns = Object.keys(data); + if (!validateColumns(res, tableParam, columns)) return; const placeholders = columns.map(() => '?').join(', '); const values = Object.values(data); const updateClauses = columns.filter(col => col !== 'id').map(col => `${col} = excluded.${col}`).join(', '); @@ -646,6 +695,7 @@ router.post('/:table', (req: AuthRequest, res: Response) => { } const columns = Object.keys(data); + if (!validateColumns(res, tableParam, columns)) return; const placeholders = columns.map(() => '?').join(', '); const values = Object.values(data); @@ -694,6 +744,8 @@ router.patch('/:table/:id', (req: AuthRequest, res: Response) => { data.updated_at = new Date().toISOString(); } + if (!validateColumns(res, tableParam, Object.keys(data))) return; + const sets = Object.keys(data).map(key => `${key} = ?`).join(', '); const values = [...Object.values(data), idParam]; @@ -738,7 +790,19 @@ router.patch('/:table', (req: AuthRequest, res: Response) => { data.updated_at = new Date().toISOString(); } - const { clause, params } = buildWhereClause(req.query as Record, tableParam); + if (!validateColumns(res, tableParam, Object.keys(data))) return; + + let clause: string; + let params: any[]; + try { + ({ clause, params } = buildWhereClause(req.query as Record, tableParam)); + } catch (err) { + if (err instanceof InvalidColumnError) { + res.status(400).json({ error: 'Invalid column name' }); + return; + } + throw err; + } if (!clause) { res.status(400).json({ error: 'No filters specified for update' }); return; diff --git a/server/routes/external-api.ts b/server/routes/external-api.ts index 6158915..91c6173 100644 --- a/server/routes/external-api.ts +++ b/server/routes/external-api.ts @@ -4,6 +4,8 @@ import { v4 as uuidv4 } from 'uuid'; import { queryAll, queryOne, execute } from '../db/database.js'; import { getUserPermission } from '../middleware/auth.js'; import { logActivity, getEntityName, loggedTables } from '../utils/activityLogger.js'; +import { areValidColumns } from '../db/columns.js'; +import { allocateInvoiceNumber, allocateJobNumber } from '../utils/numbering.js'; const router = Router(); @@ -157,22 +159,22 @@ const handleExternalRequest = async (req: Request, res: Response) => { const payload = req.body || {}; if (!payload.id) payload.id = uuidv4(); - // Auto-generate required fields for specific tables + // Auto-generate required fields for specific tables. Numbers come from + // the shared, transaction-guarded counters in company_settings so they + // never collide or get reused after a delete (see server/utils/numbering.ts). if (table === 'jobs' && !payload.job_number) { - const year = new Date().getFullYear(); - const countResult = queryOne<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM jobs`, []); - const count = countResult.data?.cnt || 0; - payload.job_number = `JOB-${year}-${String(count + 1).padStart(4, '0')}`; + payload.job_number = allocateJobNumber(); } - + if (table === 'invoices' && !payload.invoice_number) { - const year = new Date().getFullYear(); - const countResult = queryOne<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM invoices`, []); - const count = countResult.data?.cnt || 0; - payload.invoice_number = `INV-${year}-${String(count + 1).padStart(4, '0')}`; + payload.invoice_number = allocateInvoiceNumber(); } const columns = Object.keys(payload); + if (!areValidColumns(table, columns)) { + res.status(400).json({ error: 'Invalid column name' }); + return; + } const placeholders = columns.map(() => '?').join(', '); const values = Object.values(payload); const execResult = execute(`INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`, values); @@ -213,7 +215,12 @@ const handleExternalRequest = async (req: Request, res: Response) => { } const payload = req.body || {}; - const sets = Object.keys(payload).map(key => `${key} = ?`).join(', '); + const columns = Object.keys(payload); + if (!areValidColumns(table, columns)) { + res.status(400).json({ error: 'Invalid column name' }); + return; + } + const sets = columns.map(key => `${key} = ?`).join(', '); const values = [...Object.values(payload), idParam]; const execResult = execute(`UPDATE ${table} SET ${sets} WHERE id = ?`, values); diff --git a/server/routes/functions.ts b/server/routes/functions.ts index a41de11..a093cad 100644 --- a/server/routes/functions.ts +++ b/server/routes/functions.ts @@ -2,6 +2,7 @@ import { Router, Response } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { queryAll, queryOne, execute } from '../db/database.js'; import { authMiddleware, AuthRequest, requirePermission } from '../middleware/auth.js'; +import { allocateInvoiceNumber, allocateJobNumber } from '../utils/numbering.js'; const router = Router(); @@ -326,10 +327,8 @@ router.post('/generate-job-invoices', authMiddleware, async (req: AuthRequest, r } const settings = queryOne( - 'SELECT id, invoice_prefix, invoice_next_number, default_tax_rate, default_tax_rate_id, default_payment_terms FROM company_settings LIMIT 1' + 'SELECT id, default_tax_rate, default_tax_rate_id, default_payment_terms FROM company_settings LIMIT 1' ).data; - const prefix = settings?.invoice_prefix || 'INV'; - let nextNum = settings?.invoice_next_number || 1; const defaultTerms = settings?.default_payment_terms || 30; const defaultTax = settings?.default_tax_rate_id ? queryOne('SELECT id, name, rate FROM tax_rates WHERE id = ?', [settings.default_tax_rate_id]).data @@ -366,7 +365,7 @@ router.post('/generate-job-invoices', authMiddleware, async (req: AuthRequest, r const client = queryOne('SELECT * FROM clients WHERE id = ?', [job.client_id]).data; if (!client) continue; - const invoiceNumber = `${prefix}-${String(nextNum).padStart(5, '0')}`; + const invoiceNumber = allocateInvoiceNumber(); const paymentTerms = client.payment_terms || defaultTerms; const dueDate = new Date(); dueDate.setDate(dueDate.getDate() + paymentTerms); @@ -533,11 +532,6 @@ router.post('/generate-job-invoices', authMiddleware, async (req: AuthRequest, r }); invoicesCreated.push(invoiceNumber); - nextNum++; - } - - if (invoicesCreated.length > 0 && settings) { - execute('UPDATE company_settings SET invoice_next_number = ? WHERE id = ?', [nextNum, settings.id]); } res.json({ @@ -548,4 +542,20 @@ router.post('/generate-job-invoices', authMiddleware, async (req: AuthRequest, r }); }); +router.post('/allocate-invoice-number', authMiddleware, async (req: AuthRequest, res: Response) => { + const requireWrite = requirePermission('invoices', 'write'); + await requireWrite(req, res, async () => { + const invoice_number = allocateInvoiceNumber(); + res.json({ invoice_number }); + }); +}); + +router.post('/allocate-job-number', authMiddleware, async (req: AuthRequest, res: Response) => { + const requireWrite = requirePermission('jobs', 'write'); + await requireWrite(req, res, async () => { + const job_number = allocateJobNumber(); + res.json({ job_number }); + }); +}); + export default router; diff --git a/server/routes/storage.ts b/server/routes/storage.ts index a81d48f..e7ffd71 100644 --- a/server/routes/storage.ts +++ b/server/routes/storage.ts @@ -1,15 +1,33 @@ import { Router, Response } from 'express'; import multer from 'multer'; import { v4 as uuidv4 } from 'uuid'; -import { join, extname, dirname } from 'path'; -import { existsSync, mkdirSync, unlinkSync, readFileSync, writeFileSync } from 'fs'; +import { join, extname, dirname, resolve, sep } from 'path'; +import { existsSync, mkdirSync, unlinkSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; import { authMiddleware, AuthRequest } from '../middleware/auth.js'; +/** Express route params can be typed string | string[]; take the first value. */ +function asParam(value: string | string[] | undefined): string { + return Array.isArray(value) ? value[0] : (value ?? ''); +} + const router = Router(); // Configure storage const UPLOAD_DIR = process.env.UPLOAD_DIR || join(process.cwd(), 'server', 'uploads'); +/** + * Resolve a caller-supplied relative path inside a bucket, guaranteeing the + * result cannot escape the bucket directory. Returns null if it would. + */ +function resolveWithinBucket(bucket: string, relPath: string): string | null { + const bucketRoot = resolve(UPLOAD_DIR, bucket); + const full = resolve(bucketRoot, relPath); + if (full !== bucketRoot && !full.startsWith(bucketRoot + sep)) { + return null; + } + return full; +} + // Ensure upload directories exist const buckets = ['images', 'documents', 'kb-files', 'branding']; for (const bucket of buckets) { @@ -25,7 +43,7 @@ const memoryStorage = multer.memoryStorage(); // Also keep disk storage for simple uploads const diskStorage = multer.diskStorage({ destination: (req, file, cb) => { - const bucket = req.params.bucket; + const bucket = asParam(req.params.bucket); if (!buckets.includes(bucket)) { cb(new Error('Invalid bucket'), ''); return; @@ -66,8 +84,8 @@ const upload = multer({ // Upload file to bucket router.post('/:bucket', authMiddleware, upload.single('file'), (req: AuthRequest, res: Response) => { - const { bucket } = req.params; - + const bucket = asParam(req.params.bucket); + if (!buckets.includes(bucket)) { res.status(400).json({ error: 'Invalid bucket' }); return; @@ -84,14 +102,20 @@ router.post('/:bucket', authMiddleware, upload.single('file'), (req: AuthRequest filePath = `${uuidv4()}${extname(req.file.originalname)}`; } + // Ensure the resolved write location stays inside the bucket directory + const fullPath = resolveWithinBucket(bucket, filePath); + if (!fullPath) { + res.status(400).json({ error: 'Invalid path' }); + return; + } + // Ensure the directory exists - const fullDir = join(UPLOAD_DIR, bucket, dirname(filePath)); + const fullDir = dirname(fullPath); if (!existsSync(fullDir)) { mkdirSync(fullDir, { recursive: true }); } // Write the file - const fullPath = join(UPLOAD_DIR, bucket, filePath); writeFileSync(fullPath, req.file.buffer); const fileUrl = `/api/storage/${bucket}/${encodeURIComponent(filePath)}`; @@ -108,17 +132,20 @@ router.post('/:bucket', authMiddleware, upload.single('file'), (req: AuthRequest // Get file from bucket (simple filename) router.get('/:bucket/:filename', (req, res) => { - const { bucket, filename } = req.params; - + const bucket = asParam(req.params.bucket); + const filename = asParam(req.params.filename); + if (!buckets.includes(bucket)) { res.status(400).json({ error: 'Invalid bucket' }); return; } - // Decode the filename in case it was URL encoded - const decodedFilename = decodeURIComponent(filename); - const filePath = join(UPLOAD_DIR, bucket, decodedFilename); - + const filePath = resolveWithinBucket(bucket, filename); + if (!filePath) { + res.status(400).json({ error: 'Invalid path' }); + return; + } + if (!existsSync(filePath)) { res.status(404).json({ error: 'File not found' }); return; @@ -129,17 +156,20 @@ router.get('/:bucket/:filename', (req, res) => { // Delete file from bucket router.delete('/:bucket/:filename', authMiddleware, (req: AuthRequest, res: Response) => { - const { bucket, filename } = req.params; - + const bucket = asParam(req.params.bucket); + const filename = asParam(req.params.filename); + if (!buckets.includes(bucket)) { res.status(400).json({ error: 'Invalid bucket' }); return; } - // Decode the filename in case it was URL encoded - const decodedFilename = decodeURIComponent(filename); - const filePath = join(UPLOAD_DIR, bucket, decodedFilename); - + const filePath = resolveWithinBucket(bucket, filename); + if (!filePath) { + res.status(400).json({ error: 'Invalid path' }); + return; + } + if (!existsSync(filePath)) { // File doesn't exist, but that's fine for delete res.status(204).send(); @@ -156,14 +186,13 @@ router.delete('/:bucket/:filename', authMiddleware, (req: AuthRequest, res: Resp // List files in bucket router.get('/:bucket', authMiddleware, (req: AuthRequest, res: Response) => { - const { bucket } = req.params; - + const bucket = asParam(req.params.bucket); + if (!buckets.includes(bucket)) { res.status(400).json({ error: 'Invalid bucket' }); return; } - const { readdirSync, statSync } = require('fs'); const bucketPath = join(UPLOAD_DIR, bucket); try { diff --git a/server/utils/email.ts b/server/utils/email.ts index 3f45d40..8f85110 100644 --- a/server/utils/email.ts +++ b/server/utils/email.ts @@ -134,10 +134,19 @@ export async function sendInviteEmail( to: string, inviterName: string, companyName: string, - signupUrl: string + signupUrl: string, + inviteToken?: string ): Promise<{ success: boolean; error?: string }> { const subject = `You've been invited to join ${companyName}`; - + + // If an invite token was provided, append it to the signup URL so the + // recipient lands on a page that can call /auth/accept-invite. Backward + // compatible: existing callers that omit the token keep the plain URL. + if (inviteToken) { + const separator = signupUrl.includes('?') ? '&' : '?'; + signupUrl = `${signupUrl}${separator}token=${encodeURIComponent(inviteToken)}`; + } + const html = ` diff --git a/server/utils/numbering.ts b/server/utils/numbering.ts new file mode 100644 index 0000000..7ef401b --- /dev/null +++ b/server/utils/numbering.ts @@ -0,0 +1,61 @@ +import { queryOne, execute, transaction } from '../db/database.js'; + +interface CompanySettingsRow { + id: string; + invoice_prefix: string | null; + invoice_next_number: number | null; +} + +interface JobSettingsRow { + id: string; + job_prefix: string | null; + job_next_number: number | null; +} + +/** + * Atomically allocate the next invoice number and advance the counter. + * + * better-sqlite3 is fully synchronous and single-threaded per process, so a + * read-increment-write with no `await` between the read and the write is + * atomic within the process. We still wrap it in `transaction()` so that if + * the UPDATE ever throws, the whole allocation rolls back rather than + * leaving the counter in a half-advanced state. + * + * NOTE: `invoice_prefix` already includes its trailing separator (default + * 'INV-'), so we concatenate `prefix + paddedNumber` directly — no extra + * dash is inserted. + */ +export function allocateInvoiceNumber(): string { + return transaction(() => { + const s = queryOne( + 'SELECT id, invoice_prefix, invoice_next_number FROM company_settings LIMIT 1' + ).data; + const prefix = s?.invoice_prefix ?? 'INV-'; + const next = s?.invoice_next_number ?? 1; + const number = `${prefix}${String(next).padStart(5, '0')}`; + if (s) { + execute('UPDATE company_settings SET invoice_next_number = ? WHERE id = ?', [next + 1, s.id]); + } + return number; + }); +} + +/** + * Atomically allocate the next job number and advance the counter. + * Mirrors `allocateInvoiceNumber()` but uses the `job_prefix` / + * `job_next_number` columns on `company_settings`. + */ +export function allocateJobNumber(): string { + return transaction(() => { + const s = queryOne( + 'SELECT id, job_prefix, job_next_number FROM company_settings LIMIT 1' + ).data; + const prefix = s?.job_prefix ?? 'JOB-'; + const next = s?.job_next_number ?? 1; + const number = `${prefix}${String(next).padStart(5, '0')}`; + if (s) { + execute('UPDATE company_settings SET job_next_number = ? WHERE id = ?', [next + 1, s.id]); + } + return number; + }); +} diff --git a/src/App.tsx b/src/App.tsx index 0a199a0..dad09aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from "react"; import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -8,41 +9,45 @@ import { PermissionProvider } from "@/contexts/PermissionContext"; import { BrandingProvider } from "@/contexts/BrandingContext"; import ProtectedRoute from "@/components/ProtectedRoute"; import AppLayout from "@/components/layout/AppLayout"; +// Login is the common entry point, so keep it eager to avoid a load flash. import Login from "./pages/Login"; -import SignUp from "./pages/SignUp"; -import ForgotPassword from "./pages/ForgotPassword"; -import ResetPassword from "./pages/ResetPassword"; -import Dashboard from "./pages/Dashboard"; -import Clients from "./pages/Clients"; -import ClientDetail from "./pages/ClientDetail"; -import Jobs from "./pages/Jobs"; -import JobDetail from "./pages/JobDetail"; -import Invoices from "./pages/Invoices"; -import InvoiceDetail from "./pages/InvoiceDetail"; -import Payments from "./pages/Payments"; -import Inventory from "./pages/Inventory"; -import InventoryDetail from "./pages/InventoryDetail"; -import Assets from "./pages/Assets"; -import AssetDetail from "./pages/AssetDetail"; -import Issues from "./pages/Issues"; -import IssueDetail from "./pages/IssueDetail"; -import Vendors from "./pages/Vendors"; -import VendorDetail from "./pages/VendorDetail"; -import Reports from "./pages/Reports"; -import Settings from "./pages/Settings"; -import Team from "./pages/Team"; -import TeamMemberDetail from "./pages/TeamMemberDetail"; -import Roles from "./pages/Roles"; -import ApiKeys from "./pages/ApiKeys"; -import Banking from "./pages/Banking"; -import BankAccountDetail from "./pages/BankAccountDetail"; -import KnowledgeBase from "./pages/KnowledgeBase"; -import KBArticleDetail from "./pages/KBArticleDetail"; -import Locations from "./pages/Locations"; -import LocationDetail from "./pages/LocationDetail"; -import Docs from "./pages/Docs"; -import ActivityLog from "./pages/ActivityLog"; -import NotFound from "./pages/NotFound"; + +// All other pages are code-split so the initial bundle stays small; each +// route's chunk loads on demand behind the Suspense fallback below. +const SignUp = lazy(() => import("./pages/SignUp")); +const ForgotPassword = lazy(() => import("./pages/ForgotPassword")); +const ResetPassword = lazy(() => import("./pages/ResetPassword")); +const Dashboard = lazy(() => import("./pages/Dashboard")); +const Clients = lazy(() => import("./pages/Clients")); +const ClientDetail = lazy(() => import("./pages/ClientDetail")); +const Jobs = lazy(() => import("./pages/Jobs")); +const JobDetail = lazy(() => import("./pages/JobDetail")); +const Invoices = lazy(() => import("./pages/Invoices")); +const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail")); +const Payments = lazy(() => import("./pages/Payments")); +const Inventory = lazy(() => import("./pages/Inventory")); +const InventoryDetail = lazy(() => import("./pages/InventoryDetail")); +const Assets = lazy(() => import("./pages/Assets")); +const AssetDetail = lazy(() => import("./pages/AssetDetail")); +const Issues = lazy(() => import("./pages/Issues")); +const IssueDetail = lazy(() => import("./pages/IssueDetail")); +const Vendors = lazy(() => import("./pages/Vendors")); +const VendorDetail = lazy(() => import("./pages/VendorDetail")); +const Reports = lazy(() => import("./pages/Reports")); +const Settings = lazy(() => import("./pages/Settings")); +const Team = lazy(() => import("./pages/Team")); +const TeamMemberDetail = lazy(() => import("./pages/TeamMemberDetail")); +const Roles = lazy(() => import("./pages/Roles")); +const ApiKeys = lazy(() => import("./pages/ApiKeys")); +const Banking = lazy(() => import("./pages/Banking")); +const BankAccountDetail = lazy(() => import("./pages/BankAccountDetail")); +const KnowledgeBase = lazy(() => import("./pages/KnowledgeBase")); +const KBArticleDetail = lazy(() => import("./pages/KBArticleDetail")); +const Locations = lazy(() => import("./pages/Locations")); +const LocationDetail = lazy(() => import("./pages/LocationDetail")); +const Docs = lazy(() => import("./pages/Docs")); +const ActivityLog = lazy(() => import("./pages/ActivityLog")); +const NotFound = lazy(() => import("./pages/NotFound")); const queryClient = new QueryClient(); @@ -55,6 +60,7 @@ const App = () => ( + Loading…}> } /> } /> @@ -94,6 +100,7 @@ const App = () => ( } /> + diff --git a/src/integrations/api/client.ts b/src/integrations/api/client.ts index 5e2508b..7aedbb7 100644 --- a/src/integrations/api/client.ts +++ b/src/integrations/api/client.ts @@ -420,6 +420,39 @@ const auth = { } }, + async acceptInvite(token: string, password: string) { + try { + const response = await fetch(`${API_URL}/auth/accept-invite`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + return { data: { user: null, session: null }, error: { message: error.error || response.statusText } }; + } + + const data = await response.json(); + setAuthToken(data.token); + + const session = { access_token: data.token, user: data.user }; + + // Notify listeners + authStateListeners.forEach(listener => listener('SIGNED_IN', session)); + + return { + data: { + user: data.user, + session + }, + error: null + }; + } catch (error) { + return { data: { user: null, session: null }, error: { message: (error as Error).message } }; + } + }, + async signOut() { setAuthToken(null); authStateListeners.forEach(listener => listener('SIGNED_OUT', null)); diff --git a/src/pages/ForgotPassword.tsx b/src/pages/ForgotPassword.tsx index 8ec819e..0e67b0c 100644 --- a/src/pages/ForgotPassword.tsx +++ b/src/pages/ForgotPassword.tsx @@ -1,113 +1,30 @@ -import { useState } from 'react'; import { Link } from 'react-router-dom'; -import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; -import { useToast } from '@/hooks/use-toast'; -import { Mail, ArrowLeft, CheckCircle } from 'lucide-react'; +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { ArrowLeft, KeyRound } from 'lucide-react'; export default function ForgotPassword() { - const [email, setEmail] = useState(''); - const [loading, setLoading] = useState(false); - const [sent, setSent] = useState(false); - const { toast } = useToast(); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - - const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${window.location.origin}/reset-password`, - }); - - if (error) { - toast({ - title: 'Error', - description: error.message, - variant: 'destructive', - }); - } else { - setSent(true); - } - setLoading(false); - }; - - if (sent) { - return ( -
- - -
- -
- Check your email - - We've sent a password reset link to {email}. Click the link in the email to set a new password. - -
- - -

- Didn't receive the email?{' '} - -

-
-
-
- ); - } - return (
- +
- +
- Reset your password - - Enter your email address and we'll send you a link to reset your password. + Forgot your password? + + Password resets are handled by your administrator. Please contact them to have your + password reset.
-
- -
- - setEmail(e.target.value)} - required - /> -
-
- - -

- Remember your password?{' '} - - Sign in - -

-
-
+ + +
); diff --git a/src/pages/InvoiceDetail.tsx b/src/pages/InvoiceDetail.tsx index 7b233cb..8126389 100644 --- a/src/pages/InvoiceDetail.tsx +++ b/src/pages/InvoiceDetail.tsx @@ -67,6 +67,7 @@ export default function InvoiceDetail() { const [client, setClient] = useState(null); const [clientCredit, setClientCredit] = useState(0); const [showAssetSelector, setShowAssetSelector] = useState(false); + const [autoInvoiceNumber, setAutoInvoiceNumber] = useState(''); const isDraft = invoice.status === 'draft'; const isEditable = isNew || isDraft; @@ -229,12 +230,17 @@ export default function InvoiceDetail() { .limit(1) .single(); - const prefix = settings?.invoice_prefix || 'INV'; + // Preview only. The prefix already includes its separator (default 'INV-'), + // and this must match the server's allocator format (prefix + padded number, + // no extra dash) so the previewed number equals the one saved at allocation. + const prefix = settings?.invoice_prefix || 'INV-'; const nextNum = settings?.invoice_next_number || 1; - + const previewNumber = `${prefix}${String(nextNum).padStart(5, '0')}`; + + setAutoInvoiceNumber(previewNumber); setInvoice(prev => ({ ...prev, - invoice_number: `${prefix}-${String(nextNum).padStart(5, '0')}` + invoice_number: previewNumber })); } @@ -387,12 +393,28 @@ export default function InvoiceDetail() { if (isNew) { const { data: userData } = await supabase.auth.getUser(); + + // Determine the final invoice number. If the user left the auto-generated + // preview untouched, allocate the authoritative number atomically at save + // time (this is what actually reserves it and guarantees uniqueness). + // If the user typed a custom number, respect it as-is. + let finalInvoiceNumber = invoice.invoice_number; + if (!invoice.invoice_number || invoice.invoice_number === autoInvoiceNumber) { + const { data: numData, error: numError } = await supabase.functions.invoke('allocate-invoice-number'); + if (numError) { + toast({ title: 'Error', description: numError.message, variant: 'destructive' }); + setSaving(false); + return; + } + finalInvoiceNumber = numData.invoice_number; + } + const { data, error } = await supabase .from('invoices') - .insert({ ...invoice, created_by: userData.user?.id } as any) + .insert({ ...invoice, invoice_number: finalInvoiceNumber, created_by: userData.user?.id } as any) .select() .single(); - + if (error) { toast({ title: 'Error', description: error.message, variant: 'destructive' }); setSaving(false); @@ -419,11 +441,9 @@ export default function InvoiceDetail() { await supabase.from('invoice_lines').insert(linesToInsert); } - // Update next invoice number - await supabase - .from('company_settings') - .update({ invoice_next_number: (parseInt(invoice.invoice_number?.split('-').pop() || '0') || 0) + 1 }) - .neq('id', '00000000-0000-0000-0000-000000000000'); + // Note: the invoice number counter is now advanced atomically by the + // allocate-invoice-number function at allocation time above, so there is + // no separate client-side counter update here anymore. toast({ title: 'Success', description: 'Invoice created' }); navigate(`/invoices/${data.id}`); diff --git a/src/pages/JobDetail.tsx b/src/pages/JobDetail.tsx index 975e0b2..19fc181 100644 --- a/src/pages/JobDetail.tsx +++ b/src/pages/JobDetail.tsx @@ -704,16 +704,13 @@ export default function JobDetail() { return; } - // Generate invoice number - const { data: settings } = await supabase - .from('company_settings') - .select('id, invoice_prefix, invoice_next_number') - .limit(1) - .maybeSingle(); - - const prefix = settings?.invoice_prefix || 'INV'; - const nextNum = settings?.invoice_next_number || 1; - const invoiceNumber = `${prefix}-${String(nextNum).padStart(5, '0')}`; + // Allocate invoice number atomically so it's guaranteed unique + const { data: numData, error: numErr } = await supabase.functions.invoke('allocate-invoice-number'); + if (numErr || !numData?.invoice_number) { + toast({ title: 'Error', description: numErr?.message || 'Failed to allocate invoice number', variant: 'destructive' }); + return; + } + const invoiceNumber = numData.invoice_number; // Get unbilled time and expenses (billable AND not already on any invoice) const { data: existingLines } = await supabase @@ -789,13 +786,9 @@ export default function JobDetail() { await supabase.from('invoice_lines').insert(allLines); } - // Update company settings - if (settings) { - await supabase - .from('company_settings') - .update({ invoice_next_number: nextNum + 1 }) - .eq('id', settings.id); - } + // Note: the invoice number counter is now advanced atomically by the + // allocate-invoice-number function at allocation time above, so there is + // no separate client-side counter update here anymore. toast({ title: 'Success', description: 'Invoice created' }); navigate(`/invoices/${invoice.id}`); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index cee06d4..b7d361c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -78,9 +78,9 @@ export default function Login() { {loading ? 'Signing in...' : 'Sign in'}

- Don't have an account?{' '} + Have an invite?{' '} - Sign up + Set up your account

diff --git a/src/pages/SignUp.tsx b/src/pages/SignUp.tsx index a6fe8eb..944640a 100644 --- a/src/pages/SignUp.tsx +++ b/src/pages/SignUp.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -8,26 +8,43 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { useToast } from '@/hooks/use-toast'; export default function SignUp() { - const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [fullName, setFullName] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); const [loading, setLoading] = useState(false); + const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { toast } = useToast(); - const handleSignUp = async (e: React.FormEvent) => { + const token = searchParams.get('token'); + + const handleAcceptInvite = async (e: React.FormEvent) => { e.preventDefault(); + + if (!token) { + return; + } + + if (password !== confirmPassword) { + toast({ + title: 'Error', + description: 'Passwords do not match', + variant: 'destructive', + }); + return; + } + + if (password.length < 6) { + toast({ + title: 'Error', + description: 'Password must be at least 6 characters', + variant: 'destructive', + }); + return; + } + setLoading(true); - const { error } = await supabase.auth.signUp({ - email, - password, - options: { - data: { - full_name: fullName, - }, - }, - }); + const { error } = await supabase.auth.acceptInvite(token, password); if (error) { toast({ @@ -38,53 +55,62 @@ export default function SignUp() { } else { toast({ title: 'Success', - description: 'Account created successfully. You can now sign in.', + description: 'Your account is ready.', }); - navigate('/login'); + navigate('/'); } setLoading(false); }; + if (!token) { + return ( +
+ + + Invitation required + + Accounts are created by invitation. Ask your administrator to invite you. + + + + + + +
+ ); + } + return (
- Create an account + Set your password - Enter your details to create your account + You've been invited. Choose a password to finish setting up your account. -
+
- - setFullName(e.target.value)} - required - /> -
-
- + setEmail(e.target.value)} + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} required + minLength={6} />
- + setPassword(e.target.value)} + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} required minLength={6} /> @@ -92,7 +118,7 @@ export default function SignUp() {

Already have an account?{' '} diff --git a/tsconfig.server.json b/tsconfig.server.json index 0822eb9..b1d4a3f 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -2,8 +2,8 @@ "compilerOptions": { "target": "ES2022", "lib": ["ES2023"], - "module": "CommonJS", - "moduleResolution": "node", + "module": "ESNext", + "moduleResolution": "bundler", "esModuleInterop": true, "skipLibCheck": true, "strict": false, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..37fca6c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['server/**/*.test.ts'], + isolate: true, + testTimeout: 20000, + }, +});