diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000..6c25df6 --- /dev/null +++ b/FORK.md @@ -0,0 +1,69 @@ +# Fork Summary + +This fork adds optional security features and UX improvements with **zero breaking changes** and **minimal migration overhead**. All security features are **disabled by default** via feature flags. + +## Security Features Added + +1. **Password Reset** - Token-based password reset flow (`/auth/password-reset-request`, `/auth/password-reset-confirm`) +2. **Refresh Token Rotation** - Prevents token reuse by rotating refresh tokens on each use +3. **Audit Logging** - Logs security events (logins, password changes, deletions) for compliance + +## UX Improvements Added + +1. **Profile Page** - View and edit personal information, change password (`/profile`) +2. **Select All Button** - Quick selection of all drawings in current view +3. **Sort Dropdown** - Improved sort controls with icons and separate direction toggle +4. **Auto-hide Header** - Editor header auto-hides to maximize drawing space (with toggle) + +## Backward Compatibility + +✅ All security features disabled by default +✅ No breaking changes to existing code +✅ Graceful degradation (missing tables don't cause errors) +✅ Optional database migration + +## Enable Security Features + +Set in `backend/.env`: +```bash +ENABLE_PASSWORD_RESET=true +ENABLE_REFRESH_TOKEN_ROTATION=true +ENABLE_AUDIT_LOGGING=true +``` + +Then run migration: +```bash +cd backend && npx prisma migrate deploy +``` + +## Migration Strategy + +**For base project:** Keep features disabled (default) - no migration needed, zero risk. + +**For this fork:** Enable features via environment variables when ready. + +## Database Changes + +Migration adds 3 optional tables (only used when features enabled): +- `PasswordResetToken` - For password reset flow +- `RefreshToken` - For token rotation tracking +- `AuditLog` - For security event logging + +## Code Changes + +### Backend +- Feature flags in `backend/src/config.ts` +- Conditional logic in auth endpoints +- Graceful error handling for missing tables +- New endpoints: `/auth/profile` (PUT), `/auth/change-password` (POST) +- Audit logging utility (`backend/src/utils/audit.ts`) + +### Frontend +- Password reset pages (`/reset-password`, `/reset-password-confirm`) +- Profile page (`/profile`) +- Select All button in Dashboard +- Sort dropdown with icons +- Auto-hide header in Editor with toggle +- Updated API client for token rotation + +All changes are backward compatible and optional. diff --git a/backend/.env.example b/backend/.env.example index da110e9..61a0957 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,4 +2,10 @@ PORT=8000 NODE_ENV=production DATABASE_URL=file:/app/prisma/dev.db -FRONTEND_URL=http://localhost:6767 \ No newline at end of file +FRONTEND_URL=http://localhost:6767 + +# Optional Feature Flags (all default to false for backward compatibility) +# Set to "true" or "1" to enable: +# ENABLE_PASSWORD_RESET=false +# ENABLE_REFRESH_TOKEN_ROTATION=false +# ENABLE_AUDIT_LOGGING=false \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 77eb32a..7f57712 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,19 +11,29 @@ "dependencies": { "@prisma/client": "^5.22.0", "@types/archiver": "^7.0.0", + "@types/bcrypt": "^6.0.0", "@types/jsdom": "^21.1.7", + "@types/jsonwebtoken": "^9.0.10", + "@types/ms": "^2.1.0", "@types/multer": "^2.0.0", "@types/socket.io": "^3.0.1", + "@types/uuid": "^10.0.0", "archiver": "^7.0.1", + "bcrypt": "^6.0.0", "better-sqlite3": "^12.4.6", "cors": "^2.8.5", "dompurify": "^3.3.0", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "jsdom": "^22.1.0", + "jsonwebtoken": "^9.0.3", + "ms": "^2.1.3", "multer": "^2.0.2", "prisma": "^5.22.0", "socket.io": "^4.8.1", + "uuid": "^13.0.0", "zod": "^4.1.12" }, "devDependencies": { @@ -1001,6 +1011,15 @@ "@types/readdir-glob": "*" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1101,6 +1120,16 @@ "parse5": "^7.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1114,6 +1143,12 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/multer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", @@ -1229,6 +1264,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "4.0.15", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", @@ -1621,6 +1662,20 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/better-sqlite3": { "version": "12.4.6", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.6.tgz", @@ -1789,6 +1844,12 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2282,6 +2343,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2621,6 +2691,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -2955,6 +3043,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -3065,6 +3162,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3243,6 +3349,49 @@ } } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -3291,6 +3440,48 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -3566,6 +3757,26 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", @@ -5032,6 +5243,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index bb277ae..5e61bb4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,19 +16,29 @@ "dependencies": { "@prisma/client": "^5.22.0", "@types/archiver": "^7.0.0", + "@types/bcrypt": "^6.0.0", "@types/jsdom": "^21.1.7", + "@types/jsonwebtoken": "^9.0.10", + "@types/ms": "^2.1.0", "@types/multer": "^2.0.0", "@types/socket.io": "^3.0.1", + "@types/uuid": "^10.0.0", "archiver": "^7.0.1", + "bcrypt": "^6.0.0", "better-sqlite3": "^12.4.6", "cors": "^2.8.5", "dompurify": "^3.3.0", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "jsdom": "^22.1.0", + "jsonwebtoken": "^9.0.3", + "ms": "^2.1.3", "multer": "^2.0.2", "prisma": "^5.22.0", "socket.io": "^4.8.1", + "uuid": "^13.0.0", "zod": "^4.1.12" }, "devDependencies": { diff --git a/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql b/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql new file mode 100644 index 0000000..753f64e --- /dev/null +++ b/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql @@ -0,0 +1,64 @@ +/* + Warnings: + + - Added the required column `userId` to the `Collection` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `Drawing` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Collection" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Collection" ("createdAt", "id", "name", "updatedAt") SELECT "createdAt", "id", "name", "updatedAt" FROM "Collection"; +DROP TABLE "Collection"; +ALTER TABLE "new_Collection" RENAME TO "Collection"; +CREATE TABLE "new_Drawing" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "elements" TEXT NOT NULL, + "appState" TEXT NOT NULL, + "files" TEXT NOT NULL DEFAULT '{}', + "preview" TEXT, + "version" INTEGER NOT NULL DEFAULT 1, + "userId" TEXT NOT NULL, + "collectionId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Drawing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version") SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version" FROM "Drawing"; +DROP TABLE "Drawing"; +ALTER TABLE "new_Drawing" RENAME TO "Drawing"; +CREATE TABLE "new_Library" ( + "id" TEXT NOT NULL PRIMARY KEY, + "items" TEXT NOT NULL DEFAULT '[]', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Library" ("createdAt", "id", "items", "updatedAt") SELECT "createdAt", "id", "items", "updatedAt" FROM "Library"; +DROP TABLE "Library"; +ALTER TABLE "new_Library" RENAME TO "Library"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/backend/prisma/migrations/20260124152839_add_password_reset_audit_refresh_tokens/migration.sql b/backend/prisma/migrations/20260124152839_add_password_reset_audit_refresh_tokens/migration.sql new file mode 100644 index 0000000..e75a511 --- /dev/null +++ b/backend/prisma/migrations/20260124152839_add_password_reset_audit_refresh_tokens/migration.sql @@ -0,0 +1,40 @@ +-- CreateTable +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "revoked" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT, + "action" TEXT NOT NULL, + "resource" TEXT, + "ipAddress" TEXT, + "userAgent" TEXT, + "details" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 23da027..467a0bb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,9 +12,26 @@ datasource db { url = env("DATABASE_URL") } +model User { + id String @id @default(uuid()) + email String @unique + passwordHash String + name String + isActive Boolean @default(true) + drawings Drawing[] + collections Collection[] + passwordResetTokens PasswordResetToken[] + refreshTokens RefreshToken[] + auditLogs AuditLog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Collection { id String @id @default(uuid()) name String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) drawings Drawing[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -28,6 +45,8 @@ model Drawing { files String @default("{}") // Stored as JSON string preview String? // SVG string for thumbnail version Int @default(1) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) collectionId String? collection Collection? @relation(fields: [collectionId], references: [id]) createdAt DateTime @default(now()) @@ -35,8 +54,40 @@ model Drawing { } model Library { - id String @id @default("default") // Singleton pattern - use "default" ID + id String @id // User-specific library ID (e.g., "user_") items String @default("[]") // Stored as JSON string array of library items createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model PasswordResetToken { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + expiresAt DateTime + used Boolean @default(false) + createdAt DateTime @default(now()) +} + +model RefreshToken { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + expiresAt DateTime + revoked Boolean @default(false) + createdAt DateTime @default(now()) +} + +model AuditLog { + id String @id @default(uuid()) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + action String // e.g., "login", "login_failed", "password_reset", "password_changed", "drawing_deleted" + resource String? // e.g., "drawing:123", "collection:456" + ipAddress String? + userAgent String? + details String? // JSON string for additional details + createdAt DateTime @default(now()) +} diff --git a/backend/src/__tests__/drawings.integration.ts b/backend/src/__tests__/drawings.integration.ts index e14d005..0667eaa 100644 --- a/backend/src/__tests__/drawings.integration.ts +++ b/backend/src/__tests__/drawings.integration.ts @@ -315,10 +315,11 @@ describe("Security Sanitization - Image Data URLs", () => { // Database integration tests describe("Drawing API - Database Round-Trip", () => { const prisma = getTestPrisma(); + let testUser: { id: string }; beforeAll(async () => { setupTestDb(); - await initTestDb(prisma); + testUser = await initTestDb(prisma); }); afterAll(async () => { @@ -343,6 +344,7 @@ describe("Drawing API - Database Round-Trip", () => { elements: JSON.stringify([]), appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }), files: JSON.stringify(files), + userId: testUser.id, }, }); @@ -381,6 +383,7 @@ describe("Drawing API - Database Round-Trip", () => { elements: JSON.stringify([]), appState: JSON.stringify({}), files: JSON.stringify(files), + userId: testUser.id, }, }); @@ -404,6 +407,7 @@ describe("Drawing API - Database Round-Trip", () => { elements: JSON.stringify([]), appState: JSON.stringify({}), files: JSON.stringify({}), + userId: testUser.id, }, }); diff --git a/backend/src/__tests__/testUtils.ts b/backend/src/__tests__/testUtils.ts index ee9dbc7..b2555a3 100644 --- a/backend/src/__tests__/testUtils.ts +++ b/backend/src/__tests__/testUtils.ts @@ -54,19 +54,42 @@ export const cleanupTestDb = async (prisma: PrismaClient) => { }); }; +/** + * Create a test user for testing + */ +export const createTestUser = async (prisma: PrismaClient, email: string = "test@example.com") => { + const bcrypt = require("bcrypt"); + const passwordHash = await bcrypt.hash("testpassword", 10); + + return await prisma.user.upsert({ + where: { email }, + update: {}, + create: { + email, + passwordHash, + name: "Test User", + }, + }); +}; + /** * Initialize test database with required data */ export const initTestDb = async (prisma: PrismaClient) => { + // Create a test user first + const testUser = await createTestUser(prisma); + // Ensure Trash collection exists const trash = await prisma.collection.findUnique({ where: { id: "trash" }, }); if (!trash) { await prisma.collection.create({ - data: { id: "trash", name: "Trash" }, + data: { id: "trash", name: "Trash", userId: testUser.id }, }); } + + return testUser; }; /** diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..534c7ff --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,855 @@ +/** + * Authentication routes for user registration and login + */ +import express, { Request, Response } from "express"; +import bcrypt from "bcrypt"; +import jwt, { SignOptions } from "jsonwebtoken"; +import type { StringValue } from "ms"; +import { z } from "zod"; +import { PrismaClient } from "./generated/client"; +import { config } from "./config"; +import { requireAuth } from "./middleware/auth"; +import { sanitizeText } from "./security"; +import rateLimit from "express-rate-limit"; +import { logAuditEvent } from "./utils/audit"; +import crypto from "crypto"; + +interface JwtPayload { + userId: string; + email: string; + type: "access" | "refresh"; +} + +/** + * Type guard to check if decoded JWT is our expected payload structure + */ +const isJwtPayload = (decoded: unknown): decoded is JwtPayload => { + if (typeof decoded !== "object" || decoded === null) { + return false; + } + const payload = decoded as Record; + return ( + typeof payload.userId === "string" && + typeof payload.email === "string" && + (payload.type === "access" || payload.type === "refresh") + ); +}; + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Rate limiting for auth endpoints (stricter than general rate limiting) +const authRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 requests per window + message: { + error: "Too many requests", + message: "Too many authentication attempts, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, +}); + +// Validation schemas +const registerSchema = z.object({ + email: z.string().email().toLowerCase().trim(), + password: z.string().min(8).max(100), + name: z.string().trim().min(1).max(100), +}); + +const loginSchema = z.object({ + email: z.string().email().toLowerCase().trim(), + password: z.string(), +}); + +/** + * Generate JWT tokens (access and refresh) + * Note: expiresIn accepts string (like "15m", "7d") or number (seconds) + */ +const generateTokens = (userId: string, email: string) => { + // jwt.sign accepts StringValue | number for expiresIn + // Our config provides strings which are compatible with StringValue + const signOptions: SignOptions = { + expiresIn: config.jwtAccessExpiresIn as StringValue, + }; + const accessToken = jwt.sign( + { userId, email, type: "access" }, + config.jwtSecret, + signOptions + ); + + const refreshSignOptions: SignOptions = { + expiresIn: config.jwtRefreshExpiresIn as StringValue, + }; + const refreshToken = jwt.sign( + { userId, email, type: "refresh" }, + config.jwtSecret, + refreshSignOptions + ); + + return { accessToken, refreshToken }; +}; + +/** + * POST /auth/register + * Register a new user + */ +router.post("/register", authRateLimiter, async (req: Request, res: Response) => { + try { + const parsed = registerSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid registration data", + }); + } + + const { email, password, name } = parsed.data; + + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + return res.status(409).json({ + error: "Conflict", + message: "User with this email already exists", + }); + } + + // Hash password + const saltRounds = 10; + const passwordHash = await bcrypt.hash(password, saltRounds); + + // Sanitize name + const sanitizedName = sanitizeText(name, 100); + + // Create user + const user = await prisma.user.create({ + data: { + email, + passwordHash, + name: sanitizedName, + }, + select: { + id: true, + email: true, + name: true, + createdAt: true, + }, + }); + + // Create trash collection if it doesn't exist (shared across all users) + // Only create if it doesn't exist - don't update if it does + const existingTrash = await prisma.collection.findUnique({ + where: { id: "trash" }, + }); + if (!existingTrash) { + await prisma.collection.create({ + data: { + id: "trash", + name: "Trash", + userId: user.id, // Use first user's ID, but collection is shared + }, + }); + } + + // Generate tokens + const { accessToken, refreshToken } = generateTokens(user.id, user.email); + + // Store refresh token in database for rotation tracking (if enabled) + if (config.enableRefreshTokenRotation) { + const expiresAt = new Date(); + expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days + + try { + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: refreshToken, + expiresAt, + }, + }); + } catch (error) { + // Gracefully handle missing table (feature disabled) + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token storage skipped (feature disabled or table missing)"); + } + } + } + + // Log user registration (if audit logging enabled) + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "user_registered", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + res.status(201).json({ + user: { + id: user.id, + email: user.email, + name: user.name, + }, + accessToken, + refreshToken, + }); + } catch (error) { + console.error("Registration error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to register user", + }); + } +}); + +/** + * POST /auth/login + * Login with email and password + */ +router.post("/login", authRateLimiter, async (req: Request, res: Response) => { + try { + const parsed = loginSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid login credentials", + }); + } + + const { email, password } = parsed.data; + + // Find user + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + // Don't reveal if user exists (prevent user enumeration) + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid email or password", + }); + } + + if (!user.isActive) { + return res.status(403).json({ + error: "Forbidden", + message: "Account is inactive", + }); + } + + // Verify password + const passwordValid = await bcrypt.compare(password, user.passwordHash); + + if (!passwordValid) { + // Log failed login attempt (if audit logging enabled) + if (config.enableAuditLogging) { + await logAuditEvent({ + action: "login_failed", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { email }, + }); + } + + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid email or password", + }); + } + + // Generate tokens + const { accessToken, refreshToken } = generateTokens(user.id, user.email); + + // Store refresh token in database for rotation tracking (if enabled) + if (config.enableRefreshTokenRotation) { + const expiresAt = new Date(); + expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days + + try { + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: refreshToken, + expiresAt, + }, + }); + } catch (error) { + // Gracefully handle missing table (feature disabled) + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token rotation skipped (feature disabled or table missing)"); + } + } + } + + // Log successful login (if audit logging enabled) + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "login", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + res.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + }, + accessToken, + refreshToken, + }); + } catch (error) { + console.error("Login error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to login", + }); + } +}); + +/** + * POST /auth/refresh + * Refresh access token using refresh token (with rotation) + */ +router.post("/refresh", async (req: Request, res: Response) => { + try { + const { refreshToken: oldRefreshToken } = req.body; + + if (!oldRefreshToken || typeof oldRefreshToken !== "string") { + return res.status(400).json({ + error: "Bad request", + message: "Refresh token required", + }); + } + + try { + const decoded = jwt.verify(oldRefreshToken, config.jwtSecret); + + if (!isJwtPayload(decoded)) { + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid token payload", + }); + } + + if (decoded.type !== "refresh") { + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid token type", + }); + } + + // Verify user still exists and is active + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { id: true, email: true, isActive: true }, + }); + + if (!user || !user.isActive) { + return res.status(401).json({ + error: "Unauthorized", + message: "User account not found or inactive", + }); + } + + // If refresh token rotation is enabled, check database and rotate + if (config.enableRefreshTokenRotation) { + try { + // Check if refresh token exists in database and is not revoked + const storedToken = await prisma.refreshToken.findUnique({ + where: { token: oldRefreshToken }, + }); + + if (!storedToken || storedToken.revoked || storedToken.userId !== user.id) { + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid or revoked refresh token", + }); + } + + // Check if token has expired + if (new Date() > storedToken.expiresAt) { + // Mark as revoked + await prisma.refreshToken.update({ + where: { id: storedToken.id }, + data: { revoked: true }, + }); + return res.status(401).json({ + error: "Unauthorized", + message: "Refresh token has expired", + }); + } + + // Revoke old refresh token + await prisma.refreshToken.update({ + where: { id: storedToken.id }, + data: { revoked: true }, + }); + + // Generate new tokens (rotation) + const { accessToken, refreshToken: newRefreshToken } = generateTokens(user.id, user.email); + + // Store new refresh token + const expiresAt = new Date(); + expiresAt.setTime(expiresAt.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days + + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: newRefreshToken, + expiresAt, + }, + }); + + return res.json({ + accessToken, + refreshToken: newRefreshToken, + }); + } catch (error) { + // If table doesn't exist (feature disabled), fall back to old behavior + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token rotation skipped (feature disabled or table missing)"); + } + // Fall through to old behavior below + } + } + + // Old behavior: just generate new access token (no rotation) + const signOptions: SignOptions = { + expiresIn: config.jwtAccessExpiresIn as StringValue, + }; + const accessToken = jwt.sign( + { userId: user.id, email: user.email, type: "access" }, + config.jwtSecret, + signOptions + ); + + res.json({ accessToken }); + } catch (error) { + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid or expired refresh token", + }); + } + } catch (error) { + console.error("Refresh token error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to refresh token", + }); + } +}); + +/** + * GET /auth/me + * Get current user information + */ +router.get("/me", requireAuth, async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ + error: "Unauthorized", + message: "User not authenticated", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + id: true, + email: true, + name: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!user) { + return res.status(404).json({ + error: "Not found", + message: "User not found", + }); + } + + res.json({ user }); + } catch (error) { + console.error("Get user error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to get user information", + }); + } +}); + +/** + * POST /auth/password-reset-request + * Request a password reset (sends reset token via email) + * Only available if ENABLE_PASSWORD_RESET=true + */ +const passwordResetRequestSchema = z.object({ + email: z.string().email().toLowerCase().trim(), +}); + +router.post("/password-reset-request", authRateLimiter, async (req: Request, res: Response) => { + // Check if password reset feature is enabled + if (!config.enablePasswordReset) { + return res.status(404).json({ + error: "Not found", + message: "Password reset feature is not enabled", + }); + } + try { + const parsed = passwordResetRequestSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid email address", + }); + } + + const { email } = parsed.data; + + // Find user (don't reveal if user exists to prevent enumeration) + const user = await prisma.user.findUnique({ + where: { email }, + }); + + // Always return success to prevent user enumeration + // In production, you would send an email here + if (user && user.isActive) { + // Generate reset token + const resetToken = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 1); // Token expires in 1 hour + + // Invalidate any existing reset tokens for this user + await prisma.passwordResetToken.updateMany({ + where: { userId: user.id, used: false }, + data: { used: true }, + }); + + // Create new reset token + await prisma.passwordResetToken.create({ + data: { + userId: user.id, + token: resetToken, + expiresAt, + }, + }); + + // Log password reset request (if audit logging enabled) + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "password_reset_requested", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + // In production, send email with reset link + // For now, we'll return the token in development (remove in production!) + if (config.nodeEnv === "development") { + console.log(`[DEV] Password reset token for ${email}: ${resetToken}`); + console.log(`[DEV] Reset URL: ${config.frontendUrl}/reset-password?token=${resetToken}`); + } + } + + // Always return success message (security best practice) + res.json({ + message: "If an account with that email exists, a password reset link has been sent.", + }); + } catch (error) { + console.error("Password reset request error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to process password reset request", + }); + } +}); + +/** + * POST /auth/password-reset-confirm + * Confirm password reset with token + * Only available if ENABLE_PASSWORD_RESET=true + */ +const passwordResetConfirmSchema = z.object({ + token: z.string().min(1), + password: z.string().min(8).max(100), +}); + +router.post("/password-reset-confirm", authRateLimiter, async (req: Request, res: Response) => { + // Check if password reset feature is enabled + if (!config.enablePasswordReset) { + return res.status(404).json({ + error: "Not found", + message: "Password reset feature is not enabled", + }); + } + try { + const parsed = passwordResetConfirmSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid reset data", + }); + } + + const { token, password } = parsed.data; + + // Find reset token + const resetToken = await prisma.passwordResetToken.findUnique({ + where: { token }, + include: { user: true }, + }); + + if (!resetToken || resetToken.used) { + return res.status(400).json({ + error: "Invalid token", + message: "Password reset token is invalid or has already been used", + }); + } + + if (new Date() > resetToken.expiresAt) { + return res.status(400).json({ + error: "Expired token", + message: "Password reset token has expired", + }); + } + + if (!resetToken.user.isActive) { + return res.status(403).json({ + error: "Forbidden", + message: "Account is inactive", + }); + } + + // Hash new password + const saltRounds = 10; + const passwordHash = await bcrypt.hash(password, saltRounds); + + // Update user password + await prisma.user.update({ + where: { id: resetToken.userId }, + data: { passwordHash }, + }); + + // Mark reset token as used + await prisma.passwordResetToken.update({ + where: { id: resetToken.id }, + data: { used: true }, + }); + + // Revoke all refresh tokens for this user (force re-login) - if rotation enabled + if (config.enableRefreshTokenRotation) { + try { + await prisma.refreshToken.updateMany({ + where: { userId: resetToken.userId, revoked: false }, + data: { revoked: true }, + }); + } catch (error) { + // Gracefully handle missing table + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + } + + // Log password change (if audit logging enabled) + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: resetToken.userId, + action: "password_changed", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + res.json({ + message: "Password has been reset successfully", + }); + } catch (error) { + console.error("Password reset confirm error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to reset password", + }); + } +}); + +/** + * PUT /auth/profile + * Update user profile (name) + */ +const updateProfileSchema = z.object({ + name: z.string().trim().min(1).max(100), +}); + +router.put("/profile", requireAuth, async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ + error: "Unauthorized", + message: "User not authenticated", + }); + } + + const parsed = updateProfileSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid name format", + }); + } + + const { name } = parsed.data; + const sanitizedName = sanitizeText(name, 100); + + // Update user name + const updatedUser = await prisma.user.update({ + where: { id: req.user.id }, + data: { name: sanitizedName }, + select: { + id: true, + email: true, + name: true, + createdAt: true, + updatedAt: true, + }, + }); + + // Log profile update (if audit logging enabled) + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "profile_updated", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { field: "name" }, + }); + } + + res.json({ user: updatedUser }); + } catch (error) { + console.error("Update profile error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update profile", + }); + } +}); + +/** + * POST /auth/change-password + * Change password (requires current password) + */ +const changePasswordSchema = z.object({ + currentPassword: z.string(), + newPassword: z.string().min(8).max(100), +}); + +router.post("/change-password", requireAuth, authRateLimiter, async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ + error: "Unauthorized", + message: "User not authenticated", + }); + } + + const parsed = changePasswordSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid password data", + }); + } + + const { currentPassword, newPassword } = parsed.data; + + // Get user with password hash + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { id: true, passwordHash: true, isActive: true }, + }); + + if (!user || !user.isActive) { + return res.status(404).json({ + error: "Not found", + message: "User not found", + }); + } + + // Verify current password + const passwordValid = await bcrypt.compare(currentPassword, user.passwordHash); + + if (!passwordValid) { + return res.status(401).json({ + error: "Unauthorized", + message: "Current password is incorrect", + }); + } + + // Hash new password + const saltRounds = 10; + const passwordHash = await bcrypt.hash(newPassword, saltRounds); + + // Update password + await prisma.user.update({ + where: { id: user.id }, + data: { passwordHash }, + }); + + // Revoke all refresh tokens for this user (force re-login) - if rotation enabled + if (config.enableRefreshTokenRotation) { + try { + await prisma.refreshToken.updateMany({ + where: { userId: user.id, revoked: false }, + data: { revoked: true }, + }); + } catch (error) { + // Gracefully handle missing table + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + } + + // Log password change (if audit logging enabled) + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "password_changed", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { method: "change_password" }, + }); + } + + res.json({ + message: "Password changed successfully", + }); + } catch (error) { + console.error("Change password error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to change password", + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..7f92e13 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,87 @@ +/** + * Configuration validation and environment variable management + */ +import dotenv from "dotenv"; + +dotenv.config(); + +interface Config { + port: number; + nodeEnv: string; + databaseUrl: string; + frontendUrl: string; + jwtSecret: string; + jwtAccessExpiresIn: string; + jwtRefreshExpiresIn: string; + rateLimitMaxRequests: number; + csrfMaxRequests: number; + csrfSecret: string | null; + // Feature flags - all default to false for backward compatibility + enablePasswordReset: boolean; + enableRefreshTokenRotation: boolean; + enableAuditLogging: boolean; +} + +const getRequiredEnv = (key: string): string => { + const value = process.env[key]; + if (!value || value.trim().length === 0) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +}; + +const getOptionalEnv = (key: string, defaultValue: string): string => { + return process.env[key] || defaultValue; +}; + +const getOptionalBoolean = (key: string, defaultValue: boolean): boolean => { + const value = process.env[key]; + if (!value) return defaultValue; + return value.toLowerCase() === "true" || value === "1"; +}; + +const getRequiredEnvNumber = (key: string, defaultValue: number): number => { + const value = process.env[key]; + if (!value) return defaultValue; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid value for environment variable ${key}: must be a positive number`); + } + return parsed; +}; + +export const config: Config = { + port: getRequiredEnvNumber("PORT", 8000), + nodeEnv: getOptionalEnv("NODE_ENV", "development"), + databaseUrl: getRequiredEnv("DATABASE_URL"), + frontendUrl: getOptionalEnv("FRONTEND_URL", "http://localhost:6767"), + jwtSecret: getRequiredEnv("JWT_SECRET"), + jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"), + jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"), + rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000), + csrfMaxRequests: getRequiredEnvNumber("CSRF_MAX_REQUESTS", 60), + csrfSecret: process.env.CSRF_SECRET || null, + // Feature flags - disabled by default for backward compatibility + enablePasswordReset: getOptionalBoolean("ENABLE_PASSWORD_RESET", false), + enableRefreshTokenRotation: getOptionalBoolean("ENABLE_REFRESH_TOKEN_ROTATION", false), + enableAuditLogging: getOptionalBoolean("ENABLE_AUDIT_LOGGING", false), +}; + +// Validate JWT_SECRET strength in production +if (config.nodeEnv === "production") { + if (config.jwtSecret.length < 32) { + throw new Error("JWT_SECRET must be at least 32 characters long in production"); + } + if (config.jwtSecret === "your-secret-key-change-in-production") { + throw new Error("JWT_SECRET must be changed from default value in production"); + } +} + +// Validate frontend URL format +try { + new URL(config.frontendUrl); +} catch { + throw new Error(`Invalid FRONTEND_URL format: ${config.frontendUrl}`); +} + +console.log("Configuration validated successfully"); \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 506646e..aae2c27 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,5 @@ import express from "express"; import cors from "cors"; -import dotenv from "dotenv"; import path from "path"; import fs from "fs"; import { promises as fsPromises } from "fs"; @@ -10,6 +9,9 @@ import { Worker } from "worker_threads"; import multer from "multer"; import archiver from "archiver"; import { z } from "zod"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import { v4 as uuidv4 } from "uuid"; import { PrismaClient, Prisma } from "./generated/client"; import { sanitizeDrawingData, @@ -23,8 +25,11 @@ import { getCsrfTokenHeader, getOriginFromReferer, } from "./security"; - -dotenv.config(); +import { config } from "./config"; +import { requireAuth } from "./middleware/auth"; +import { errorHandler, asyncHandler } from "./middleware/errorHandler"; +import authRouter from "./auth"; +import { logAuditEvent } from "./utils/audit"; const backendRoot = path.resolve(__dirname, "../"); const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); @@ -93,7 +98,7 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => { return parsed.length > 0 ? parsed : [fallback]; }; -const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL); +const allowedOrigins = normalizeOrigins(config.frontendUrl); console.log("Allowed origins:", allowedOrigins); const isDev = (process.env.NODE_ENV || "development") !== "production"; @@ -220,7 +225,7 @@ const getCachedDrawingsBody = (key: string): Buffer | null => { return entry.body; }; -const cacheDrawingsResponse = (key: string, payload: any): Buffer => { +const cacheDrawingsResponse = (key: string, payload: unknown): Buffer => { const body = Buffer.from(JSON.stringify(payload)); drawingsCache.set(key, { body, @@ -233,6 +238,29 @@ const invalidateDrawingsCache = () => { drawingsCache.clear(); }; +/** + * Ensure trash collection exists (shared across all users) + * This is needed because Prisma enforces foreign key constraints + * The trash collection is shared - drawings are still filtered by userId + */ +const ensureTrashCollection = async (userId: string): Promise => { + const trashCollection = await prisma.collection.findUnique({ + where: { id: "trash" }, + }); + + if (!trashCollection) { + // Create trash collection (use first user's ID, but it's shared) + await prisma.collection.create({ + data: { + id: "trash", + name: "Trash", + userId, // Use current user's ID, but collection is shared + }, + }); + } + // If it already exists, don't update it - it's shared +}; + setInterval(() => { const now = Date.now(); for (const [key, entry] of drawingsCache.entries()) { @@ -242,7 +270,7 @@ setInterval(() => { } }, 60_000).unref(); -const PORT = process.env.PORT || 8000; +const PORT = config.port; const upload = multer({ dest: uploadDir, @@ -263,53 +291,89 @@ const upload = multer({ }, }); +// Request ID middleware (must be early in the chain) +app.use((req, res, next) => { + const requestId = uuidv4(); + req.headers["x-request-id"] = requestId; + res.setHeader("X-Request-ID", requestId); + next(); +}); + +// HTTPS enforcement in production +if (config.nodeEnv === "production") { + app.use((req, res, next) => { + if (req.header("x-forwarded-proto") !== "https") { + res.redirect(`https://${req.header("host")}${req.url}`); + } else { + next(); + } + }); +} + +// Helmet security headers +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: [ + "'self'", + "'unsafe-inline'", // Required for Excalidraw + "'unsafe-eval'", // Required for Excalidraw + "https://cdn.jsdelivr.net", + "https://unpkg.com", + ], + styleSrc: [ + "'self'", + "'unsafe-inline'", // Required for Excalidraw + "https://fonts.googleapis.com", + ], + fontSrc: ["'self'", "https://fonts.gstatic.com"], + imgSrc: ["'self'", "data:", "blob:", "https:"], + connectSrc: ["'self'", "ws:", "wss:"], + frameAncestors: ["'none'"], + }, + }, + hsts: { + maxAge: 31536000, // 1 year + includeSubDomains: true, + preload: true, + }, + }) +); + app.use( cors({ origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)), credentials: true, allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"], - exposedHeaders: ["x-csrf-token"], + exposedHeaders: ["x-csrf-token", "x-request-id"], }) ); app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ extended: true, limit: "50mb" })); +// Request logging middleware app.use((req, res, next) => { + const requestId = req.headers["x-request-id"] || "unknown"; const contentLength = req.headers["content-length"]; + const userEmail = req.user?.email || "anonymous"; + if (contentLength) { const sizeInMB = parseInt(contentLength, 10) / 1024 / 1024; if (sizeInMB > 10) { console.log( `[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed( 2 - )}MB - Content-Length: ${contentLength} bytes` + )}MB - User: ${userEmail} - RequestID: ${requestId}` ); } } - next(); -}); - -app.use((req, res, next) => { - res.setHeader("X-Content-Type-Options", "nosniff"); - res.setHeader("X-Frame-Options", "DENY"); - res.setHeader("X-XSS-Protection", "1; mode=block"); - res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); - res.setHeader( - "Permissions-Policy", - "geolocation=(), microphone=(), camera=()" + + console.log( + `[REQUEST] ${req.method} ${req.path} - User: ${userEmail} - IP: ${req.ip} - RequestID: ${requestId}` ); - - res.setHeader( - "Content-Security-Policy", - "default-src 'self'; " + - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " + - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + - "font-src 'self' https://fonts.gstatic.com; " + - "img-src 'self' data: blob: https:; " + - "connect-src 'self' ws: wss:; " + - "frame-ancestors 'none';" - ); - + next(); }); @@ -325,35 +389,20 @@ setInterval(() => { } }, 5 * 60 * 1000).unref(); -const RATE_LIMIT_MAX_REQUESTS = (() => { - const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS); - if (!Number.isFinite(parsed) || parsed <= 0) { - return 1000; - } - return parsed; -})(); - -app.use((req, res, next) => { - const ip = req.ip || req.connection.remoteAddress || "unknown"; - const now = Date.now(); - const clientData = requestCounts.get(ip); - - if (!clientData || now > clientData.resetTime) { - requestCounts.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); - return next(); - } - - if (clientData.count >= RATE_LIMIT_MAX_REQUESTS) { - return res.status(429).json({ - error: "Rate limit exceeded", - message: "Too many requests, please try again later", - }); - } - - clientData.count++; - next(); +// General rate limiting with express-rate-limit +const generalRateLimiter = rateLimit({ + windowMs: RATE_LIMIT_WINDOW, + max: config.rateLimitMaxRequests, + message: { + error: "Rate limit exceeded", + message: "Too many requests, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, }); +app.use(generalRateLimiter); + // CSRF Protection Middleware // Generates a unique client ID based on IP and User-Agent for token association const getClientId = (req: express.Request): string => { @@ -488,11 +537,21 @@ const csrfProtectionMiddleware = ( next(); }; -// Apply CSRF protection to all routes -app.use(csrfProtectionMiddleware); +// Apply CSRF protection to all routes (except auth endpoints) +app.use((req, res, next) => { + // Skip CSRF for auth endpoints + if (req.path.startsWith("/auth/")) { + return next(); + } + csrfProtectionMiddleware(req, res, next); +}); +// Authentication routes (no CSRF required, uses JWT) +app.use("/auth", authRouter); + +// Files field can contain arbitrary file metadata, so we use unknown and validate structure const filesFieldSchema = z - .union([z.record(z.string(), z.any()), z.null()]) + .union([z.record(z.string(), z.unknown()), z.null()]) .optional() .transform((value) => (value === null ? undefined : value)); @@ -578,12 +637,23 @@ const respondWithValidationErrors = ( res: express.Response, issues: z.ZodIssue[] ) => { - res.status(400).json({ - error: "Invalid drawing payload", - details: issues, - }); + // In production, don't expose validation details + if (config.nodeEnv === "production") { + res.status(400).json({ + error: "Validation error", + message: "Invalid request data", + }); + } else { + res.status(400).json({ + error: "Invalid drawing payload", + details: issues, + }); + } }; +// Collection name validation schema +const collectionNameSchema = z.string().trim().min(1).max(100); + const validateSqliteHeader = (filePath: string): boolean => { try { const buffer = Buffer.alloc(16); @@ -745,30 +815,53 @@ app.get("/health", (req, res) => { res.status(200).json({ status: "ok" }); }); -app.get("/drawings", async (req, res) => { - try { - const { search, collectionId, includeData } = req.query; - const where: any = {}; - const searchTerm = - typeof search === "string" && search.trim().length > 0 - ? search.trim() - : undefined; +// Health check endpoint doesn't require auth - if (searchTerm) { - where.name = { contains: searchTerm }; - } +app.get("/drawings", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } - let collectionFilterKey = "default"; - if (collectionId === "null") { - where.collectionId = null; - collectionFilterKey = "null"; - } else if (collectionId) { - const normalizedCollectionId = String(collectionId); + const { search, collectionId, includeData } = req.query; + const where: Prisma.DrawingWhereInput = { + userId: req.user.id, // Filter by user + }; + const searchTerm = + typeof search === "string" && search.trim().length > 0 + ? search.trim() + : undefined; + + if (searchTerm) { + where.name = { contains: searchTerm }; + } + + let collectionFilterKey = "default"; + if (collectionId === "null") { + where.collectionId = null; + collectionFilterKey = "null"; + } else if (collectionId) { + const normalizedCollectionId = String(collectionId); + // Special handling for trash collection + if (normalizedCollectionId === "trash") { + where.collectionId = "trash"; + collectionFilterKey = "trash"; + } else { + // Verify collection belongs to user + const collection = await prisma.collection.findFirst({ + where: { + id: normalizedCollectionId, + userId: req.user.id, + }, + }); + if (!collection) { + return res.status(404).json({ error: "Collection not found" }); + } where.collectionId = normalizedCollectionId; collectionFilterKey = `id:${normalizedCollectionId}`; - } else { - where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }]; } + } else { + where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }]; + } const shouldIncludeData = typeof includeData === "string" @@ -809,10 +902,17 @@ app.get("/drawings", async (req, res) => { const drawings = await prisma.drawing.findMany(queryOptions); - let responsePayload: any = drawings; + type DrawingResponse = Prisma.DrawingGetPayload; + type DrawingWithParsedData = Omit & { + elements: unknown[]; + appState: Record; + files: Record; + }; + + let responsePayload: DrawingResponse[] | DrawingWithParsedData[] = drawings; if (shouldIncludeData) { - responsePayload = drawings.map((d: any) => ({ + responsePayload = drawings.map((d): DrawingWithParsedData => ({ ...d, elements: parseJsonField(d.elements, []), appState: parseJsonField(d.appState, {}), @@ -824,318 +924,485 @@ app.get("/drawings", async (req, res) => { res.setHeader("X-Cache", "MISS"); res.setHeader("Content-Type", "application/json"); return res.send(body); - } catch (error) { - console.error(error); - res.status(500).json({ error: "Failed to fetch drawings" }); +})); + +app.get("/drawings/:id", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); } -}); -app.get("/drawings/:id", async (req, res) => { - try { - const { id } = req.params; - console.log("[API] Fetching drawing", { id }); - const drawing = await prisma.drawing.findUnique({ where: { id } }); + const { id } = req.params; + console.log("[API] Fetching drawing", { id, userId: req.user.id }); + const drawing = await prisma.drawing.findFirst({ + where: { + id, + userId: req.user.id, // Ensure user owns the drawing + }, + }); - if (!drawing) { - console.warn("[API] Drawing not found", { id }); - return res.status(404).json({ error: "Drawing not found" }); - } + if (!drawing) { + console.warn("[API] Drawing not found", { id, userId: req.user.id }); + return res.status(404).json({ error: "Drawing not found" }); + } - res.json({ - ...drawing, - elements: JSON.parse(drawing.elements), - appState: JSON.parse(drawing.appState), - files: JSON.parse(drawing.files || "{}"), + res.json({ + ...drawing, + elements: JSON.parse(drawing.elements), + appState: JSON.parse(drawing.appState), + files: JSON.parse(drawing.files || "{}"), + }); +})); + +app.post("/drawings", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const isImportedDrawing = req.headers["x-imported-file"] === "true"; + + if (isImportedDrawing && !validateImportedDrawing(req.body)) { + return res.status(400).json({ + error: "Invalid imported drawing file", + message: + "The imported file contains potentially malicious content or invalid structure", }); - } catch (error) { - res.status(500).json({ error: "Failed to fetch drawing" }); } -}); -app.post("/drawings", async (req, res) => { - try { - const isImportedDrawing = req.headers["x-imported-file"] === "true"; + const parsed = drawingCreateSchema.safeParse(req.body); + if (!parsed.success) { + return respondWithValidationErrors(res, parsed.error.issues); + } - if (isImportedDrawing && !validateImportedDrawing(req.body)) { - return res.status(400).json({ - error: "Invalid imported drawing file", - message: - "The imported file contains potentially malicious content or invalid structure", - }); - } + const payload = parsed.data; + const drawingName = payload.name ?? "Untitled Drawing"; + let targetCollectionId = + payload.collectionId === undefined ? null : payload.collectionId; - const parsed = drawingCreateSchema.safeParse(req.body); - if (!parsed.success) { - return respondWithValidationErrors(res, parsed.error.issues); - } - - const payload = parsed.data; - const drawingName = payload.name ?? "Untitled Drawing"; - const targetCollectionId = - payload.collectionId === undefined ? null : payload.collectionId; - - const newDrawing = await prisma.drawing.create({ - data: { - name: drawingName, - elements: JSON.stringify(payload.elements), - appState: JSON.stringify(payload.appState), - collectionId: targetCollectionId, - preview: payload.preview ?? null, - files: JSON.stringify(payload.files ?? {}), + // Verify collection belongs to user if provided (except for special "trash" collection) + if (targetCollectionId && targetCollectionId !== "trash") { + const collection = await prisma.collection.findFirst({ + where: { + id: targetCollectionId, + userId: req.user.id, }, }); - invalidateDrawingsCache(); - - res.json({ - ...newDrawing, - elements: JSON.parse(newDrawing.elements), - appState: JSON.parse(newDrawing.appState), - files: JSON.parse(newDrawing.files || "{}"), - }); - } catch (error) { - console.error("Failed to create drawing:", error); - res.status(500).json({ error: "Failed to create drawing" }); + if (!collection) { + return res.status(404).json({ error: "Collection not found" }); + } + } else if (targetCollectionId === "trash") { + // Ensure trash collection exists for this user + await ensureTrashCollection(req.user.id); } -}); -app.put("/drawings/:id", async (req, res) => { - try { - const { id } = req.params; + const newDrawing = await prisma.drawing.create({ + data: { + name: drawingName, + elements: JSON.stringify(payload.elements), + appState: JSON.stringify(payload.appState), + userId: req.user.id, + collectionId: targetCollectionId, + preview: payload.preview ?? null, + files: JSON.stringify(payload.files ?? {}), + }, + }); + invalidateDrawingsCache(); - const parsed = drawingUpdateSchema.safeParse(req.body); - if (!parsed.success) { + res.json({ + ...newDrawing, + elements: JSON.parse(newDrawing.elements), + appState: JSON.parse(newDrawing.appState), + files: JSON.parse(newDrawing.files || "{}"), + }); +})); + +app.put("/drawings/:id", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { id } = req.params; + + // Verify drawing belongs to user + const existingDrawing = await prisma.drawing.findFirst({ + where: { + id, + userId: req.user.id, + }, + }); + + if (!existingDrawing) { + return res.status(404).json({ error: "Drawing not found" }); + } + + const parsed = drawingUpdateSchema.safeParse(req.body); + if (!parsed.success) { + if (config.nodeEnv === "development") { console.error("[API] Validation failed", { id, errorCount: parsed.error.issues.length, - errors: parsed.error.issues.map((issue) => ({ - path: issue.path, - message: issue.message, - received: - issue.path.length > 0 ? req.body?.[issue.path.join(".")] : "root", - })), + errors: parsed.error.issues, }); - return respondWithValidationErrors(res, parsed.error.issues); } + return respondWithValidationErrors(res, parsed.error.issues); + } - const payload = parsed.data; + const payload = parsed.data; - const data: any = { - version: { increment: 1 }, - }; + const data: Prisma.DrawingUpdateInput = { + version: { increment: 1 }, + }; - if (payload.name !== undefined) data.name = payload.name; - if (payload.elements !== undefined) - data.elements = JSON.stringify(payload.elements); - if (payload.appState !== undefined) - data.appState = JSON.stringify(payload.appState); - if (payload.files !== undefined) data.files = JSON.stringify(payload.files); - if (payload.collectionId !== undefined) - data.collectionId = payload.collectionId; - if (payload.preview !== undefined) data.preview = payload.preview; + if (payload.name !== undefined) data.name = payload.name; + if (payload.elements !== undefined) + data.elements = JSON.stringify(payload.elements); + if (payload.appState !== undefined) + data.appState = JSON.stringify(payload.appState); + if (payload.files !== undefined) data.files = JSON.stringify(payload.files); + if (payload.collectionId !== undefined) { + // Special handling for trash collection - ensure it exists first + if (payload.collectionId === "trash") { + await ensureTrashCollection(req.user.id); + (data as Prisma.DrawingUncheckedUpdateInput).collectionId = "trash"; + } else if (payload.collectionId) { + // Verify collection belongs to user if provided + const collection = await prisma.collection.findFirst({ + where: { + id: payload.collectionId, + userId: req.user.id, + }, + }); + if (!collection) { + return res.status(404).json({ error: "Collection not found" }); + } + (data as Prisma.DrawingUncheckedUpdateInput).collectionId = payload.collectionId; + } else { + // null collectionId (Unorganized) + (data as Prisma.DrawingUncheckedUpdateInput).collectionId = null; + } + } + if (payload.preview !== undefined) data.preview = payload.preview; - const updatedDrawing = await prisma.drawing.update({ + const updatedDrawing = await prisma.drawing.update({ + where: { id }, + data, + }); + invalidateDrawingsCache(); + + res.json({ + ...updatedDrawing, + elements: JSON.parse(updatedDrawing.elements), + appState: JSON.parse(updatedDrawing.appState), + files: JSON.parse(updatedDrawing.files || "{}"), + }); +})); + +app.delete("/drawings/:id", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { id } = req.params; + + // Verify drawing belongs to user + const drawing = await prisma.drawing.findFirst({ + where: { + id, + userId: req.user.id, + }, + }); + + if (!drawing) { + return res.status(404).json({ error: "Drawing not found" }); + } + + await prisma.drawing.delete({ where: { id } }); + invalidateDrawingsCache(); + + // Log deletion (if audit logging enabled) + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "drawing_deleted", + resource: `drawing:${id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { drawingId: id, drawingName: drawing.name }, + }); + } + + res.json({ success: true }); +})); + +app.post("/drawings/:id/duplicate", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { id } = req.params; + const original = await prisma.drawing.findFirst({ + where: { + id, + userId: req.user.id, + }, + }); + + if (!original) { + return res.status(404).json({ error: "Original drawing not found" }); + } + + const newDrawing = await prisma.drawing.create({ + data: { + name: `${original.name} (Copy)`, + elements: original.elements, + appState: original.appState, + files: original.files, + userId: req.user.id, + collectionId: original.collectionId, + version: 1, + }, + }); + invalidateDrawingsCache(); + + res.json({ + ...newDrawing, + elements: JSON.parse(newDrawing.elements), + appState: JSON.parse(newDrawing.appState), + files: JSON.parse(newDrawing.files || "{}"), + }); +})); + +app.get("/collections", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const collections = await prisma.collection.findMany({ + where: { + userId: req.user.id, + }, + orderBy: { createdAt: "desc" }, + }); + res.json(collections); +})); + +app.post("/collections", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const parsed = collectionNameSchema.safeParse(req.body.name); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Collection name must be between 1 and 100 characters", + }); + } + + const sanitizedName = sanitizeText(parsed.data, 100); + const newCollection = await prisma.collection.create({ + data: { + name: sanitizedName, + userId: req.user.id, + }, + }); + res.json(newCollection); +})); + +app.put("/collections/:id", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { id } = req.params; + + // Verify collection belongs to user + const existingCollection = await prisma.collection.findFirst({ + where: { + id, + userId: req.user.id, + }, + }); + + if (!existingCollection) { + return res.status(404).json({ error: "Collection not found" }); + } + + const parsed = collectionNameSchema.safeParse(req.body.name); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Collection name must be between 1 and 100 characters", + }); + } + + const sanitizedName = sanitizeText(parsed.data, 100); + const updatedCollection = await prisma.collection.update({ + where: { id }, + data: { name: sanitizedName }, + }); + res.json(updatedCollection); +})); + +app.delete("/collections/:id", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { id } = req.params; + + // Verify collection belongs to user + const collection = await prisma.collection.findFirst({ + where: { + id, + userId: req.user.id, + }, + }); + + if (!collection) { + return res.status(404).json({ error: "Collection not found" }); + } + + await prisma.$transaction([ + prisma.drawing.updateMany({ + where: { collectionId: id, userId: req.user.id }, + data: { collectionId: null }, + }), + prisma.collection.delete({ where: { id }, - data, - }); - invalidateDrawingsCache(); + }), + ]); + invalidateDrawingsCache(); - res.json({ - ...updatedDrawing, - elements: JSON.parse(updatedDrawing.elements), - appState: JSON.parse(updatedDrawing.appState), - files: JSON.parse(updatedDrawing.files || "{}"), + // Log collection deletion (if audit logging enabled) + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "collection_deleted", + resource: `collection:${id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { collectionId: id, collectionName: collection.name }, }); - } catch (error) { - console.error("[CRITICAL] Update failed:", error); - res.status(500).json({ error: "Failed to update drawing" }); } -}); -app.delete("/drawings/:id", async (req, res) => { - try { - const { id } = req.params; - await prisma.drawing.delete({ where: { id } }); - invalidateDrawingsCache(); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: "Failed to delete drawing" }); + res.json({ success: true }); +})); + +app.get("/library", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); } -}); -app.post("/drawings/:id/duplicate", async (req, res) => { - try { - const { id } = req.params; - const original = await prisma.drawing.findUnique({ where: { id } }); + // Library is user-specific, use userId as the key + const libraryId = `user_${req.user.id}`; + const library = await prisma.library.findUnique({ + where: { id: libraryId }, + }); - if (!original) { - return res.status(404).json({ error: "Original drawing not found" }); - } - - const newDrawing = await prisma.drawing.create({ - data: { - name: `${original.name} (Copy)`, - elements: original.elements, - appState: original.appState, - files: original.files, - collectionId: original.collectionId, - version: 1, - }, - }); - invalidateDrawingsCache(); - - res.json({ - ...newDrawing, - elements: JSON.parse(newDrawing.elements), - appState: JSON.parse(newDrawing.appState), - files: JSON.parse(newDrawing.files || "{}"), - }); - } catch (error) { - res.status(500).json({ error: "Failed to duplicate drawing" }); + if (!library) { + return res.json({ items: [] }); } -}); -app.get("/collections", async (req, res) => { - try { - const collections = await prisma.collection.findMany({ - orderBy: { createdAt: "desc" }, - }); - res.json(collections); - } catch (error) { - console.error(error); - res.status(500).json({ error: "Failed to fetch collections" }); + res.json({ + items: JSON.parse(library.items), + }); +})); + +app.put("/library", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); } -}); -app.post("/collections", async (req, res) => { - try { - const { name } = req.body; - const newCollection = await prisma.collection.create({ - data: { name }, - }); - res.json(newCollection); - } catch (error) { - res.status(500).json({ error: "Failed to create collection" }); + const { items } = req.body; + + if (!Array.isArray(items)) { + return res.status(400).json({ error: "Items must be an array" }); } -}); -app.put("/collections/:id", async (req, res) => { - try { - const { id } = req.params; - const { name } = req.body; - const updatedCollection = await prisma.collection.update({ - where: { id }, - data: { name }, - }); - res.json(updatedCollection); - } catch (error) { - res.status(500).json({ error: "Failed to update collection" }); + // Library is user-specific, use userId as the key + const libraryId = `user_${req.user.id}`; + const library = await prisma.library.upsert({ + where: { id: libraryId }, + update: { + items: JSON.stringify(items), + }, + create: { + id: libraryId, + items: JSON.stringify(items), + }, + }); + + res.json({ + items: JSON.parse(library.items), + }); +})); + +app.get("/export", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); } -}); -app.delete("/collections/:id", async (req, res) => { - try { - const { id } = req.params; - await prisma.$transaction([ - prisma.drawing.updateMany({ - where: { collectionId: id }, - data: { collectionId: null }, - }), - prisma.collection.delete({ - where: { id }, - }), - ]); - invalidateDrawingsCache(); + // Export only user's data as JSON, not the entire database + const formatParam = + typeof req.query.format === "string" + ? req.query.format.toLowerCase() + : undefined; - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: "Failed to delete collection" }); + if (formatParam === "db" || formatParam === "sqlite") { + // Database export should be admin-only, return 403 for regular users + return res.status(403).json({ + error: "Forbidden", + message: "Database export is not available", + }); } -}); -app.get("/library", async (req, res) => { - try { - const library = await prisma.library.findUnique({ - where: { id: "default" }, - }); + // Export user's drawings as JSON + const drawings = await prisma.drawing.findMany({ + where: { + userId: req.user.id, + }, + include: { + collection: true, + }, + }); - if (!library) { - return res.json({ items: [] }); - } + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "Content-Disposition", + `attachment; filename="excalidash-export-${new Date().toISOString().split("T")[0]}.json"` + ); - res.json({ - items: JSON.parse(library.items), - }); - } catch (error) { - console.error("Failed to fetch library:", error); - res.status(500).json({ error: "Failed to fetch library" }); + res.json({ + version: "1.0", + exportedAt: new Date().toISOString(), + userId: req.user.id, + drawings: drawings.map((d: any) => ({ + id: d.id, + name: d.name, + elements: JSON.parse(d.elements), + appState: JSON.parse(d.appState), + files: JSON.parse(d.files || "{}"), + collectionId: d.collectionId, + collectionName: d.collection?.name || null, + createdAt: d.createdAt, + updatedAt: d.updatedAt, + })), + }); +})); + +app.get("/export/json", requireAuth, asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); } -}); -app.put("/library", async (req, res) => { - try { - const { items } = req.body; - - if (!Array.isArray(items)) { - return res.status(400).json({ error: "Items must be an array" }); - } - - const library = await prisma.library.upsert({ - where: { id: "default" }, - update: { - items: JSON.stringify(items), - }, - create: { - id: "default", - items: JSON.stringify(items), - }, - }); - - res.json({ - items: JSON.parse(library.items), - }); - } catch (error) { - console.error("Failed to update library:", error); - res.status(500).json({ error: "Failed to update library" }); - } -}); - -app.get("/export", async (req, res) => { - try { - const formatParam = - typeof req.query.format === "string" - ? req.query.format.toLowerCase() - : undefined; - const extension = formatParam === "db" ? "db" : "sqlite"; - const dbPath = getResolvedDbPath(); - - try { - await fsPromises.access(dbPath); - } catch { - return res.status(404).json({ error: "Database file not found" }); - } - - res.setHeader("Content-Type", "application/octet-stream"); - res.setHeader( - "Content-Disposition", - `attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0] - }.${extension}"` - ); - - const fileStream = fs.createReadStream(dbPath); - fileStream.pipe(res); - } catch (error) { - console.error(error); - res.status(500).json({ error: "Failed to export database" }); - } -}); - -app.get("/export/json", async (req, res) => { - try { - const drawings = await prisma.drawing.findMany({ - include: { - collection: true, - }, - }); + const drawings = await prisma.drawing.findMany({ + where: { + userId: req.user.id, + }, + include: { + collection: true, + }, + }); res.setHeader("Content-Type", "application/zip"); res.setHeader( @@ -1153,18 +1420,31 @@ app.get("/export/json", async (req, res) => { archive.pipe(res); - const drawingsByCollection: { [key: string]: any[] } = {}; + type DrawingWithCollection = Prisma.DrawingGetPayload<{ + include: { collection: true }; + }>; - drawings.forEach((drawing: any) => { + type DrawingExportItem = { + name: string; + data: { + elements: unknown[]; + appState: Record; + files: Record; + }; + }; + + const drawingsByCollection: Record = {}; + + drawings.forEach((drawing: DrawingWithCollection) => { const collectionName = drawing.collection?.name || "Unorganized"; if (!drawingsByCollection[collectionName]) { drawingsByCollection[collectionName] = []; } const drawingData = { - elements: JSON.parse(drawing.elements), - appState: JSON.parse(drawing.appState), - files: JSON.parse(drawing.files || "{}"), + elements: JSON.parse(drawing.elements) as unknown[], + appState: JSON.parse(drawing.appState) as Record, + files: JSON.parse(drawing.files || "{}") as Record, }; drawingsByCollection[collectionName].push({ @@ -1212,112 +1492,106 @@ ${Object.entries(drawingsByCollection) archive.append(readmeContent, { name: "README.txt" }); await archive.finalize(); - } catch (error) { - console.error(error); - res.status(500).json({ error: "Failed to export drawings" }); +})); + +// Database import endpoints should be admin-only or disabled in production +// For now, we'll require auth but note that full DB import is dangerous +app.post("/import/sqlite/verify", requireAuth, upload.single("db"), asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); } -}); -app.post("/import/sqlite/verify", upload.single("db"), async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: "No file uploaded" }); - } - - const stagedPath = req.file.path; - const isValid = await verifyDatabaseIntegrityAsync(stagedPath); - await removeFileIfExists(stagedPath); - - if (!isValid) { - return res.status(400).json({ error: "Invalid database format" }); - } - - res.json({ valid: true, message: "Database file is valid" }); - } catch (error) { - console.error(error); - if (req.file) { - await removeFileIfExists(req.file.path); - } - res.status(500).json({ error: "Failed to verify database file" }); - } -}); - -app.post("/import/sqlite", upload.single("db"), async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: "No file uploaded" }); - } - - const originalPath = req.file.path; - const stagedPath = path.join( - uploadDir, - `temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db` - ); - - try { - await moveFile(originalPath, stagedPath); - } catch (error) { - console.error("Failed to stage uploaded database", error); - await removeFileIfExists(originalPath); - await removeFileIfExists(stagedPath); - return res.status(500).json({ error: "Failed to stage uploaded file" }); - } - - const isValid = await verifyDatabaseIntegrityAsync(stagedPath); - if (!isValid) { - await removeFileIfExists(stagedPath); - return res - .status(400) - .json({ error: "Uploaded database failed integrity check" }); - } - - const dbPath = getResolvedDbPath(); - const backupPath = `${dbPath}.backup`; - - try { - try { - await fsPromises.access(dbPath); - await fsPromises.copyFile(dbPath, backupPath); - } catch { } - - await moveFile(stagedPath, dbPath); - } catch (error) { - console.error("Failed to replace database", error); - await removeFileIfExists(stagedPath); - return res.status(500).json({ error: "Failed to replace database" }); - } - - await prisma.$disconnect(); - invalidateDrawingsCache(); - - res.json({ success: true, message: "Database imported successfully" }); - } catch (error) { - console.error(error); - if (req.file) { - await removeFileIfExists(req.file.path); - } - res.status(500).json({ error: "Failed to import database" }); - } -}); - -const ensureTrashCollection = async () => { - try { - const trash = await prisma.collection.findUnique({ - where: { id: "trash" }, + // Database import is dangerous - consider disabling in production + if (config.nodeEnv === "production") { + return res.status(403).json({ + error: "Forbidden", + message: "Database import is disabled in production", }); - if (!trash) { - await prisma.collection.create({ - data: { id: "trash", name: "Trash" }, - }); - console.log("Created Trash collection"); - } - } catch (error) { - console.error("Failed to ensure Trash collection:", error); } -}; + + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } + + const stagedPath = req.file.path; + const isValid = await verifyDatabaseIntegrityAsync(stagedPath); + await removeFileIfExists(stagedPath); + + if (!isValid) { + return res.status(400).json({ error: "Invalid database format" }); + } + + res.json({ valid: true, message: "Database file is valid" }); +})); + +app.post("/import/sqlite", requireAuth, upload.single("db"), asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + // Database import is dangerous - consider disabling in production + if (config.nodeEnv === "production") { + return res.status(403).json({ + error: "Forbidden", + message: "Database import is disabled in production", + }); + } + + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } + + const originalPath = req.file.path; + const stagedPath = path.join( + uploadDir, + `temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db` + ); + + try { + await moveFile(originalPath, stagedPath); + } catch (error) { + console.error("Failed to stage uploaded database", error); + await removeFileIfExists(originalPath); + await removeFileIfExists(stagedPath); + return res.status(500).json({ error: "Failed to stage uploaded file" }); + } + + const isValid = await verifyDatabaseIntegrityAsync(stagedPath); + if (!isValid) { + await removeFileIfExists(stagedPath); + return res + .status(400) + .json({ error: "Uploaded database failed integrity check" }); + } + + const dbPath = getResolvedDbPath(); + const backupPath = `${dbPath}.backup`; + + try { + try { + await fsPromises.access(dbPath); + await fsPromises.copyFile(dbPath, backupPath); + } catch { } + + await moveFile(stagedPath, dbPath); + } catch (error) { + console.error("Failed to replace database", error); + await removeFileIfExists(stagedPath); + return res.status(500).json({ error: "Failed to replace database" }); + } + + await prisma.$disconnect(); + invalidateDrawingsCache(); + + res.json({ success: true, message: "Database imported successfully" }); +})); + +// Error handler middleware (must be last) +app.use(errorHandler); httpServer.listen(PORT, async () => { await initializeUploadDir(); - await ensureTrashCollection(); console.log(`Server running on port ${PORT}`); + console.log(`Environment: ${config.nodeEnv}`); + console.log(`Frontend URL: ${config.frontendUrl}`); }); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..b7dbe35 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,179 @@ +/** + * Authentication middleware for protecting routes + */ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import { config } from "../config"; +import { PrismaClient } from "../generated/client"; + +const prisma = new PrismaClient(); + +// Extend Express Request type to include user +declare global { + namespace Express { + interface Request { + user?: { + id: string; + email: string; + name: string; + }; + } + } +} + +interface JwtPayload { + userId: string; + email: string; + type: "access" | "refresh"; +} + +/** + * Type guard to check if decoded JWT is our expected payload structure + */ +const isJwtPayload = (decoded: unknown): decoded is JwtPayload => { + if (typeof decoded !== "object" || decoded === null) { + return false; + } + const payload = decoded as Record; + return ( + typeof payload.userId === "string" && + typeof payload.email === "string" && + (payload.type === "access" || payload.type === "refresh") + ); +}; + +/** + * Extract JWT token from Authorization header + */ +const extractToken = (req: Request): string | null => { + const authHeader = req.headers.authorization; + if (!authHeader || typeof authHeader !== "string") return null; + + const parts = authHeader.split(" "); + if (parts.length !== 2 || parts[0] !== "Bearer") { + return null; + } + + return parts[1]; +}; + +/** + * Verify and decode JWT token + */ +const verifyToken = (token: string): JwtPayload | null => { + try { + const decoded = jwt.verify(token, config.jwtSecret); + if (!isJwtPayload(decoded)) { + return null; + } + if (decoded.type !== "access") { + return null; // Only accept access tokens in middleware + } + return decoded; + } catch { + return null; + } +}; + +/** + * Require authentication middleware + * Protects routes that require a valid JWT token + */ +export const requireAuth = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + const token = extractToken(req); + + if (!token) { + res.status(401).json({ + error: "Unauthorized", + message: "Authentication token required", + }); + return; + } + + const payload = verifyToken(token); + + if (!payload) { + res.status(401).json({ + error: "Unauthorized", + message: "Invalid or expired token", + }); + return; + } + + // Verify user still exists and is active + try { + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { id: true, email: true, name: true, isActive: true }, + }); + + if (!user || !user.isActive) { + res.status(401).json({ + error: "Unauthorized", + message: "User account not found or inactive", + }); + return; + } + + // Attach user to request + req.user = { + id: user.id, + email: user.email, + name: user.name, + }; + + next(); + } catch (error) { + console.error("Error verifying user:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to verify user", + }); + } +}; + +/** + * Optional authentication middleware + * Attaches user to request if token is present, but doesn't require it + */ +export const optionalAuth = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + const token = extractToken(req); + + if (!token) { + return next(); + } + + const payload = verifyToken(token); + + if (!payload) { + return next(); + } + + try { + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { id: true, email: true, name: true, isActive: true }, + }); + + if (user && user.isActive) { + req.user = { + id: user.id, + email: user.email, + name: user.name, + }; + } + } catch (error) { + // Silently fail for optional auth + console.error("Error in optional auth:", error); + } + + next(); +}; \ No newline at end of file diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..c6edf17 --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,86 @@ +/** + * Error handling middleware + * Sanitizes error messages in production to prevent information leakage + */ +import { Request, Response, NextFunction } from "express"; +import { config } from "../config"; + +export interface AppError extends Error { + statusCode?: number; + isOperational?: boolean; +} + +/** + * Error handler middleware + * Should be added last in the middleware chain + */ +export const errorHandler = ( + err: AppError, + req: Request, + res: Response, + next: NextFunction +): void => { + const statusCode = err.statusCode || 500; + const isDevelopment = config.nodeEnv === "development"; + + // Log full error details server-side + console.error("Error:", { + message: err.message, + stack: err.stack, + statusCode, + path: req.path, + method: req.method, + timestamp: new Date().toISOString(), + }); + + // In production, don't expose internal error details + if (!isDevelopment) { + // Generic error messages for clients + if (statusCode >= 500) { + res.status(statusCode).json({ + error: "Internal server error", + message: "An error occurred while processing your request", + }); + return; + } + + // For client errors (4xx), provide generic message + res.status(statusCode).json({ + error: "Request error", + message: err.isOperational ? err.message : "Invalid request", + }); + return; + } + + // In development, show full error details + res.status(statusCode).json({ + error: err.message, + stack: err.stack, + statusCode, + }); +}; + +/** + * Async error wrapper + * Wraps async route handlers to catch errors + */ +export const asyncHandler = ( + fn: (req: Request, res: Response, next: NextFunction) => Promise +) => { + return (req: Request, res: Response, next: NextFunction): void => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; + +/** + * Create an operational error (known error that can be safely shown to client) + */ +export const createError = ( + message: string, + statusCode: number = 400 +): AppError => { + const error: AppError = new Error(message); + error.statusCode = statusCode; + error.isOperational = true; + return error; +}; \ No newline at end of file diff --git a/backend/src/scripts/migrate-existing-data.ts b/backend/src/scripts/migrate-existing-data.ts new file mode 100644 index 0000000..ca89c97 --- /dev/null +++ b/backend/src/scripts/migrate-existing-data.ts @@ -0,0 +1,92 @@ +/** + * Data migration script for existing drawings and collections + * This script assigns existing data to a default user + * Run this if you have existing data before the auth migration + */ +import { PrismaClient } from '../generated/client'; +import bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function migrateExistingData() { + try { + console.log('Starting data migration...'); + + // Check if there are any drawings or collections without userId + // Note: After migration, userId is required, so this query is for pre-migration data + // We use a raw query or check for missing userId field + const allDrawings = await prisma.drawing.findMany({ + select: { id: true, userId: true }, + }); + const drawingsWithoutUser = allDrawings.filter((d) => !d.userId); + + const allCollections = await prisma.collection.findMany({ + select: { id: true, userId: true }, + }); + const collectionsWithoutUser = allCollections.filter((c) => !c.userId); + + if (drawingsWithoutUser.length === 0 && collectionsWithoutUser.length === 0) { + console.log('No data to migrate. All records already have userId.'); + return; + } + + console.log(`Found ${drawingsWithoutUser.length} drawings and ${collectionsWithoutUser.length} collections without userId`); + + // Create a default migration user + const defaultEmail = 'migration@excalidash.local'; + const defaultPassword = await bcrypt.hash('migration-temp-password-change-me', 10); + + let migrationUser = await prisma.user.findUnique({ + where: { email: defaultEmail }, + }); + + if (!migrationUser) { + migrationUser = await prisma.user.create({ + data: { + email: defaultEmail, + passwordHash: defaultPassword, + name: 'Migration User', + }, + }); + console.log('Created migration user:', migrationUser.id); + } + + // Update collections + if (collectionsWithoutUser.length > 0) { + const collectionIds = collectionsWithoutUser.map((c) => c.id); + await prisma.collection.updateMany({ + where: { + id: { in: collectionIds }, + }, + data: { + userId: migrationUser.id, + }, + }); + console.log(`Assigned ${collectionsWithoutUser.length} collections to migration user`); + } + + // Update drawings + if (drawingsWithoutUser.length > 0) { + const drawingIds = drawingsWithoutUser.map((d) => d.id); + await prisma.drawing.updateMany({ + where: { + id: { in: drawingIds }, + }, + data: { + userId: migrationUser.id, + }, + }); + console.log(`Assigned ${drawingsWithoutUser.length} drawings to migration user`); + } + + console.log('Migration completed successfully!'); + console.log(`⚠️ IMPORTANT: Change the password for user ${defaultEmail} or delete this user after assigning data to real users.`); + } catch (error) { + console.error('Migration failed:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +migrateExistingData(); \ No newline at end of file diff --git a/backend/src/utils/__tests__/audit.test.ts b/backend/src/utils/__tests__/audit.test.ts new file mode 100644 index 0000000..7d20f3a --- /dev/null +++ b/backend/src/utils/__tests__/audit.test.ts @@ -0,0 +1,205 @@ +/** + * Tests for audit logging utility + * + * These tests verify that audit logging works correctly when enabled + * and gracefully degrades when disabled or when tables don't exist. + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { getTestPrisma, setupTestDb, initTestDb, createTestUser } from "../../__tests__/testUtils"; +import { logAuditEvent, getAuditLogs, type AuditLogData } from "../audit"; + +describe("Audit Logging", () => { + const prisma = getTestPrisma(); + let testUser: { id: string; email: string }; + + beforeAll(async () => { + setupTestDb(); + testUser = await initTestDb(prisma); + // Enable audit logging for tests + process.env.ENABLE_AUDIT_LOGGING = "true"; + }); + + afterAll(async () => { + await prisma.$disconnect(); + delete process.env.ENABLE_AUDIT_LOGGING; + }); + + beforeEach(async () => { + // Clean up audit logs before each test + await prisma.auditLog.deleteMany({}); + }); + + describe("logAuditEvent", () => { + it("should create an audit log entry when enabled", async () => { + const auditData: AuditLogData = { + userId: testUser.id, + action: "test_action", + resource: "test_resource", + ipAddress: "127.0.0.1", + userAgent: "test-agent", + details: { test: "value" }, + }; + + await logAuditEvent(auditData); + + const logs = await prisma.auditLog.findMany({ + where: { userId: testUser.id, action: "test_action" }, + }); + + expect(logs.length).toBe(1); + expect(logs[0].action).toBe("test_action"); + expect(logs[0].resource).toBe("test_resource"); + expect(logs[0].ipAddress).toBe("127.0.0.1"); + expect(logs[0].userAgent).toBe("test-agent"); + expect(logs[0].details).toBe(JSON.stringify({ test: "value" })); + }); + + it("should handle audit log without userId", async () => { + const auditData: AuditLogData = { + action: "anonymous_action", + ipAddress: "127.0.0.1", + }; + + await logAuditEvent(auditData); + + const logs = await prisma.auditLog.findMany({ + where: { action: "anonymous_action" }, + }); + + expect(logs.length).toBe(1); + expect(logs[0].userId).toBeNull(); + }); + + it("should handle audit log without optional fields", async () => { + const auditData: AuditLogData = { + action: "minimal_action", + }; + + await logAuditEvent(auditData); + + const logs = await prisma.auditLog.findMany({ + where: { action: "minimal_action" }, + }); + + expect(logs.length).toBe(1); + expect(logs[0].resource).toBeNull(); + expect(logs[0].ipAddress).toBeNull(); + expect(logs[0].userAgent).toBeNull(); + expect(logs[0].details).toBeNull(); + }); + + it("should gracefully handle when feature is disabled", async () => { + // Note: Config is cached, so we test the graceful error handling instead + // by checking that errors don't propagate + const auditData: AuditLogData = { + action: "should_not_log_disabled", + }; + + // Should not throw even if feature is disabled or table missing + await expect(logAuditEvent(auditData)).resolves.not.toThrow(); + }); + + it("should serialize details object to JSON", async () => { + const complexDetails = { + nested: { value: 123 }, + array: [1, 2, 3], + string: "test", + }; + + await logAuditEvent({ + userId: testUser.id, + action: "complex_details", + details: complexDetails, + }); + + const logs = await prisma.auditLog.findMany({ + where: { action: "complex_details" }, + }); + + expect(logs.length).toBe(1); + const parsed = JSON.parse(logs[0].details || "{}"); + expect(parsed).toEqual(complexDetails); + }); + }); + + describe("getAuditLogs", () => { + beforeEach(async () => { + // Create some test audit logs + await prisma.auditLog.createMany({ + data: [ + { + userId: testUser.id, + action: "action_1", + createdAt: new Date("2025-01-01T10:00:00Z"), + }, + { + userId: testUser.id, + action: "action_2", + createdAt: new Date("2025-01-01T11:00:00Z"), + }, + { + userId: testUser.id, + action: "action_3", + createdAt: new Date("2025-01-01T12:00:00Z"), + }, + ], + }); + }); + + it("should retrieve audit logs for a specific user", async () => { + const logs = await getAuditLogs(testUser.id); + + expect(logs.length).toBe(3); + expect(logs[0].action).toBe("action_3"); // Most recent first + expect(logs[1].action).toBe("action_2"); + expect(logs[2].action).toBe("action_1"); + }); + + it("should retrieve all audit logs when userId is not provided", async () => { + // Create a log for another user + const otherUser = await createTestUser(prisma, "other@example.com"); + await prisma.auditLog.create({ + data: { + userId: otherUser.id, + action: "other_action", + }, + }); + + const logs = await getAuditLogs(); + + expect(logs.length).toBeGreaterThanOrEqual(4); + }); + + it("should respect limit parameter", async () => { + const logs = await getAuditLogs(testUser.id, 2); + + expect(logs.length).toBe(2); + }); + + it("should parse details JSON in returned logs", async () => { + await prisma.auditLog.create({ + data: { + userId: testUser.id, + action: "with_details", + details: JSON.stringify({ key: "value" }), + }, + }); + + const logs = await getAuditLogs(testUser.id, 1); + + expect(logs.length).toBe(1); + expect((logs[0] as { details: unknown }).details).toEqual({ key: "value" }); + }); + + it("should include user information in logs", async () => { + const logs = await getAuditLogs(testUser.id, 1); + + expect(logs.length).toBe(1); + const log = logs[0] as { user: { id: string; email: string; name: string } }; + expect(log.user).toBeDefined(); + expect(log.user.id).toBe(testUser.id); + expect(log.user.email).toBe(testUser.email); + }); + }); +}); diff --git a/backend/src/utils/audit.ts b/backend/src/utils/audit.ts new file mode 100644 index 0000000..ae3ac32 --- /dev/null +++ b/backend/src/utils/audit.ts @@ -0,0 +1,91 @@ +/** + * Audit logging utility for security events + */ +import { PrismaClient } from "../generated/client"; + +const prisma = new PrismaClient(); + +export interface AuditLogData { + userId?: string; + action: string; + resource?: string; + ipAddress?: string; + userAgent?: string; + details?: Record; +} + +/** + * Log a security event to the audit log + * This should be called for important security-related actions + * Gracefully handles missing audit log table (feature disabled) + */ +export const logAuditEvent = async (data: AuditLogData): Promise => { + try { + // Check if audit logging is enabled via config + const { config } = await import("../config"); + if (!config.enableAuditLogging) { + return; // Feature disabled, silently skip + } + + await prisma.auditLog.create({ + data: { + userId: data.userId || null, + action: data.action, + resource: data.resource || null, + ipAddress: data.ipAddress || null, + userAgent: data.userAgent || null, + details: data.details ? JSON.stringify(data.details) : null, + }, + }); + } catch (error) { + // Don't fail the request if audit logging fails + // This handles cases where the table doesn't exist (feature disabled) + // or other database errors + if (process.env.NODE_ENV === "development") { + console.debug("Audit logging skipped (feature disabled or table missing):", error); + } + } +}; + +/** + * Get audit logs for a user (or all users if userId is not provided) + * Returns empty array if audit logging is disabled or table doesn't exist + */ +export const getAuditLogs = async ( + userId?: string, + limit: number = 100 +): Promise => { + try { + // Check if audit logging is enabled via config + const { config } = await import("../config"); + if (!config.enableAuditLogging) { + return []; // Feature disabled, return empty array + } + + const logs = await prisma.auditLog.findMany({ + where: userId ? { userId } : undefined, + orderBy: { createdAt: "desc" }, + take: limit, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); + + return logs.map((log) => ({ + ...log, + details: log.details ? JSON.parse(log.details) : null, + })); + } catch (error) { + // Gracefully handle missing table or other errors + if (process.env.NODE_ENV === "development") { + console.debug("Failed to retrieve audit logs (feature disabled or table missing):", error); + } + return []; + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index d2d928a..466cfcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: - DATABASE_URL=file:/app/prisma/dev.db - PORT=8000 - NODE_ENV=production + # Required for authentication: set a strong secret (min 32 chars) + - JWT_SECRET=${JWT_SECRET:-change-this-secret-in-production-min-32-chars} # Required for horizontal scaling (k8s): uncomment and set to same value on all instances # - CSRF_SECRET=${CSRF_SECRET} volumes: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 650c1ad..b66db6f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.1.8", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.1.8", + "version": "0.3.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index efc99e9..dcfec97 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,23 +1,73 @@ -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { Dashboard } from './pages/Dashboard'; import { Editor } from './pages/Editor'; import { Settings } from './pages/Settings'; +import { Profile } from './pages/Profile'; +import { Login } from './pages/Login'; +import { Register } from './pages/Register'; +import { PasswordResetRequest } from './pages/PasswordResetRequest'; +import { PasswordResetConfirm } from './pages/PasswordResetConfirm'; import { ThemeProvider } from './context/ThemeContext'; import { UploadProvider } from './context/UploadContext'; +import { AuthProvider } from './context/AuthContext'; +import { ProtectedRoute } from './components/ProtectedRoute'; function App() { return ( - - - - } /> - } /> - } /> - } /> - - - + + + + + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + + ); } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1ea40db..b65db66 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -7,6 +7,22 @@ export const api = axios.create({ baseURL: API_URL, }); +// Re-export axios for type checking +export { default as axios } from 'axios'; +export const isAxiosError = axios.isAxiosError; + +// Export api instance for direct use +export { api as default }; + +// JWT Token Management +const TOKEN_KEY = 'excalidash-access-token'; +const REFRESH_TOKEN_KEY = 'excalidash-refresh-token'; + +const getAuthToken = (): string | null => { + if (typeof window === 'undefined') return null; + return localStorage.getItem(TOKEN_KEY); +}; + // CSRF Token Management let csrfToken: string | null = null; let csrfHeaderName: string = "x-csrf-token"; @@ -50,12 +66,40 @@ export const clearCsrfToken = (): void => { csrfToken = null; }; -// Add request interceptor to include CSRF token +// Add request interceptor to include JWT and CSRF tokens api.interceptors.request.use( async (config) => { - // Only add CSRF token for state-changing methods + // Auth endpoints that require authentication (need JWT token) + const authenticatedAuthEndpoints = [ + '/auth/me', + '/auth/profile', + '/auth/change-password', + ]; + + // Auth endpoints that don't require authentication (login, register, etc.) + const publicAuthEndpoints = [ + '/auth/login', + '/auth/register', + '/auth/refresh', + '/auth/password-reset-request', + '/auth/password-reset-confirm', + ]; + + const isAuthenticatedAuthEndpoint = config.url && authenticatedAuthEndpoints.some(endpoint => config.url?.startsWith(endpoint)); + const isPublicAuthEndpoint = config.url && publicAuthEndpoints.some(endpoint => config.url?.startsWith(endpoint)); + const isAuthEndpoint = config.url?.startsWith('/auth/'); + + // Add JWT token to all requests except public auth endpoints + if (!isPublicAuthEndpoint) { + const token = getAuthToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + + // Only add CSRF token for state-changing methods (except public auth endpoints) const method = config.method?.toUpperCase(); - if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method)) { + if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method) && !isPublicAuthEndpoint) { await ensureCsrfToken(); if (csrfToken) { config.headers[csrfHeaderName] = csrfToken; @@ -66,10 +110,47 @@ api.interceptors.request.use( (error) => Promise.reject(error) ); -// Add response interceptor to handle CSRF token errors +// Add response interceptor to handle auth and CSRF token errors api.interceptors.response.use( (response) => response, async (error) => { + // Handle 401 Unauthorized (invalid/expired JWT) + if (error.response?.status === 401) { + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + if (refreshToken && !error.config.url?.includes('/auth/')) { + try { + const refreshResponse = await axios.post(`${API_URL}/auth/refresh`, { + refreshToken, + }); + localStorage.setItem(TOKEN_KEY, refreshResponse.data.accessToken); + + // Update refresh token if rotation returned a new one + if (refreshResponse.data.refreshToken) { + localStorage.setItem(REFRESH_TOKEN_KEY, refreshResponse.data.refreshToken); + } + + // Retry original request with new token + error.config.headers.Authorization = `Bearer ${refreshResponse.data.accessToken}`; + return api(error.config); + } catch { + // Refresh failed, clear tokens and redirect to login + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem('excalidash-user'); + window.location.href = '/login'; + return Promise.reject(error); + } + } else { + // No refresh token or auth endpoint, redirect to login + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem('excalidash-user'); + if (!error.config.url?.includes('/auth/')) { + window.location.href = '/login'; + } + } + } + // If we get a 403 with CSRF error, clear token and retry once if ( error.response?.status === 403 && @@ -99,7 +180,14 @@ const coerceTimestamp = (value: string | number | Date): number => { return Number.isNaN(parsed) ? Date.now() : parsed; }; -const deserializeTimestamps = ( +type TimestampValue = string | number | Date; + +interface HasTimestamps { + createdAt: TimestampValue; + updatedAt: TimestampValue; +} + +const deserializeTimestamps = ( data: T ): T & { createdAt: number; updatedAt: number } => ({ ...data, @@ -107,11 +195,19 @@ const deserializeTimestamps = ( updatedAt: coerceTimestamp(data.updatedAt), }); -const deserializeDrawingSummary = (drawing: any): DrawingSummary => - deserializeTimestamps(drawing); +const deserializeDrawingSummary = (drawing: unknown): DrawingSummary => { + if (typeof drawing !== 'object' || drawing === null) { + throw new Error('Invalid drawing data'); + } + return deserializeTimestamps(drawing as HasTimestamps & DrawingSummary); +}; -const deserializeDrawing = (drawing: any): Drawing => - deserializeTimestamps(drawing); +const deserializeDrawing = (drawing: unknown): Drawing => { + if (typeof drawing !== 'object' || drawing === null) { + throw new Error('Invalid drawing data'); + } + return deserializeTimestamps(drawing as HasTimestamps & Drawing); +}; export function getDrawings( search?: string, @@ -129,7 +225,7 @@ export async function getDrawings( collectionId?: string | null, options?: { includeData?: boolean } ) { - const params: any = {}; + const params: Record = {}; if (search) params.search = search; if (collectionId !== undefined) params.collectionId = collectionId === null ? "null" : collectionId; @@ -152,8 +248,10 @@ export const createDrawing = async ( collectionId?: string | null ) => { const response = await api.post<{ id: string }>("/drawings", { - name, - collectionId, + name: name || "Untitled Drawing", + collectionId: collectionId ?? null, + elements: [], + appState: {}, }); return response.data; }; @@ -197,12 +295,15 @@ export const deleteCollection = async (id: string) => { // --- Library --- -export const getLibrary = async () => { - const response = await api.get<{ items: any[] }>("/library"); +// Library items are Excalidraw library items - dynamic structure from Excalidraw +type LibraryItem = Record; + +export const getLibrary = async (): Promise => { + const response = await api.get<{ items: LibraryItem[] }>("/library"); return response.data.items; }; -export const updateLibrary = async (items: any[]) => { - const response = await api.put<{ items: any[] }>("/library", { items }); +export const updateLibrary = async (items: LibraryItem[]): Promise => { + const response = await api.put<{ items: LibraryItem[] }>("/library", { items }); return response.data.items; }; diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..2a9bd86 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export const ProtectedRoute: React.FC = ({ children }) => { + const { isAuthenticated, loading } = useAuth(); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +}; \ No newline at end of file diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 07dcd06..7ab8e4c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon } from 'lucide-react'; +import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut } from 'lucide-react'; import type { Collection } from '../types'; import clsx from 'clsx'; import { ConfirmModal } from './ConfirmModal'; import { Logo } from './Logo'; +import { useAuth } from '../context/AuthContext'; interface SidebarProps { collections: Collection[]; @@ -120,6 +121,8 @@ export const Sidebar: React.FC = ({ onDeleteCollection, onDrop }) => { + const navigate = useNavigate(); + const { logout, user } = useAuth(); const [isCreating, setIsCreating] = useState(false); const [newCollectionName, setNewCollectionName] = useState(''); const [editingId, setEditingId] = useState(null); @@ -127,7 +130,6 @@ export const Sidebar: React.FC = ({ const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'item' | 'background'; id?: string } | null>(null); const [collectionToDelete, setCollectionToDelete] = useState(null); const [isTrashDragOver, setIsTrashDragOver] = useState(false); - const navigate = useNavigate(); useEffect(() => { const handleClickOutside = () => setContextMenu(null); @@ -284,6 +286,19 @@ export const Sidebar: React.FC = ({ Trash + + + + {/* User info and logout */} +
+ {user && ( +
+
{user.name}
+
{user.email}
+
+ )} + +
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..3faf477 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,190 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import axios from 'axios'; + +const API_URL = import.meta.env.VITE_API_URL || "/api"; + +interface User { + id: string; + email: string; + name: string; +} + +interface AuthContextType { + user: User | null; + loading: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string, name: string) => Promise; + logout: () => void; + isAuthenticated: boolean; +} + +const AuthContext = createContext(undefined); + +const TOKEN_KEY = 'excalidash-access-token'; +const REFRESH_TOKEN_KEY = 'excalidash-refresh-token'; +const USER_KEY = 'excalidash-user'; + +export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + // Load user from localStorage on mount + useEffect(() => { + const loadUser = async () => { + try { + const storedUser = localStorage.getItem(USER_KEY); + const storedToken = localStorage.getItem(TOKEN_KEY); + + if (storedUser && storedToken) { + const userData = JSON.parse(storedUser); + setUser(userData); + + // Verify token is still valid by fetching user info + try { + const response = await axios.get(`${API_URL}/auth/me`, { + headers: { + Authorization: `Bearer ${storedToken}`, + }, + }); + setUser(response.data.user); + } catch (error) { + // Token invalid, try refresh + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + if (refreshToken) { + try { + const refreshResponse = await axios.post(`${API_URL}/auth/refresh`, { + refreshToken, + }); + localStorage.setItem(TOKEN_KEY, refreshResponse.data.accessToken); + const userResponse = await axios.get(`${API_URL}/auth/me`, { + headers: { + Authorization: `Bearer ${refreshResponse.data.accessToken}`, + }, + }); + setUser(userResponse.data.user); + } catch { + // Refresh failed, clear auth but don't navigate during initial load + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); + setUser(null); + } + } else { + // No refresh token, clear auth + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); + setUser(null); + } + } + } + } catch (error) { + console.error('Failed to load user:', error); + // Clear auth on error + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); + setUser(null); + } finally { + setLoading(false); + } + }; + + loadUser(); + }, []); + + const login = async (email: string, password: string) => { + try { + const response = await axios.post(`${API_URL}/auth/login`, { + email, + password, + }); + + const { user: userData, accessToken, refreshToken } = response.data; + + localStorage.setItem(TOKEN_KEY, accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + localStorage.setItem(USER_KEY, JSON.stringify(userData)); + + setUser(userData); + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + const message = + typeof error.response?.data === 'object' && + error.response.data !== null && + 'message' in error.response.data && + typeof error.response.data.message === 'string' + ? error.response.data.message + : 'Login failed'; + throw new Error(message); + } + throw error instanceof Error ? error : new Error('Login failed'); + } + }; + + const register = async (email: string, password: string, name: string) => { + try { + const response = await axios.post(`${API_URL}/auth/register`, { + email, + password, + name, + }); + + const { user: userData, accessToken, refreshToken } = response.data; + + localStorage.setItem(TOKEN_KEY, accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + localStorage.setItem(USER_KEY, JSON.stringify(userData)); + + setUser(userData); + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + const message = + typeof error.response?.data === 'object' && + error.response.data !== null && + 'message' in error.response.data && + typeof error.response.data.message === 'string' + ? error.response.data.message + : 'Registration failed'; + throw new Error(message); + } + throw error instanceof Error ? error : new Error('Registration failed'); + } + }; + + const logout = () => { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); + setUser(null); + // Navigate to login - use setTimeout to ensure Router is ready + setTimeout(() => { + navigate('/login'); + }, 0); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 20e1496..c7f976e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Layout } from '../components/Layout'; import { DrawingCard } from '../components/DrawingCard'; -import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload } from 'lucide-react'; +import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload, CheckSquare, Square, ArrowUp, ArrowDown, ChevronDown, FileText, Calendar, Clock } from 'lucide-react'; import { useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import * as api from '../api'; import type { DrawingSummary, Collection } from '../types'; @@ -73,6 +73,7 @@ export const Dashboard: React.FC = () => { const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedId, setLastSelectedId] = useState(null); const [showBulkMoveMenu, setShowBulkMoveMenu] = useState(false); + const [showSortMenu, setShowSortMenu] = useState(false); const [drawingToDelete, setDrawingToDelete] = useState(null); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); @@ -256,38 +257,35 @@ export const Dashboard: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [sortedDrawings]); - const handleSort = (field: SortField) => { + const handleSortFieldChange = (field: SortField) => { setSortConfig(current => { - if (current.field === field) return { ...current, direction: current.direction === 'asc' ? 'desc' : 'asc' }; - const defaultDirection = field === 'name' ? 'asc' : 'desc'; - return { field, direction: defaultDirection }; + // If changing field, use default direction for that field + if (current.field !== field) { + const defaultDirection = field === 'name' ? 'asc' : 'desc'; + return { field, direction: defaultDirection }; + } + // If same field, keep current direction + return current; }); + setShowSortMenu(false); }; - const SortButton = ({ field, label }: { field: SortField; label: string }) => { - const isActive = sortConfig.field === field; - return ( - - ); + const handleSortDirectionToggle = () => { + setSortConfig(current => ({ + ...current, + direction: current.direction === 'asc' ? 'desc' : 'asc' + })); }; - - const isTrashView = selectedCollectionId === 'trash'; + const sortOptions: { field: SortField; label: string; icon: React.ReactNode }[] = [ + { field: 'name', label: 'Name', icon: }, + { field: 'createdAt', label: 'Date Created', icon: }, + { field: 'updatedAt', label: 'Date Modified', icon: }, + ]; + + const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0]; + + const isTrashView = selectedCollectionId === 'trash'; const handleCreateDrawing = async () => { if (isTrashView) return; try { @@ -513,6 +511,19 @@ export const Dashboard: React.FC = () => { }, [selectedCollectionId, collections]); const hasSelection = selectedIds.size > 0; + const allSelected = sortedDrawings.length > 0 && selectedIds.size === sortedDrawings.length; + + const handleSelectAll = () => { + if (allSelected) { + // Deselect all + setSelectedIds(new Set()); + setLastSelectedId(null); + } else { + // Select all + const allIds = new Set(sortedDrawings.map(d => d.id)); + setSelectedIds(allIds); + } + }; const handleDrop = async (e: React.DragEvent, targetCollectionId: string | null) => { e.preventDefault(); @@ -685,15 +696,86 @@ export const Dashboard: React.FC = () => { -
- - - +
+
+ + + {showSortMenu && ( + <> +
setShowSortMenu(false)} /> +
+ {sortOptions.map((option) => ( + + ))} +
+ + )} +
+ +
+ +
+ {/* Auto-hide Toggle */} + + +
+ {/* Download Button */}
-
+
{initialData ? ( { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await login(email, password); + navigate('/'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to login'; + setError(message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +

+ Sign in to your account +

+

+ Or{' '} + + create a new account + +

+
+
+ {error && ( +
+
{error}
+
+ )} +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ +
+ + Forgot your password? + +
+ +
+ +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/PasswordResetConfirm.tsx b/frontend/src/pages/PasswordResetConfirm.tsx new file mode 100644 index 0000000..46ac145 --- /dev/null +++ b/frontend/src/pages/PasswordResetConfirm.tsx @@ -0,0 +1,177 @@ +import React, { useState, useEffect } from 'react'; +import { useSearchParams, useNavigate, Link } from 'react-router-dom'; +import axios from 'axios'; +import { Logo } from '../components/Logo'; + +const API_URL = import.meta.env.VITE_API_URL || "/api"; + +export const PasswordResetConfirm: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get('token'); + + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (!token) { + setError('Invalid reset link. Please request a new password reset.'); + } + }, [token]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters long'); + return; + } + + if (!token) { + setError('Invalid reset token'); + return; + } + + setLoading(true); + + try { + await axios.post(`${API_URL}/auth/password-reset-confirm`, { + token, + password, + }); + setSuccess(true); + setTimeout(() => { + navigate('/login'); + }, 3000); + } catch (err: unknown) { + let message = 'Failed to reset password'; + if (axios.isAxiosError(err)) { + if (err.response?.status === 404) { + message = 'Password reset feature is not enabled on this server'; + } else if (err.response?.data?.message) { + message = err.response.data.message; + } else if (err.response?.data?.error) { + message = err.response.data.error; + } else if (err.message) { + message = err.message; + } + } else if (err instanceof Error) { + message = err.message; + } + setError(message); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
+
+
+ +

+ Password reset successful +

+

+ Your password has been reset. Redirecting to login... +

+
+ + Go to login + +
+
+
+
+ ); + } + + return ( +
+
+
+ +

+ Set new password +

+

+ Enter your new password below. +

+
+
+ {error && ( +
+
{error}
+
+ )} +
+
+ + setPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+
+ +
+ +
+ +
+ + Back to login + +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/PasswordResetRequest.tsx b/frontend/src/pages/PasswordResetRequest.tsx new file mode 100644 index 0000000..90dd412 --- /dev/null +++ b/frontend/src/pages/PasswordResetRequest.tsx @@ -0,0 +1,124 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import { Logo } from '../components/Logo'; + +const API_URL = import.meta.env.VITE_API_URL || "/api"; + +export const PasswordResetRequest: React.FC = () => { + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await axios.post(`${API_URL}/auth/password-reset-request`, { email }); + setSuccess(true); + } catch (err: unknown) { + let message = 'Failed to send reset email'; + if (axios.isAxiosError(err)) { + if (err.response?.status === 404) { + message = 'Password reset feature is not enabled on this server'; + } else if (err.response?.data?.message) { + message = err.response.data.message; + } else if (err.message) { + message = err.message; + } + } else if (err instanceof Error) { + message = err.message; + } + setError(message); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
+
+
+ +

+ Check your email +

+

+ If an account with that email exists, a password reset link has been sent. +

+
+ + Back to login + +
+
+
+
+ ); + } + + return ( +
+
+
+ +

+ Reset your password +

+

+ Enter your email address and we'll send you a link to reset your password. +

+
+
+ {error && ( +
+
{error}
+
+ )} +
+ + setEmail(e.target.value)} + /> +
+ +
+ +
+ +
+ + Back to login + +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..5f2f9e1 --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -0,0 +1,321 @@ +import React, { useState, useEffect } from 'react'; +import { Layout } from '../components/Layout'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import * as api from '../api'; +import type { Collection } from '../types'; +import { User, Lock, Save, X } from 'lucide-react'; +import { ConfirmModal } from '../components/ConfirmModal'; + +export const Profile: React.FC = () => { + const { user: authUser, logout } = useAuth(); + const navigate = useNavigate(); + const [collections, setCollections] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // User info state + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + + // Password change state + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPasswordForm, setShowPasswordForm] = useState(false); + + useEffect(() => { + const fetchData = async () => { + try { + const collectionsData = await api.getCollections(); + setCollections(collectionsData); + + // Fetch user info + if (authUser) { + setName(authUser.name); + setEmail(authUser.email); + } + } catch (err) { + console.error('Failed to fetch data:', err); + } + }; + fetchData(); + }, [authUser]); + + const handleSelectCollection = (id: string | null | undefined) => { + if (id === undefined) navigate('/'); + else if (id === null) navigate('/collections?id=unorganized'); + else navigate(`/collections?id=${id}`); + }; + + const handleCreateCollection = async (name: string) => { + await api.createCollection(name); + const newCollections = await api.getCollections(); + setCollections(newCollections); + }; + + const handleEditCollection = async (id: string, name: string) => { + setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c)); + await api.updateCollection(id, name); + }; + + const handleDeleteCollection = async (id: string) => { + setCollections(prev => prev.filter(c => c.id !== id)); + await api.deleteCollection(id); + }; + + const handleUpdateName = async () => { + if (!name.trim()) { + setError('Name cannot be empty'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + + try { + const response = await api.api.put<{ user: { id: string; email: string; name: string; createdAt: string; updatedAt: string } }>('/auth/profile', { name: name.trim() }); + setSuccess('Name updated successfully'); + // Update auth context - refresh user data + if (response.data?.user) { + // Update localStorage with new user data + localStorage.setItem('excalidash-user', JSON.stringify(response.data.user)); + // Reload to update auth context + setTimeout(() => window.location.reload(), 500); + } + } catch (err: unknown) { + let message = 'Failed to update name'; + if (api.isAxiosError(err)) { + if (err.response?.data?.message) { + message = err.response.data.message; + } else if (err.response?.data?.error) { + message = err.response.data.error; + } + } + setError(message); + } finally { + setLoading(false); + } + }; + + const handleChangePassword = async () => { + if (!currentPassword || !newPassword || !confirmPassword) { + setError('All password fields are required'); + return; + } + + if (newPassword.length < 8) { + setError('New password must be at least 8 characters long'); + return; + } + + if (newPassword !== confirmPassword) { + setError('New passwords do not match'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + + try { + await api.api.post('/auth/change-password', { + currentPassword, + newPassword, + }); + setSuccess('Password changed successfully'); + setShowPasswordForm(false); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + // Logout user to force re-login with new password + setTimeout(() => { + logout(); + navigate('/login'); + }, 2000); + } catch (err: unknown) { + let message = 'Failed to change password'; + if (api.isAxiosError(err)) { + if (err.response?.data?.message) { + message = err.response.data.message; + } else if (err.response?.data?.error) { + message = err.response.data.error; + } + } + setError(message); + } finally { + setLoading(false); + } + }; + + return ( + +

+ Profile +

+ + {/* Success/Error Messages */} + {success && ( +
+

{success}

+
+ )} + {error && ( +
+

{error}

+
+ )} + +
+ {/* Personal Information Section */} +
+
+
+ +
+

Personal Information

+
+ +
+
+ + +

Email cannot be changed

+
+ +
+ +
+ setName(e.target.value)} + className="flex-1 px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 font-medium" + placeholder="Your name" + /> + +
+
+
+
+ + {/* Password Change Section */} +
+
+
+
+ +
+

Change Password

+
+ {!showPasswordForm && ( + + )} +
+ + {showPasswordForm && ( +
+
+ + setCurrentPassword(e.target.value)} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium" + placeholder="Enter current password" + /> +
+ +
+ + setNewPassword(e.target.value)} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium" + placeholder="Enter new password (min 8 characters)" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium" + placeholder="Confirm new password" + /> +
+ +
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx new file mode 100644 index 0000000..7ab61ef --- /dev/null +++ b/frontend/src/pages/Register.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { Logo } from '../components/Logo'; + +export const Register: React.FC = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [name, setName] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { register } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (password.length < 8) { + setError('Password must be at least 8 characters long'); + return; + } + + setLoading(true); + + try { + await register(email, password, name); + navigate('/'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to register'; + setError(message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ +

+ Create your account +

+

+ Or{' '} + + sign in to your existing account + +

+
+
+ {error && ( +
+
{error}
+
+ )} +
+
+ + setName(e.target.value)} + /> +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ +
+ +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 93cd1d6..5ac798e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -92,20 +92,56 @@ export const Settings: React.FC = () => {