merge: pull PR48 auth and UX into pre-release
This commit is contained in:
@@ -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.
|
||||||
@@ -3,3 +3,9 @@ PORT=8000
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
FRONTEND_URL=http://localhost:6767
|
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
|
||||||
Generated
+224
@@ -11,19 +11,29 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/ms": "^2.1.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/socket.io": "^3.0.1",
|
"@types/socket.io": "^3.0.1",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dompurify": "^3.3.0",
|
"dompurify": "^3.3.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"ms": "^2.1.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1001,6 +1011,15 @@
|
|||||||
"@types/readdir-glob": "*"
|
"@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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -1101,6 +1120,16 @@
|
|||||||
"parse5": "^7.0.0"
|
"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": {
|
"node_modules/@types/methods": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||||
@@ -1114,6 +1143,12 @@
|
|||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/multer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||||
@@ -1229,6 +1264,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"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": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.0.15",
|
"version": "4.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz",
|
||||||
@@ -1621,6 +1662,20 @@
|
|||||||
"node": "^4.5.0 || >= 5.9"
|
"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": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.4.6",
|
"version": "12.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.6.tgz",
|
||||||
@@ -1789,6 +1844,12 @@
|
|||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -2282,6 +2343,15 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -2621,6 +2691,24 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fast-fifo": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||||
@@ -2955,6 +3043,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/html-encoding-sniffer": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||||
@@ -3065,6 +3162,15 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"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": {
|
"node_modules/lazystream": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||||
@@ -3291,6 +3440,48 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "10.4.3",
|
"version": "10.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
@@ -3566,6 +3757,26 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.11",
|
"version": "3.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||||
@@ -5032,6 +5243,19 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||||
|
|||||||
@@ -16,19 +16,29 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/ms": "^2.1.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/socket.io": "^3.0.1",
|
"@types/socket.io": "^3.0.1",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dompurify": "^3.3.0",
|
"dompurify": "^3.3.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"ms": "^2.1.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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");
|
||||||
+40
@@ -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");
|
||||||
@@ -12,9 +12,26 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
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 {
|
model Collection {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -28,6 +45,8 @@ model Drawing {
|
|||||||
files String @default("{}") // Stored as JSON string
|
files String @default("{}") // Stored as JSON string
|
||||||
preview String? // SVG string for thumbnail
|
preview String? // SVG string for thumbnail
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
collectionId String?
|
collectionId String?
|
||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -35,8 +54,40 @@ model Drawing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Library {
|
model Library {
|
||||||
id String @id @default("default") // Singleton pattern - use "default" ID
|
id String @id // User-specific library ID (e.g., "user_<userId>")
|
||||||
items String @default("[]") // Stored as JSON string array of library items
|
items String @default("[]") // Stored as JSON string array of library items
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -315,10 +315,11 @@ describe("Security Sanitization - Image Data URLs", () => {
|
|||||||
// Database integration tests
|
// Database integration tests
|
||||||
describe("Drawing API - Database Round-Trip", () => {
|
describe("Drawing API - Database Round-Trip", () => {
|
||||||
const prisma = getTestPrisma();
|
const prisma = getTestPrisma();
|
||||||
|
let testUser: { id: string };
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
setupTestDb();
|
setupTestDb();
|
||||||
await initTestDb(prisma);
|
testUser = await initTestDb(prisma);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -343,6 +344,7 @@ describe("Drawing API - Database Round-Trip", () => {
|
|||||||
elements: JSON.stringify([]),
|
elements: JSON.stringify([]),
|
||||||
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
|
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
|
||||||
files: JSON.stringify(files),
|
files: JSON.stringify(files),
|
||||||
|
userId: testUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -381,6 +383,7 @@ describe("Drawing API - Database Round-Trip", () => {
|
|||||||
elements: JSON.stringify([]),
|
elements: JSON.stringify([]),
|
||||||
appState: JSON.stringify({}),
|
appState: JSON.stringify({}),
|
||||||
files: JSON.stringify(files),
|
files: JSON.stringify(files),
|
||||||
|
userId: testUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -404,6 +407,7 @@ describe("Drawing API - Database Round-Trip", () => {
|
|||||||
elements: JSON.stringify([]),
|
elements: JSON.stringify([]),
|
||||||
appState: JSON.stringify({}),
|
appState: JSON.stringify({}),
|
||||||
files: JSON.stringify({}),
|
files: JSON.stringify({}),
|
||||||
|
userId: testUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* Initialize test database with required data
|
||||||
*/
|
*/
|
||||||
export const initTestDb = async (prisma: PrismaClient) => {
|
export const initTestDb = async (prisma: PrismaClient) => {
|
||||||
|
// Create a test user first
|
||||||
|
const testUser = await createTestUser(prisma);
|
||||||
|
|
||||||
// Ensure Trash collection exists
|
// Ensure Trash collection exists
|
||||||
const trash = await prisma.collection.findUnique({
|
const trash = await prisma.collection.findUnique({
|
||||||
where: { id: "trash" },
|
where: { id: "trash" },
|
||||||
});
|
});
|
||||||
if (!trash) {
|
if (!trash) {
|
||||||
await prisma.collection.create({
|
await prisma.collection.create({
|
||||||
data: { id: "trash", name: "Trash" },
|
data: { id: "trash", name: "Trash", userId: testUser.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return testUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
+501
-227
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown>;
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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();
|
||||||
|
};
|
||||||
@@ -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 = <T = void>(
|
||||||
|
fn: (req: Request, res: Response, next: NextFunction) => Promise<T>
|
||||||
|
) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> => {
|
||||||
|
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<unknown[]> => {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,6 +8,8 @@ services:
|
|||||||
- DATABASE_URL=file:/app/prisma/dev.db
|
- DATABASE_URL=file:/app/prisma/dev.db
|
||||||
- PORT=8000
|
- PORT=8000
|
||||||
- NODE_ENV=production
|
- 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
|
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
|
||||||
# - CSRF_SECRET=${CSRF_SECRET}
|
# - CSRF_SECRET=${CSRF_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.8",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.8",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|||||||
+57
-7
@@ -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 { Dashboard } from './pages/Dashboard';
|
||||||
import { Editor } from './pages/Editor';
|
import { Editor } from './pages/Editor';
|
||||||
import { Settings } from './pages/Settings';
|
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 { ThemeProvider } from './context/ThemeContext';
|
||||||
import { UploadProvider } from './context/UploadContext';
|
import { UploadProvider } from './context/UploadContext';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<UploadProvider>
|
|
||||||
<Router>
|
<Router>
|
||||||
|
<AuthProvider>
|
||||||
|
<UploadProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/collections" element={<Dashboard />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/reset-password" element={<PasswordResetRequest />} />
|
||||||
<Route path="/editor/:id" element={<Editor />} />
|
<Route path="/reset-password-confirm" element={<PasswordResetConfirm />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/collections"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Settings />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Profile />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/editor/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Editor />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
|
||||||
</UploadProvider>
|
</UploadProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</Router>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-16
@@ -7,6 +7,22 @@ export const api = axios.create({
|
|||||||
baseURL: API_URL,
|
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
|
// CSRF Token Management
|
||||||
let csrfToken: string | null = null;
|
let csrfToken: string | null = null;
|
||||||
let csrfHeaderName: string = "x-csrf-token";
|
let csrfHeaderName: string = "x-csrf-token";
|
||||||
@@ -50,12 +66,40 @@ export const clearCsrfToken = (): void => {
|
|||||||
csrfToken = null;
|
csrfToken = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add request interceptor to include CSRF token
|
// Add request interceptor to include JWT and CSRF tokens
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
async (config) => {
|
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();
|
const method = config.method?.toUpperCase();
|
||||||
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method) && !isPublicAuthEndpoint) {
|
||||||
await ensureCsrfToken();
|
await ensureCsrfToken();
|
||||||
if (csrfToken) {
|
if (csrfToken) {
|
||||||
config.headers[csrfHeaderName] = csrfToken;
|
config.headers[csrfHeaderName] = csrfToken;
|
||||||
@@ -66,10 +110,47 @@ api.interceptors.request.use(
|
|||||||
(error) => Promise.reject(error)
|
(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(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
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 we get a 403 with CSRF error, clear token and retry once
|
||||||
if (
|
if (
|
||||||
error.response?.status === 403 &&
|
error.response?.status === 403 &&
|
||||||
@@ -99,7 +180,14 @@ const coerceTimestamp = (value: string | number | Date): number => {
|
|||||||
return Number.isNaN(parsed) ? Date.now() : parsed;
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deserializeTimestamps = <T extends { createdAt: any; updatedAt: any }>(
|
type TimestampValue = string | number | Date;
|
||||||
|
|
||||||
|
interface HasTimestamps {
|
||||||
|
createdAt: TimestampValue;
|
||||||
|
updatedAt: TimestampValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deserializeTimestamps = <T extends HasTimestamps>(
|
||||||
data: T
|
data: T
|
||||||
): T & { createdAt: number; updatedAt: number } => ({
|
): T & { createdAt: number; updatedAt: number } => ({
|
||||||
...data,
|
...data,
|
||||||
@@ -107,11 +195,19 @@ const deserializeTimestamps = <T extends { createdAt: any; updatedAt: any }>(
|
|||||||
updatedAt: coerceTimestamp(data.updatedAt),
|
updatedAt: coerceTimestamp(data.updatedAt),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deserializeDrawingSummary = (drawing: any): DrawingSummary =>
|
const deserializeDrawingSummary = (drawing: unknown): DrawingSummary => {
|
||||||
deserializeTimestamps(drawing);
|
if (typeof drawing !== 'object' || drawing === null) {
|
||||||
|
throw new Error('Invalid drawing data');
|
||||||
|
}
|
||||||
|
return deserializeTimestamps(drawing as HasTimestamps & DrawingSummary);
|
||||||
|
};
|
||||||
|
|
||||||
const deserializeDrawing = (drawing: any): Drawing =>
|
const deserializeDrawing = (drawing: unknown): Drawing => {
|
||||||
deserializeTimestamps(drawing);
|
if (typeof drawing !== 'object' || drawing === null) {
|
||||||
|
throw new Error('Invalid drawing data');
|
||||||
|
}
|
||||||
|
return deserializeTimestamps(drawing as HasTimestamps & Drawing);
|
||||||
|
};
|
||||||
|
|
||||||
export function getDrawings(
|
export function getDrawings(
|
||||||
search?: string,
|
search?: string,
|
||||||
@@ -129,7 +225,7 @@ export async function getDrawings(
|
|||||||
collectionId?: string | null,
|
collectionId?: string | null,
|
||||||
options?: { includeData?: boolean }
|
options?: { includeData?: boolean }
|
||||||
) {
|
) {
|
||||||
const params: any = {};
|
const params: Record<string, string> = {};
|
||||||
if (search) params.search = search;
|
if (search) params.search = search;
|
||||||
if (collectionId !== undefined)
|
if (collectionId !== undefined)
|
||||||
params.collectionId = collectionId === null ? "null" : collectionId;
|
params.collectionId = collectionId === null ? "null" : collectionId;
|
||||||
@@ -152,8 +248,10 @@ export const createDrawing = async (
|
|||||||
collectionId?: string | null
|
collectionId?: string | null
|
||||||
) => {
|
) => {
|
||||||
const response = await api.post<{ id: string }>("/drawings", {
|
const response = await api.post<{ id: string }>("/drawings", {
|
||||||
name,
|
name: name || "Untitled Drawing",
|
||||||
collectionId,
|
collectionId: collectionId ?? null,
|
||||||
|
elements: [],
|
||||||
|
appState: {},
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
@@ -197,12 +295,15 @@ export const deleteCollection = async (id: string) => {
|
|||||||
|
|
||||||
// --- Library ---
|
// --- Library ---
|
||||||
|
|
||||||
export const getLibrary = async () => {
|
// Library items are Excalidraw library items - dynamic structure from Excalidraw
|
||||||
const response = await api.get<{ items: any[] }>("/library");
|
type LibraryItem = Record<string, unknown>;
|
||||||
|
|
||||||
|
export const getLibrary = async (): Promise<LibraryItem[]> => {
|
||||||
|
const response = await api.get<{ items: LibraryItem[] }>("/library");
|
||||||
return response.data.items;
|
return response.data.items;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateLibrary = async (items: any[]) => {
|
export const updateLibrary = async (items: LibraryItem[]): Promise<LibraryItem[]> => {
|
||||||
const response = await api.put<{ items: any[] }>("/library", { items });
|
const response = await api.put<{ items: LibraryItem[] }>("/library", { items });
|
||||||
return response.data.items;
|
return response.data.items;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<ProtectedRouteProps> = ({ children }) => {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 type { Collection } from '../types';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
import { Logo } from './Logo';
|
import { Logo } from './Logo';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
collections: Collection[];
|
collections: Collection[];
|
||||||
@@ -120,6 +121,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onDeleteCollection,
|
onDeleteCollection,
|
||||||
onDrop
|
onDrop
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { logout, user } = useAuth();
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [newCollectionName, setNewCollectionName] = useState('');
|
const [newCollectionName, setNewCollectionName] = useState('');
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@@ -127,7 +130,6 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'item' | 'background'; id?: string } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'item' | 'background'; id?: string } | null>(null);
|
||||||
const [collectionToDelete, setCollectionToDelete] = useState<string | null>(null);
|
const [collectionToDelete, setCollectionToDelete] = useState<string | null>(null);
|
||||||
const [isTrashDragOver, setIsTrashDragOver] = useState(false);
|
const [isTrashDragOver, setIsTrashDragOver] = useState(false);
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = () => setContextMenu(null);
|
const handleClickOutside = () => setContextMenu(null);
|
||||||
@@ -284,6 +286,19 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<span className="min-w-0 flex-1 text-left">Trash</span>
|
<span className="min-w-0 flex-1 text-left">Trash</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/profile')}
|
||||||
|
className={clsx(
|
||||||
|
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
|
||||||
|
selectedCollectionId === 'PROFILE'
|
||||||
|
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
|
||||||
|
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User size={18} />
|
||||||
|
<span className="min-w-0 flex-1 text-left">Profile</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/settings')}
|
onClick={() => navigate('/settings')}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -296,6 +311,23 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<SettingsIcon size={18} />
|
<SettingsIcon size={18} />
|
||||||
<span className="min-w-0 flex-1 text-left">Settings</span>
|
<span className="min-w-0 flex-1 text-left">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* User info and logout */}
|
||||||
|
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
|
||||||
|
{user && (
|
||||||
|
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
|
||||||
|
<div className="font-semibold text-slate-700 dark:text-neutral-300">{user.name}</div>
|
||||||
|
<div className="truncate">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-rose-300 dark:border-rose-700 bg-white dark:bg-neutral-900 text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
<span className="min-w-0 flex-1 text-left">Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
register: (email: string, password: string, name: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(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<User | null>(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 (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Layout } from '../components/Layout';
|
import { Layout } from '../components/Layout';
|
||||||
import { DrawingCard } from '../components/DrawingCard';
|
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 { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import type { DrawingSummary, Collection } from '../types';
|
import type { DrawingSummary, Collection } from '../types';
|
||||||
@@ -73,6 +73,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
|
||||||
const [showBulkMoveMenu, setShowBulkMoveMenu] = useState(false);
|
const [showBulkMoveMenu, setShowBulkMoveMenu] = useState(false);
|
||||||
|
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||||
|
|
||||||
const [drawingToDelete, setDrawingToDelete] = useState<string | null>(null);
|
const [drawingToDelete, setDrawingToDelete] = useState<string | null>(null);
|
||||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
||||||
@@ -256,36 +257,33 @@ export const Dashboard: React.FC = () => {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [sortedDrawings]);
|
}, [sortedDrawings]);
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
const handleSortFieldChange = (field: SortField) => {
|
||||||
setSortConfig(current => {
|
setSortConfig(current => {
|
||||||
if (current.field === field) return { ...current, direction: current.direction === 'asc' ? 'desc' : 'asc' };
|
// If changing field, use default direction for that field
|
||||||
|
if (current.field !== field) {
|
||||||
const defaultDirection = field === 'name' ? 'asc' : 'desc';
|
const defaultDirection = field === 'name' ? 'asc' : 'desc';
|
||||||
return { field, direction: defaultDirection };
|
return { field, direction: defaultDirection };
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const SortButton = ({ field, label }: { field: SortField; label: string }) => {
|
|
||||||
const isActive = sortConfig.field === field;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSort(field)}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700
|
|
||||||
${isActive
|
|
||||||
? 'bg-indigo-100 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5'
|
|
||||||
: 'bg-white dark:bg-neutral-900 text-slate-600 dark:text-neutral-400 hover:bg-slate-50 dark:hover:bg-neutral-800 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5'
|
|
||||||
}
|
}
|
||||||
`}
|
// If same field, keep current direction
|
||||||
>
|
return current;
|
||||||
{label}
|
});
|
||||||
<div className="flex flex-col -space-y-1">
|
setShowSortMenu(false);
|
||||||
<svg className={`w-2.5 h-2.5 ${isActive && sortConfig.direction === 'asc' ? 'text-indigo-600 dark:text-neutral-200' : 'text-slate-400 dark:text-neutral-600'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6" /></svg>
|
|
||||||
<svg className={`w-2.5 h-2.5 ${isActive && sortConfig.direction === 'desc' ? 'text-indigo-600 dark:text-neutral-200' : 'text-slate-400 dark:text-neutral-600'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSortDirectionToggle = () => {
|
||||||
|
setSortConfig(current => ({
|
||||||
|
...current,
|
||||||
|
direction: current.direction === 'asc' ? 'desc' : 'asc'
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortOptions: { field: SortField; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ field: 'name', label: 'Name', icon: <FileText size={16} /> },
|
||||||
|
{ field: 'createdAt', label: 'Date Created', icon: <Calendar size={16} /> },
|
||||||
|
{ field: 'updatedAt', label: 'Date Modified', icon: <Clock size={16} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0];
|
||||||
|
|
||||||
const isTrashView = selectedCollectionId === 'trash';
|
const isTrashView = selectedCollectionId === 'trash';
|
||||||
const handleCreateDrawing = async () => {
|
const handleCreateDrawing = async () => {
|
||||||
@@ -513,6 +511,19 @@ export const Dashboard: React.FC = () => {
|
|||||||
}, [selectedCollectionId, collections]);
|
}, [selectedCollectionId, collections]);
|
||||||
|
|
||||||
const hasSelection = selectedIds.size > 0;
|
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) => {
|
const handleDrop = async (e: React.DragEvent, targetCollectionId: string | null) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -685,15 +696,86 @@ export const Dashboard: React.FC = () => {
|
|||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 p-1 overflow-x-auto no-scrollbar">
|
<div className="flex items-center gap-2 p-1">
|
||||||
<SortButton field="name" label="Name" />
|
<div className="relative">
|
||||||
<SortButton field="createdAt" label="Date Created" />
|
<button
|
||||||
<SortButton field="updatedAt" label="Date Modified" />
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowSortMenu(!showSortMenu);
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700 whitespace-nowrap h-[42px] w-[180px]",
|
||||||
|
"bg-white dark:bg-neutral-900 text-slate-700 dark:text-neutral-300 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-indigo-600 dark:text-indigo-400 flex-shrink-0">{currentSortOption.icon}</span>
|
||||||
|
<span className="whitespace-nowrap flex-1 text-left">{currentSortOption.label}</span>
|
||||||
|
<ChevronDown size={16} className="text-slate-400 dark:text-neutral-500 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showSortMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowSortMenu(false)} />
|
||||||
|
<div className="absolute top-full left-0 mt-2 z-50 bg-white dark:bg-neutral-800 rounded-lg border-2 border-black dark:border-neutral-700 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] py-1 min-w-[180px]">
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.field}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSortFieldChange(option.field);
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
"w-full px-3 py-2 text-sm text-left flex items-center gap-2 transition-colors",
|
||||||
|
sortConfig.field === option.field
|
||||||
|
? "bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 font-bold"
|
||||||
|
: "text-slate-600 dark:text-neutral-300 hover:bg-slate-50 dark:hover:bg-neutral-700 hover:text-indigo-600 dark:hover:text-indigo-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-indigo-600 dark:text-indigo-400">{option.icon}</span>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{sortConfig.field === option.field && (
|
||||||
|
<span className="ml-auto text-xs">✓</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSortDirectionToggle}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700 h-[42px] min-w-[42px]",
|
||||||
|
"bg-white dark:bg-neutral-900 text-indigo-600 dark:text-indigo-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||||||
|
)}
|
||||||
|
title={sortConfig.direction === 'asc' ? 'Sort Ascending' : 'Sort Descending'}
|
||||||
|
>
|
||||||
|
{sortConfig.direction === 'asc' ? (
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 w-full sm:w-auto justify-end">
|
<div className="flex items-center gap-3 w-full sm:w-auto justify-end">
|
||||||
<div className="flex items-center gap-2 mr-2">
|
<div className="flex items-center gap-2 mr-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
disabled={sortedDrawings.length === 0}
|
||||||
|
className={clsx(
|
||||||
|
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
|
||||||
|
sortedDrawings.length > 0
|
||||||
|
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-indigo-600 dark:text-indigo-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||||||
|
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
title={allSelected ? "Deselect All" : "Select All"}
|
||||||
|
>
|
||||||
|
{allSelected ? <CheckSquare size={20} /> : <Square size={20} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleBulkDeleteClick}
|
onClick={handleBulkDeleteClick}
|
||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user