Security audit fixes, mobile responsiveness across 40+ admin pages
Security hardening from Mar 31 audit:
- Separate login rate limit (10/15min) from general auth budget (15/15min)
- Timing-safe webhook secret comparison (Listmonk)
- Docs file creation ACL check (matches PUT/DELETE guards)
- Key separation warnings for GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
- Clear GITEA_ADMIN_PASSWORD from .env after auto-setup
- SQL injection prevention in effectiveness groupBy (pre-validated map)
- Token hashing for password reset and verification tokens
Mobile responsiveness (Phase 2C):
- Add MobilePageHeader component and useMobile hook
- Responsive table columns (hide secondary cols on mobile)
- scroll={{ x: 'max-content' }} across all data tables
- Mobile-adapted layouts for Dashboard, Settings, Calendar, SMS, Social pages
- Conditional toolbar buttons on mobile viewports
Infrastructure:
- Updated docker-compose and nginx templates
- Build script and mirror script updates
Bunker Admin
This commit is contained in:
parent
d7ab8f0d99
commit
5a0c4641a1
221
CLAUDE.md
221
CLAUDE.md
@ -10,15 +10,19 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
|
||||
|
||||
**Status Summary:**
|
||||
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
|
||||
- ✅ Security Audit Complete (13 findings addressed, Feb 2026)
|
||||
- ✅ NAR 2025 Server Import (Canadian electoral data)
|
||||
- ✅ Media Manager Integration (dual API architecture)
|
||||
- ✅ Email Templates System
|
||||
- ✅ Data Quality Dashboard
|
||||
- ✅ Observability Dashboard
|
||||
- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026)
|
||||
- ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026)
|
||||
- ✅ **Migration Drift Fixed** (Baseline catch-up migration, 14 migrations cover full schema, Feb 2026)
|
||||
- ✅ Drizzle to Prisma Migration Complete (single-ORM, Feb 2026)
|
||||
- ✅ Automated Pangolin Setup (one-command tunnel deployment)
|
||||
- ✅ 3 Security Audits Complete (Feb 2025 + Mar 22/27/30 2026)
|
||||
- ✅ Social Connections + Calendar (friendship, shared views, availability finder)
|
||||
- ✅ Payments + Ticketed Events (Stripe integration, check-in scanner)
|
||||
- ✅ Meeting Planner + Straw Polls (scheduling, voting)
|
||||
- ✅ SMS Campaign Connector (Termux Android bridge)
|
||||
- ✅ Docs CMS (blog authoring, access policies, collaboration, version history)
|
||||
- ✅ User Provisioning Framework (Gitea, Vaultwarden, Listmonk)
|
||||
- ✅ Granular Admin Roles (9 admin roles + module-specific RBAC)
|
||||
- ✅ Collaborative Docs Editing (Y.js CRDT + Hocuspocus)
|
||||
- ✅ Engagement Scoring + EventBus + Gitea SSO
|
||||
- ✅ MCP Server (Claude Code integration, 27 core + 40 on-demand tools)
|
||||
- 🚧 Phase 15 (Testing + Polish) - Next
|
||||
|
||||
---
|
||||
@ -59,10 +63,9 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
|
||||
changemaker.lite/
|
||||
├── api/ # Dual API servers (Express + Fastify)
|
||||
│ ├── prisma/
|
||||
│ │ ├── schema.prisma # 30+ models: User, Campaign, Location, Shift, etc.
|
||||
│ │ ├── migrations/ # Prisma migration history
|
||||
│ │ ├── schema.prisma # 186 models: User, Campaign, Location, Shift, Payment, Social, etc.
|
||||
│ │ ├── migrations/ # 44 Prisma migrations (full schema history)
|
||||
│ │ └── seed.ts # Admin user, settings, page blocks
|
||||
│ ├── drizzle/ # Media tables (Drizzle ORM)
|
||||
│ ├── Dockerfile.media # Fastify media server container
|
||||
│ └── src/
|
||||
│ ├── server.ts # Express API entry point (port 4000)
|
||||
@ -70,10 +73,10 @@ changemaker.lite/
|
||||
│ ├── config/
|
||||
│ │ └── env.ts # Zod-validated environment config (100+ vars)
|
||||
│ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler
|
||||
│ ├── modules/
|
||||
│ ├── modules/ # 40 modules total
|
||||
│ │ ├── auth/ # JWT login, register, refresh, logout
|
||||
│ │ ├── users/ # User CRUD + pagination + search
|
||||
│ │ ├── settings/ # Site settings singleton
|
||||
│ │ ├── settings/ # Site settings singleton (20+ feature flags)
|
||||
│ │ ├── services/ # Service health checks
|
||||
│ │ ├── influence/
|
||||
│ │ │ ├── campaigns/ # Campaign CRUD + public routes
|
||||
@ -90,16 +93,39 @@ changemaker.lite/
|
||||
│ │ │ ├── canvass/ # Canvassing sessions + visits + routes
|
||||
│ │ │ ├── tracking/ # GPS tracking sessions (volunteer + admin routes)
|
||||
│ │ │ └── settings/ # Map settings singleton
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── pages-admin.routes.ts # Landing page CRUD
|
||||
│ │ │ ├── pages-public.routes.ts # Public page renderer
|
||||
│ │ │ └── blocks.routes.ts # Block library API
|
||||
│ │ ├── pages/ # Landing page CRUD + block library + public renderer
|
||||
│ │ ├── email-templates/ # Email template CRUD + rendering
|
||||
│ │ ├── media/ # Fastify media API (videos, reactions, jobs)
|
||||
│ │ ├── media/ # Fastify media API (videos, reactions, jobs, analytics)
|
||||
│ │ ├── social/ # Friendships, challenges, spotlights, referrals
|
||||
│ │ ├── calendar/ # Calendar layers, items, shared views, availability
|
||||
│ │ ├── payments/ # Stripe products, donations, subscriptions
|
||||
│ │ ├── ticketed-events/ # Event ticketing, tiers, check-in
|
||||
│ │ ├── sms/ # SMS campaigns via Termux Android bridge
|
||||
│ │ ├── meeting-planner/ # Meeting scheduling with polls
|
||||
│ │ ├── meetings/ # Meeting agendas, minutes, action items
|
||||
│ │ ├── polls/ # Straw polls with comments + voting
|
||||
│ │ ├── docs/ # MkDocs health checks + export routes
|
||||
│ │ ├── docs-analytics/ # Docs page view tracking
|
||||
│ │ ├── docs-comments/ # Gitea-backed comments on docs
|
||||
│ │ ├── people/ # CRM people module
|
||||
│ │ ├── events/ # Gancio event integration
|
||||
│ │ ├── newsletter/ # Newsletter management
|
||||
│ │ ├── listmonk/ # Newsletter sync admin routes
|
||||
│ │ ├── pangolin/ # Tunnel management (Newt integration)
|
||||
│ │ ├── docs/ # MkDocs + Code Server health checks
|
||||
│ │ ├── rocketchat/ # Rocket.Chat integration
|
||||
│ │ ├── jitsi/ # Jitsi video conferencing auth
|
||||
│ │ ├── registry/ # Docker image registry management
|
||||
│ │ ├── upgrade/ # Auto-upgrade checks + deployment
|
||||
│ │ ├── gitea-setup/ # Gitea SSO + API token management
|
||||
│ │ ├── volunteer-invite/ # Invite codes + setup workflows
|
||||
│ │ ├── gallery-ads/ # Media gallery ads
|
||||
│ │ ├── homepage/ # Homepage stats + dashboard
|
||||
│ │ ├── search/ # Cross-module search
|
||||
│ │ ├── reports/ # Analytics + reporting
|
||||
│ │ ├── og/ # Open Graph metadata
|
||||
│ │ ├── qr/ # QR code PNG generation (public)
|
||||
│ │ ├── dashboard/ # Admin dashboard data
|
||||
│ │ ├── activity/ # Activity feed
|
||||
│ │ └── observability/ # Prometheus/Grafana/Alertmanager integration
|
||||
│ ├── services/ # email, email-queue, geocode-queue, listmonk, pangolin, docker
|
||||
│ ├── types/ # express.d.ts (Request augmentation)
|
||||
@ -119,23 +145,25 @@ changemaker.lite/
|
||||
│ │ ├── media/ # VideoCard, BulkActions, gallery components
|
||||
│ │ ├── email-templates/ # Email template components
|
||||
│ │ └── observability/ # Monitoring components
|
||||
│ ├── pages/
|
||||
│ │ ├── auth/ # LoginPage
|
||||
│ │ ├── influence/ # CampaignsPage, ResponsesPage, RepresentativesPage, EmailQueuePage
|
||||
│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboardPage
|
||||
│ │ ├── volunteer/ # VolunteerMapPage, VolunteerShiftsPage, MyActivityPage, MyRoutesPage
|
||||
│ │ ├── public/ # CampaignsListPage, CampaignPage, ResponseWallPage, MapPage, ShiftsPage, LandingPage, MediaGalleryPage, MediaViewerPage
|
||||
│ │ ├── media/ # LibraryPage, SharedMediaPage, MediaJobsPage, AnalyticsDashboardPage
|
||||
│ │ ├── services/ # MiniQRPage, MailHogPage, CodeEditorPage, N8nPage, GiteaPage, NocoDBPage
|
||||
│ │ └── (root) # DashboardPage, UsersPage, SettingsPage, CanvassDashboardPage, WalkSheetPage, CutExportPage, LandingPagesPage, PageEditorPage, EmailTemplatesPage, ListmonkPage, PangolinPage, ObservabilityPage
|
||||
│ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand)
|
||||
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios)
|
||||
│ ├── pages/ # 52 root pages + 8 subdirectories
|
||||
│ │ ├── influence/ # Campaign moderation, effectiveness, impact stories, straw polls
|
||||
│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboard
|
||||
│ │ ├── media/ # Library, Playlists, Analytics, Gallery Ads, Comment Moderation
|
||||
│ │ ├── payments/ # Dashboard, Products, Plans, Donations, Subscribers, Settings
|
||||
│ │ ├── social/ # Dashboard, Graph, Moderation, Referrals, Spotlights, Challenges
|
||||
│ │ ├── sms/ # Dashboard, Contacts, Campaigns, Conversations, Templates, Setup
|
||||
│ │ ├── events/ # Ticketed Events, Event Detail, Check-in Scanner
|
||||
│ │ ├── volunteer/ # Map, Shifts, Routes, Calendar, Friends, Profile, Groups, Achievements
|
||||
│ │ ├── public/ # Homepage, Campaigns, Map, Events, Media Gallery, Pricing, Donations, Meet
|
||||
│ │ └── (root) # Dashboard, Users, Settings, Docs*, MeetingPlanner, Observability, etc.
|
||||
│ ├── stores/ # 9 Zustand stores (auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking)
|
||||
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts, nav-defaults.ts, service-url.ts, y-textarea.ts
|
||||
│ ├── hooks/ # useDebounce, useLocalStorage
|
||||
│ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces)
|
||||
│
|
||||
├── media-manager/ # Legacy media manager (reference)
|
||||
├── mcp-server/ # Claude Code MCP server (27 core + 40 on-demand tools)
|
||||
├── nginx/ # Reverse proxy config (subdomain routing + CSP)
|
||||
├── configs/ # Prometheus, Grafana, Alertmanager configs
|
||||
├── configs/ # Prometheus, Grafana, Alertmanager, Pangolin configs
|
||||
├── scripts/ # Deployment, backup, upgrade, registry scripts
|
||||
│ ├── install.sh # Curl-friendly installer (downloads tarball + runs config.sh)
|
||||
│ ├── build-and-push.sh # Build production images → push to Gitea registry
|
||||
@ -144,9 +172,10 @@ changemaker.lite/
|
||||
│ ├── upgrade.sh # 6-phase upgrade (git or release-tarball mode)
|
||||
│ ├── upgrade-check.sh # Check for updates (git or Gitea API)
|
||||
│ ├── upgrade-watcher.sh # Systemd bridge for admin GUI upgrades
|
||||
│ └── backup.sh # PostgreSQL + Listmonk + uploads backup
|
||||
├── docker-compose.yml # V2 orchestration (20+ services)
|
||||
├── docker-compose.v1.yml # V1 backup (reference)
|
||||
│ ├── backup.sh / restore.sh # PostgreSQL + Listmonk + uploads backup/restore
|
||||
│ └── validate-env.sh # Required env variable validation
|
||||
├── docker-compose.yml # V2 orchestration (40+ services)
|
||||
├── docker-compose.prod.yml # Production (image-only, no source mounts)
|
||||
├── .env.example # All required environment variables
|
||||
└── V2_PLAN.md # Full 14-phase roadmap
|
||||
```
|
||||
@ -238,27 +267,34 @@ cd api && npm run dev:media
|
||||
|---------|-----|---------------------|
|
||||
| Admin GUI | http://localhost:3000 | See INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env |
|
||||
| API | http://localhost:4000 | - |
|
||||
| Media API | http://localhost:4100 | - |
|
||||
| NocoDB | http://localhost:8091 | See `NC_ADMIN_EMAIL`/`NC_ADMIN_PASSWORD` in .env |
|
||||
| Gitea | http://localhost:3030 | See `GITEA_ADMIN_USER`/`GITEA_ADMIN_PASSWORD` in .env |
|
||||
| MailHog | http://localhost:8025 | - |
|
||||
| Grafana | http://localhost:3001 | admin / admin |
|
||||
| Prometheus | http://localhost:9090 | - |
|
||||
| Listmonk | http://localhost:9001 | See `LISTMONK_WEB_ADMIN_USER`/`PASSWORD` in .env |
|
||||
| Rocket.Chat | http://localhost:3100 | See RC env vars in .env |
|
||||
| Excalidraw | http://localhost:8090 | - |
|
||||
| Vaultwarden | http://localhost:8093 | See `VAULTWARDEN_ADMIN_TOKEN` in .env |
|
||||
|
||||
### Feature Flags
|
||||
|
||||
Enable optional features in `.env`:
|
||||
Most features are toggled via **SiteSettings** in the database (admin Settings page). Some also have `.env` overrides:
|
||||
|
||||
```bash
|
||||
# Media Manager
|
||||
ENABLE_MEDIA_FEATURES=true
|
||||
|
||||
# Listmonk Newsletter Sync
|
||||
LISTMONK_SYNC_ENABLED=true
|
||||
|
||||
# Email Test Mode (sends to MailHog instead of SMTP)
|
||||
EMAIL_TEST_MODE=true
|
||||
# .env feature flags (env-level)
|
||||
ENABLE_MEDIA_FEATURES=true # Media manager
|
||||
ENABLE_PAYMENTS=true # Stripe integration
|
||||
ENABLE_SMS=true # SMS campaigns
|
||||
ENABLE_CHAT=true # Rocket.Chat
|
||||
ENABLE_MEET=true # Jitsi meetings
|
||||
LISTMONK_SYNC_ENABLED=true # Newsletter sync
|
||||
EMAIL_TEST_MODE=true # MailHog vs SMTP
|
||||
```
|
||||
|
||||
**Database feature flags (SiteSettings):** `enableInfluence`, `enableMap`, `enableNewsletter`, `enableLandingPages`, `enableMediaFeatures`, `enablePayments`, `enableGalleryAds`, `enableChat`, `enableEvents`, `enableDocsComments`, `enableSms`, `enablePeople`, `enableSocial`, `enableMeet`, `enableMeetingPlanner`, `enableTicketedEvents`, `enableSocialCalendar`, `enablePolls`, `enableDocsCollaboration`, `enableUserProvisioning`
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
@ -272,7 +308,6 @@ cd api && npm run dev:media # Fastify media dev server (port 4100)
|
||||
cd api && npx tsc --noEmit # Type-check
|
||||
cd api && npx prisma migrate dev # Run/create Prisma migrations
|
||||
cd api && npx prisma studio # Browse database
|
||||
cd api && npx drizzle-kit push # Push Drizzle schema changes (media)
|
||||
```
|
||||
|
||||
### Admin Development
|
||||
@ -295,7 +330,6 @@ docker compose logs -f media-api
|
||||
|
||||
# Database operations
|
||||
docker compose exec api npx prisma migrate dev
|
||||
docker compose exec api npx drizzle-kit push
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
@ -513,20 +547,25 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
|
||||
| **Core Services** | | |
|
||||
| 3000 | Admin GUI | Vite dev / React production |
|
||||
| 4000 | Express API | Main V2 API (Prisma) |
|
||||
| 4100 | Fastify Media API | Video library (Drizzle) |
|
||||
| 4100 | Fastify Media API | Video library (Prisma) |
|
||||
| 5433 | V2 PostgreSQL | Localhost (container: 5432) |
|
||||
| 6379 | Redis | Cache, rate limit, BullMQ |
|
||||
| **Supporting Services** | | |
|
||||
| 3001 | Grafana | Metrics visualization |
|
||||
| 3010 | Homepage | Service dashboard |
|
||||
| 3030 | Gitea | Git hosting |
|
||||
| 3030 | Gitea | Git hosting + SSO |
|
||||
| 3100 | Rocket.Chat | Team chat (embed proxy) |
|
||||
| 4001 | MkDocs Site | Served docs |
|
||||
| 4003 | MkDocs Dev | Live preview |
|
||||
| 5432 | Listmonk PostgreSQL | Listmonk DB |
|
||||
| 5678 | n8n | Workflow automation |
|
||||
| 8025 | MailHog | Email capture (dev) |
|
||||
| 8089 | Mini QR | QR generator |
|
||||
| 8090 | Excalidraw | Collaborative whiteboard |
|
||||
| 8091 | NocoDB | Data browser |
|
||||
| 8092 | Gancio | Event management |
|
||||
| 8093 | Vaultwarden | Password manager |
|
||||
| 8443 | Jitsi Web | Video conferencing |
|
||||
| 8885 | Mini QR Proxy | Iframe-friendly |
|
||||
| 8888 | Code Server | Web IDE |
|
||||
| 9001 | Listmonk | Newsletter platform |
|
||||
@ -551,11 +590,17 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
|
||||
| `docs.cmlite.org` | MkDocs (4003) | Docs site |
|
||||
| `code.cmlite.org` | Code Server (8888) | Web IDE |
|
||||
| `n8n.cmlite.org` | n8n (5678) | Workflow automation |
|
||||
| `git.cmlite.org` | Gitea (3030) | Git hosting |
|
||||
| `git.cmlite.org` | Gitea (3030) | Git hosting + SSO |
|
||||
| `home.cmlite.org` | Homepage (3010) | Dashboard |
|
||||
| `grafana.cmlite.org` | Grafana (3001) | Metrics viz |
|
||||
| `listmonk.cmlite.org` | Listmonk (9001) | Newsletters |
|
||||
| `qr.cmlite.org` | Mini QR (8089) | QR generator |
|
||||
| `chat.cmlite.org` | Rocket.Chat (3100) | Team chat |
|
||||
| `meet.cmlite.org` | Jitsi (8443) | Video conferencing |
|
||||
| `events.cmlite.org` | Gancio (8092) | Event management |
|
||||
| `draw.cmlite.org` | Excalidraw (8090) | Collaborative whiteboard |
|
||||
| `vault.cmlite.org` | Vaultwarden (8093) | Password manager |
|
||||
| `mail.cmlite.org` | MailHog (8025) | Email capture (dev) |
|
||||
| `cmlite.org` | MkDocs Static (4004) | **Documentation/marketing site only** |
|
||||
|
||||
**Clean separation:** Root domain (`${DOMAIN}`) serves MkDocs documentation site. All application functionality (admin GUI, public campaigns, map, shifts, media gallery) is accessible via `app.${DOMAIN}` subdomain. This provides clear separation between public documentation and the application.
|
||||
@ -564,7 +609,7 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Note:** See `MEMORY.md` for comprehensive development patterns, gotchas, and lessons learned. Below are V2-specific patterns only.
|
||||
**Note:** Below are the key development patterns for this project.
|
||||
|
||||
### API Router Structure
|
||||
- Service layer (`*.service.ts`) — business logic, database queries
|
||||
@ -579,47 +624,57 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
|
||||
- Login redirects: ADMIN_ROLES → `/app`, USER/TEMP → `/volunteer`
|
||||
|
||||
### Frontend Architecture
|
||||
- Admin pages: `admin/src/pages/` (AppLayout)
|
||||
- Admin pages: `admin/src/pages/` + subdirs (AppLayout)
|
||||
- Public pages: `admin/src/pages/public/` (PublicLayout, dark theme)
|
||||
- Volunteer pages: `admin/src/pages/volunteer/` (VolunteerLayout)
|
||||
- Zustand stores: `auth.store.ts`, `canvass.store.ts`
|
||||
- Zustand stores (9): auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking
|
||||
- API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts`
|
||||
|
||||
### Database ORMs
|
||||
- **Prisma** (main API): Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays
|
||||
- **Drizzle** (media API): Separate schema file, push with `npx drizzle-kit push`, no migrations generated
|
||||
### Database ORM
|
||||
- **Prisma** (both APIs): 186 models in single `schema.prisma`. Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays
|
||||
|
||||
### Prisma Migration Workflow
|
||||
- **Always use `prisma migrate dev`** for schema changes (not `prisma db push`) — `db push` applies changes directly but doesn't create migration files, causing drift
|
||||
- **Migration history:** 14 migrations in `api/prisma/migrations/` fully cover the schema (baseline catch-up applied Feb 2026)
|
||||
- **Fixing drift:** Use `prisma migrate diff --from-migrations ... --to-schema-datamodel ... --script` with a shadow DB to generate catch-up SQL, then `prisma migrate resolve --applied`. See MEMORY.md for detailed steps
|
||||
- **Migration history:** 44 migrations in `api/prisma/migrations/` fully cover the schema
|
||||
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`)
|
||||
|
||||
### V2-Specific Gotchas
|
||||
### Key Gotchas
|
||||
- **Prisma migrations:** Never use `db push` — always `migrate dev` to keep history in sync
|
||||
- Nginx media API block must come BEFORE general API block
|
||||
- `IMAGE_TAG=local` (default) never pulls from registry; set to SHA or `latest` for pre-built images
|
||||
- **Release vs source installs:** Detected by `VERSION` file + absence of `.git/`; release uses `docker-compose.prod.yml`, source uses `docker-compose.yml`
|
||||
- **`api/dist/` is gitignored** — never commit; if root-owned from container builds, fix with `chown`
|
||||
- See MEMORY.md "Common Gotchas" for additional gotchas (ports, volumes, media upload, registry, etc.)
|
||||
- **`!` in passwords** triggers bash history expansion — use Write tool to write JSON to file, then `curl -d @file`
|
||||
- **Port mappings:** API container 4000 → host 4002, Admin container 3000 → host 3002
|
||||
- **BullMQ** needs its own Redis connections (pass URL string, not shared ioredis instance)
|
||||
- **Public pages** use `axios` directly (no auth interceptor), admin pages use `{ api }` from lib
|
||||
- **Prisma JSON fields:** typed arrays need `as unknown as Prisma.InputJsonValue` cast
|
||||
- **nginx conf.d files** have `.template` counterparts used by envsubst at startup
|
||||
|
||||
---
|
||||
|
||||
## Security & Configuration
|
||||
|
||||
### Security Audit
|
||||
Comprehensive security audit completed 2025-02-11, addressing 13 findings. See `SECURITY_AUDIT_2025-02-11.md` for full report.
|
||||
### Security Audits
|
||||
Four security audits completed. See audit reports for full details:
|
||||
- **Feb 2025:** 13 findings (password policy, rate limits, token rotation, XSS prevention). `SECURITY_AUDIT_2025-02-11.md`
|
||||
- **Mar 22 2026:** JWT algorithm lockdown, invite secret separation, webhook hardening, CSV injection, QR DoS
|
||||
- **Mar 27 2026:** 33 findings (30 fixed) — IDOR, XSS, path traversal, MongoDB auth, SSTI, open redirect
|
||||
- **Mar 30 2026:** 19 findings — IDOR action items/ticketed events, nginx rate limit, JWT secret reuse
|
||||
|
||||
**Key improvements:**
|
||||
**Key security features:**
|
||||
- Password policy: 12+ chars, uppercase, lowercase, digit (schema-enforced)
|
||||
- Rate limits on auth endpoints (10/min per IP)
|
||||
- Refresh token rotation (atomic transaction)
|
||||
- Rate limits on auth endpoints (10/min per IP) + nginx rate limiting
|
||||
- Refresh token rotation (atomic Prisma transaction)
|
||||
- JWT algorithm locked to HS256, separate invite secret
|
||||
- User enumeration prevention (401 not 404)
|
||||
- Redis authentication required
|
||||
- XSS/injection prevention (HTML escaping)
|
||||
- Path traversal protection
|
||||
- XSS/injection prevention (HTML escaping, DOMPurify, SSTI protection)
|
||||
- Path traversal protection (resolve + startsWith checks)
|
||||
- Encryption key for DB secrets (`ENCRYPTION_KEY` required in all environments)
|
||||
- Nginx security headers (HSTS, Permissions-Policy, CSP)
|
||||
- Nginx security headers (HSTS, Permissions-Policy, CSP, X-Forwarded-For)
|
||||
- MongoDB keyfile authentication
|
||||
- httpOnly cookies for refresh tokens
|
||||
|
||||
### Required Environment Variables
|
||||
See `.env.example` for all 100+ variables. Critical ones:
|
||||
@ -642,8 +697,8 @@ See `.env.example` for all 100+ variables. Critical ones:
|
||||
When deploying to a production domain via Pangolin tunnel, you MUST update the `.env` file to include the production domain in `CORS_ORIGINS`:
|
||||
|
||||
```bash
|
||||
# Example for betteredmonton.org
|
||||
CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.org,http://localhost:3000,http://localhost
|
||||
# Example for cmlite.org
|
||||
CORS_ORIGINS=http://app.cmlite.org,https://app.cmlite.org,http://localhost:3000,http://localhost
|
||||
|
||||
# Also set production mode
|
||||
NODE_ENV=production
|
||||
@ -672,18 +727,16 @@ docker compose restart api
|
||||
4. Save changes
|
||||
|
||||
**Critical resources to fix first:**
|
||||
- `api.betteredmonton.org` - Main API (all endpoints fail without this)
|
||||
- `app.betteredmonton.org` - Admin GUI + public pages
|
||||
- `media.betteredmonton.org` - Media API
|
||||
- `api.${DOMAIN}` - Main API (all endpoints fail without this)
|
||||
- `app.${DOMAIN}` - Admin GUI + public pages
|
||||
- `media.${DOMAIN}` - Media API
|
||||
|
||||
**Verification:**
|
||||
```bash
|
||||
# Should return JSON, NOT a 302 redirect
|
||||
curl https://api.betteredmonton.org/api/health
|
||||
curl https://api.cmlite.org/api/health
|
||||
```
|
||||
|
||||
**See Also:** `PRODUCTION_403_FIX.md` for detailed step-by-step instructions.
|
||||
|
||||
### CORS Errors in Production
|
||||
|
||||
**Symptom:** Browser console shows CORS errors when accessing production domain.
|
||||
@ -706,25 +759,21 @@ Check container status (`docker compose ps`), verify credentials in `.env`, chec
|
||||
|
||||
## V1 Reference (Legacy)
|
||||
|
||||
V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two independent Express apps using NocoDB REST API. See individual README files for V1 documentation:
|
||||
- `influence/README.MD` — Features, config, campaign management
|
||||
- `map/README.md` — Features, config, setup instructions
|
||||
- Both use session-based auth, bcryptjs passwords, Bull job queues
|
||||
V1 code has been removed from the repo. History preserved as `v1-archive` git tag. `docker-compose.v1.yml` remains as reference only.
|
||||
|
||||
---
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
### Infrastructure
|
||||
- `docker-compose.yml` — Development orchestration (build blocks + source mounts, 20+ services)
|
||||
- `docker-compose.yml` — Development orchestration (build blocks + source mounts, 40+ services)
|
||||
- `docker-compose.prod.yml` — Production orchestration (image-only, no source mounts, `IMAGE_TAG:-latest`)
|
||||
- `.env` / `.env.example` — Environment variables (100+ vars)
|
||||
- `config.sh` — Interactive setup wizard (14 steps, release-mode aware)
|
||||
|
||||
### Database
|
||||
- `api/prisma/schema.prisma` — Main schema (30+ Prisma models)
|
||||
- `api/prisma/migrations/` — 14 migration files (fully cover schema as of Feb 2026)
|
||||
- `api/drizzle.config.ts` — Drizzle config for media tables
|
||||
- `api/prisma/schema.prisma` — Main schema (186 Prisma models)
|
||||
- `api/prisma/migrations/` — 44 migration files (full schema history)
|
||||
- `api/prisma/seed.ts` — Database seeding
|
||||
|
||||
### Nginx
|
||||
@ -742,5 +791,5 @@ V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two indep
|
||||
### Documentation
|
||||
- `CLAUDE.md` — Project-wide instructions (this file)
|
||||
- `V2_PLAN.md` — Full 14-phase roadmap
|
||||
- `SECURITY_AUDIT_2025-02-11.md` — Security audit report
|
||||
- `MEMORY.md` — Development patterns and gotchas
|
||||
- `SECURITY_AUDIT_2025-02-11.md` — Initial security audit report
|
||||
- `.mcp.json` — MCP server configuration for Claude Code
|
||||
|
||||
@ -647,11 +647,14 @@ export default function AppLayout() {
|
||||
const getIcon = (iconName: string) => ADMIN_ICON_OVERRIDES[iconName] ?? ICON_MAP[iconName] ?? <GlobalOutlined />;
|
||||
const handleItemClick = (item: NavItem) => {
|
||||
if (item.path.startsWith('$')) {
|
||||
window.open(resolveNavUrl(item.path), '_blank');
|
||||
window.open(resolveNavUrl(item.path), '_blank', 'noopener,noreferrer');
|
||||
} else if (item.external && item.id === 'home') {
|
||||
window.open(buildHomeUrl(), '_blank');
|
||||
window.open(buildHomeUrl(), '_blank', 'noopener,noreferrer');
|
||||
} else if (item.external) {
|
||||
window.open(item.path, '_blank');
|
||||
// Only open http/https URLs to prevent javascript: URI injection
|
||||
if (item.path.startsWith('http://') || item.path.startsWith('https://')) {
|
||||
window.open(item.path, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} else {
|
||||
navigate(item.path);
|
||||
}
|
||||
|
||||
47
admin/src/components/MobilePageHeader.tsx
Normal file
47
admin/src/components/MobilePageHeader.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Row, Col, Typography, Space } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
|
||||
interface MobilePageHeaderProps {
|
||||
title: string;
|
||||
/** Optional element next to the title (badge, count, etc.) */
|
||||
extra?: ReactNode;
|
||||
/** Action buttons — will wrap on mobile, stay inline on desktop */
|
||||
actions?: ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive page header that stacks title and actions on mobile.
|
||||
* On desktop: title left, actions right (single row).
|
||||
* On mobile: title full-width, actions below with wrapping.
|
||||
*/
|
||||
export function MobilePageHeader({ title, extra, actions, style }: MobilePageHeaderProps) {
|
||||
const { isMobile } = useMobile();
|
||||
|
||||
return (
|
||||
<Row
|
||||
justify="space-between"
|
||||
align={isMobile ? 'top' : 'middle'}
|
||||
style={{ marginBottom: 16, ...style }}
|
||||
gutter={[0, isMobile ? 12 : 0]}
|
||||
wrap
|
||||
>
|
||||
<Col xs={24} md="auto">
|
||||
<Space>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{title}
|
||||
</Typography.Title>
|
||||
{extra}
|
||||
</Space>
|
||||
</Col>
|
||||
{actions && (
|
||||
<Col xs={24} md="auto">
|
||||
<Space wrap size={isMobile ? 'small' : 'middle'}>
|
||||
{actions}
|
||||
</Space>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@ -148,15 +148,9 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
|
||||
|
||||
const connectSSE = async () => {
|
||||
try {
|
||||
// Get auth token from localStorage
|
||||
const stored = localStorage.getItem('auth-storage');
|
||||
let token = '';
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
token = parsed?.state?.accessToken || '';
|
||||
} catch {}
|
||||
}
|
||||
// Get auth token from in-memory store (not localStorage)
|
||||
const { useAuthStore } = await import('@/stores/auth.store');
|
||||
const token = useAuthStore.getState().accessToken || '';
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
headers: {
|
||||
|
||||
@ -57,7 +57,7 @@ export default function VideoCallModal({ open, onClose, personName }: VideoCallM
|
||||
const { data } = await api.post<{ token: string; jitsiRoom: string; domain: string }>(
|
||||
`/jitsi/meetings/${meeting.slug}/token`,
|
||||
);
|
||||
window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank');
|
||||
window.open(`https://${data.domain}/${data.jitsiRoom}?jwt=${data.token}`, '_blank', 'noopener,noreferrer');
|
||||
} catch (err: unknown) {
|
||||
message.error(getErrorMessage(err, 'Failed to get moderator token'));
|
||||
} finally {
|
||||
|
||||
18
admin/src/hooks/useMobile.ts
Normal file
18
admin/src/hooks/useMobile.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Grid } from 'antd';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
/**
|
||||
* Shared mobile detection hook.
|
||||
* Replaces duplicated `const screens = Grid.useBreakpoint(); const isMobile = !screens.md;`
|
||||
*
|
||||
* Breakpoints (Ant Design defaults):
|
||||
* xs: 0px, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1600px
|
||||
*/
|
||||
export function useMobile() {
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md; // < 768px
|
||||
const isSmall = !screens.sm; // < 576px (very narrow phones)
|
||||
const isTablet = !!screens.md && !screens.lg; // 768–991px
|
||||
return { isMobile, isSmall, isTablet, screens };
|
||||
}
|
||||
@ -361,6 +361,7 @@ export default function ActionItemsPage() {
|
||||
<Table
|
||||
dataSource={items}
|
||||
columns={columns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import type { AdminCalendarView } from '@/types/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import { ROLE_COLORS, ROLE_OPTIONS } from '@/utils/role-constants';
|
||||
@ -28,6 +29,7 @@ const LAYER_TYPE_OPTIONS = [
|
||||
|
||||
export default function AdminCalendarPage() {
|
||||
const navigate = useNavigate();
|
||||
const { isMobile } = useMobile();
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const [views, setViews] = useState<AdminCalendarView[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -125,6 +127,7 @@ export default function AdminCalendarPage() {
|
||||
title: 'Layer Types',
|
||||
dataIndex: 'includedLayerTypes',
|
||||
key: 'layerTypes',
|
||||
responsive: ['md'] as any,
|
||||
render: (types: string[]) => (
|
||||
<Space size={4} wrap>
|
||||
{types.map((t) => (
|
||||
@ -138,12 +141,14 @@ export default function AdminCalendarPage() {
|
||||
dataIndex: 'userCount',
|
||||
key: 'userCount',
|
||||
width: 80,
|
||||
responsive: ['lg'] as any,
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 120,
|
||||
responsive: ['md'] as any,
|
||||
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
|
||||
},
|
||||
{
|
||||
@ -187,6 +192,7 @@ export default function AdminCalendarPage() {
|
||||
<Table
|
||||
dataSource={views}
|
||||
columns={columns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
@ -202,6 +208,7 @@ export default function AdminCalendarPage() {
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
width={isMobile ? '100%' : undefined}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
|
||||
@ -132,6 +132,7 @@ export default function CanvassDashboardPage() {
|
||||
dataIndex: 'visitedAt',
|
||||
key: 'visitedAt',
|
||||
width: 80,
|
||||
responsive: ['md'] as any,
|
||||
render: (val: string) => dayjs(val).format('h:mm A'),
|
||||
},
|
||||
];
|
||||
@ -144,12 +145,13 @@ export default function CanvassDashboardPage() {
|
||||
render: (_: unknown, r: VolunteerSummary) => r.name || r.email,
|
||||
},
|
||||
{ title: 'Visits', dataIndex: 'totalVisits', key: 'visits', width: 60, sorter: (a: VolunteerSummary, b: VolunteerSummary) => a.totalVisits - b.totalVisits },
|
||||
{ title: 'Sessions', dataIndex: 'sessions', key: 'sessions', width: 70 },
|
||||
{ title: 'Sessions', dataIndex: 'sessions', key: 'sessions', width: 70, responsive: ['md'] as any },
|
||||
{
|
||||
title: 'Last Active',
|
||||
dataIndex: 'lastActive',
|
||||
key: 'lastActive',
|
||||
width: 120,
|
||||
responsive: ['md'] as any,
|
||||
render: (val: string | null) => val ? dayjs(val).format('MMM D, h:mm A') : '—',
|
||||
},
|
||||
];
|
||||
@ -173,6 +175,7 @@ export default function CanvassDashboardPage() {
|
||||
dataIndex: 'lastCanvassed',
|
||||
key: 'lastCanvassed',
|
||||
width: 100,
|
||||
responsive: ['md'] as any,
|
||||
render: (val: string | null) => val ? dayjs(val).format('MMM D') : 'Never',
|
||||
},
|
||||
];
|
||||
@ -288,7 +291,7 @@ export default function CanvassDashboardPage() {
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
locale={{ emptyText: 'No activity recorded yet.' }}
|
||||
/>
|
||||
{activityPagination && activityPagination.total > 10 && (
|
||||
@ -305,7 +308,7 @@ export default function CanvassDashboardPage() {
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
locale={{ emptyText: 'No cuts assigned.' }}
|
||||
/>
|
||||
</Card>
|
||||
@ -317,7 +320,7 @@ export default function CanvassDashboardPage() {
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
locale={{ emptyText: 'No sessions recorded yet.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@ -177,6 +177,7 @@ export default function DashboardPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
|
||||
|
||||
useEffect(() => {
|
||||
@ -341,18 +342,22 @@ export default function DashboardPage() {
|
||||
{showInfluence && <Tooltip title="New Campaign"><Button type="text" icon={<PlusOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/campaigns')} /></Tooltip>}
|
||||
{showMap && <Tooltip title="Locations"><Button type="text" icon={<EnvironmentOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/map')} /></Tooltip>}
|
||||
{showMedia && <Tooltip title="Videos"><Button type="text" icon={<UploadOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/media/library')} /></Tooltip>}
|
||||
<Tooltip title="Pages"><Button type="text" icon={<FileTextOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/pages')} /></Tooltip>
|
||||
{!isMobile && <Tooltip title="Pages"><Button type="text" icon={<FileTextOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/pages')} /></Tooltip>}
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Tooltip title="Monitoring"><Button type="text" icon={<BarChartOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/observability')} /></Tooltip>
|
||||
<Tooltip title="Tunnel"><Button type="text" icon={<CloudServerOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/tunnel')} /></Tooltip>
|
||||
<Tooltip title="NocoDB"><Button type="text" icon={<DatabaseOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/nocodb')} /></Tooltip>
|
||||
<Tooltip title="Workflows"><Button type="text" icon={<BranchesOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/n8n')} /></Tooltip>
|
||||
<Tooltip title="Git"><Button type="text" icon={<GlobalOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/gitea')} /></Tooltip>
|
||||
<Tooltip title="Code"><Button type="text" icon={<CodeOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/code')} /></Tooltip>
|
||||
<Tooltip title="Docs"><Button type="text" icon={<BookOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/docs')} /></Tooltip>
|
||||
<Tooltip title="QR"><Button type="text" icon={<QrcodeOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/miniqr')} /></Tooltip>
|
||||
<Tooltip title="Data Quality"><Button type="text" icon={<DashboardOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/map/data-quality')} /></Tooltip>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Tooltip title="Tunnel"><Button type="text" icon={<CloudServerOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/tunnel')} /></Tooltip>
|
||||
<Tooltip title="NocoDB"><Button type="text" icon={<DatabaseOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/nocodb')} /></Tooltip>
|
||||
<Tooltip title="Workflows"><Button type="text" icon={<BranchesOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/n8n')} /></Tooltip>
|
||||
<Tooltip title="Git"><Button type="text" icon={<GlobalOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/gitea')} /></Tooltip>
|
||||
<Tooltip title="Code"><Button type="text" icon={<CodeOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/code')} /></Tooltip>
|
||||
<Tooltip title="Docs"><Button type="text" icon={<BookOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/docs')} /></Tooltip>
|
||||
<Tooltip title="QR"><Button type="text" icon={<QrcodeOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/services/miniqr')} /></Tooltip>
|
||||
<Tooltip title="Data Quality"><Button type="text" icon={<DashboardOutlined style={{ color: '#fff', fontSize: 18 }} />} onClick={() => navigate('/app/map/data-quality')} /></Tooltip>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 18 }} />} onClick={handleRefresh} /></Tooltip>
|
||||
@ -427,9 +432,9 @@ export default function DashboardPage() {
|
||||
|
||||
{/* === Status Bar (weather + stats + pending actions + connectivity) === */}
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }} styles={{ body: { padding: '10px 16px' } }} data-tour-dashboard-stats>
|
||||
<Card size="small" style={{ marginBottom: 16 }} styles={{ body: { padding: isMobile ? '8px 10px' : '10px 16px' } }} data-tour-dashboard-stats>
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
|
||||
<Flex gap={0} wrap="wrap" align="center">
|
||||
<Flex gap={0} align="center" style={isMobile ? { overflowX: 'auto', maxWidth: '100%', WebkitOverflowScrolling: 'touch' } : { flexWrap: 'wrap' }}>
|
||||
{weather && (
|
||||
<Flex align="center" gap={6} style={{ padding: '0 14px 0 0', borderRight: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ fontSize: 24 }}>{getWeatherIcon(weather.weatherCode, weather.isDay)}</span>
|
||||
@ -469,7 +474,7 @@ export default function DashboardPage() {
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
{isSuperAdmin && connectivity && (
|
||||
{isSuperAdmin && connectivity && !isMobile && (
|
||||
<Flex gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||
<ConnectivityDot label="SMTP" online={connectivity.smtp} />
|
||||
<ConnectivityDot label="Listmonk" online={connectivity.listmonk} />
|
||||
@ -981,7 +986,7 @@ export default function DashboardPage() {
|
||||
<ResponsiveContainer width="100%" height={Math.max(routeBarData.length * 24 + 20, 120)}>
|
||||
<BarChart data={routeBarData} layout="vertical" margin={{ top: 0, right: 16, left: 4, bottom: 0 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={120} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={isMobile ? 80 : 120} />
|
||||
<RechartsTooltip contentStyle={{ fontSize: 12, borderRadius: 6 }} />
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||
{routeBarData.map((_, i) => (
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
|
||||
interface DocsCommentItem {
|
||||
@ -122,7 +123,7 @@ function SettingsTab() {
|
||||
description="Comments are stored as Gitea issues. Anonymous comments go through a moderation queue. Authenticated users comment via Gitea OAuth2 login."
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 600 }}>
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 600, width: '100%' }}>
|
||||
<Form.Item name="enableDocsComments" label="Enable Docs Comments" valuePropName="checked">
|
||||
<Switch onChange={(val) => setEnabled(val)} />
|
||||
</Form.Item>
|
||||
@ -211,6 +212,7 @@ function SettingsTab() {
|
||||
export default function DocsCommentsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { message } = App.useApp();
|
||||
const { isMobile } = useMobile();
|
||||
const [activeTab, setActiveTab] = useState('moderation');
|
||||
const [stats, setStats] = useState<CommentStats | null>(null);
|
||||
const [comments, setComments] = useState<DocsCommentItem[]>([]);
|
||||
@ -282,6 +284,7 @@ export default function DocsCommentsPage() {
|
||||
dataIndex: 'authorName',
|
||||
key: 'authorName',
|
||||
width: 140,
|
||||
responsive: ['md'] as any,
|
||||
render: (name: string, record: DocsCommentItem) => (
|
||||
<span title={record.authorEmail || undefined}>{name}</span>
|
||||
),
|
||||
@ -309,6 +312,7 @@ export default function DocsCommentsPage() {
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 140,
|
||||
responsive: ['md'] as any,
|
||||
render: (date: string) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
@ -328,7 +332,7 @@ export default function DocsCommentsPage() {
|
||||
loading={moderating[record.id]}
|
||||
onClick={() => handleModerate(record.id, 'approve')}
|
||||
>
|
||||
Approve
|
||||
{!isMobile && 'Approve'}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="Reject this comment?"
|
||||
@ -341,7 +345,7 @@ export default function DocsCommentsPage() {
|
||||
icon={<CloseCircleOutlined />}
|
||||
loading={moderating[record.id]}
|
||||
>
|
||||
Reject
|
||||
{!isMobile && 'Reject'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
@ -404,6 +408,7 @@ export default function DocsCommentsPage() {
|
||||
<Table
|
||||
dataSource={comments}
|
||||
columns={columns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
|
||||
@ -107,7 +107,7 @@ export default function JitsiMeetPage() {
|
||||
try {
|
||||
const res = await api.post<{ token: string; jitsiRoom: string; domain: string }>(`/jitsi/meetings/${meeting.slug}/token`);
|
||||
const url = `${meetUrl}/${res.data.jitsiRoom}?jwt=${res.data.token}`;
|
||||
window.open(url, '_blank');
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} catch {
|
||||
message.error('Failed to generate meeting token');
|
||||
}
|
||||
@ -149,7 +149,7 @@ export default function JitsiMeetPage() {
|
||||
await navigator.clipboard.writeText(guestLink);
|
||||
|
||||
message.success('Guest link copied — opening meeting...');
|
||||
window.open(`${meetUrl}/${tokenRes.data.jitsiRoom}?jwt=${tokenRes.data.token}`, '_blank');
|
||||
window.open(`${meetUrl}/${tokenRes.data.jitsiRoom}?jwt=${tokenRes.data.token}`, '_blank', 'noopener,noreferrer');
|
||||
fetchMeetings();
|
||||
} catch {
|
||||
message.error('Failed to create fast meeting');
|
||||
|
||||
@ -484,6 +484,7 @@ export default function LandingPagesPage() {
|
||||
dataSource={pages}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
|
||||
@ -202,7 +202,7 @@ export default function ListmonkPage() {
|
||||
try {
|
||||
const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');
|
||||
const url = buildProxyAuthUrl(res.data.token);
|
||||
if (url) window.open(url, '_blank');
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
||||
} catch {
|
||||
message.error('Failed to get Listmonk auth URL');
|
||||
}
|
||||
|
||||
@ -501,6 +501,7 @@ export default function MeetingAgendaPage() {
|
||||
<Table
|
||||
dataSource={agendas}
|
||||
columns={columns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
@ -803,6 +804,7 @@ export default function MeetingAgendaPage() {
|
||||
<Table
|
||||
dataSource={selectedAgenda.actionItems || []}
|
||||
columns={actionItemColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
|
||||
@ -606,6 +606,7 @@ export default function MeetingPlannerPage() {
|
||||
<Table
|
||||
dataSource={polls}
|
||||
columns={columns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
|
||||
@ -27,6 +27,7 @@ import {
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { NavItem } from '@/types/api';
|
||||
import {
|
||||
@ -39,6 +40,7 @@ import {
|
||||
|
||||
export default function NavigationSettingsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { isMobile } = useMobile();
|
||||
const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();
|
||||
|
||||
const [navItems, setNavItems] = useState<NavItem[]>(DEFAULT_NAV_ITEMS);
|
||||
@ -293,6 +295,7 @@ export default function NavigationSettingsPage() {
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
paddingLeft: indent ? 52 : 12,
|
||||
minWidth: 600,
|
||||
background: isGroup
|
||||
? 'rgba(100,150,255,0.06)'
|
||||
: item.enabled ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.01)',
|
||||
@ -432,7 +435,7 @@ export default function NavigationSettingsPage() {
|
||||
const sorted = [...navItems].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800 }}>
|
||||
<div style={{ maxWidth: isMobile ? undefined : 800 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
message="Configure the navigation bar shown on all public pages, the admin header, Gancio events page, and MkDocs site. Groups appear as dropdowns on desktop and collapsible sections on mobile."
|
||||
@ -457,7 +460,7 @@ export default function NavigationSettingsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16, overflowX: isMobile ? 'auto' : undefined, WebkitOverflowScrolling: 'touch' as any }}>
|
||||
{sorted.map((item, idx) => (
|
||||
<div key={item.id}>
|
||||
{renderItemRow(item, idx, sorted, false)}
|
||||
@ -469,18 +472,18 @@ export default function NavigationSettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCustomLinkModalOpen(true)}
|
||||
>
|
||||
Add Custom Link
|
||||
{!isMobile && 'Add Custom Link'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FolderAddOutlined />}
|
||||
onClick={addGroup}
|
||||
>
|
||||
Add Group
|
||||
{!isMobile && 'Add Group'}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
|
||||
@ -406,6 +406,7 @@ export default function PangolinPage() {
|
||||
<Paragraph strong style={{ marginTop: 24 }}>Resources to Create ({resourceDefinitions.length})</Paragraph>
|
||||
<Table
|
||||
dataSource={resourceDefinitions}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="fullDomain"
|
||||
size="small"
|
||||
pagination={false}
|
||||
@ -488,6 +489,7 @@ export default function PangolinPage() {
|
||||
{setupResult.resources?.items && setupResult.resources.items.length > 0 && (
|
||||
<Table
|
||||
dataSource={setupResult.resources.items}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="resourceId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
|
||||
@ -327,7 +327,7 @@ export default function ResponsesPage() {
|
||||
dataSource={responses}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => setDetailResponse(record),
|
||||
style: { cursor: 'pointer' },
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import UnifiedCalendar from '@/components/calendar/UnifiedCalendar';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import type { UnifiedCalendarItem, AdminCalendarView } from '@/types/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ROLE_COLORS, ROLE_OPTIONS } from '@/utils/role-constants';
|
||||
@ -40,6 +41,7 @@ const LAYER_TYPE_OPTIONS = [
|
||||
|
||||
export default function SchedulingCalendarPage() {
|
||||
const navigate = useNavigate();
|
||||
const { isMobile } = useMobile();
|
||||
const addEventRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Panel state
|
||||
@ -154,6 +156,7 @@ export default function SchedulingCalendarPage() {
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 100,
|
||||
responsive: ['md'] as any,
|
||||
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
|
||||
},
|
||||
{
|
||||
@ -171,8 +174,10 @@ export default function SchedulingCalendarPage() {
|
||||
},
|
||||
];
|
||||
|
||||
// Compute right margin to squish calendar when drawers are open
|
||||
const drawerOffset = (viewsOpen ? VIEWS_PANEL_WIDTH : 0) + (formOpen ? FORM_PANEL_WIDTH : 0);
|
||||
// Compute right margin to squish calendar when drawers are open (skip on mobile — drawers overlay)
|
||||
const viewsWidth = isMobile ? '100%' : VIEWS_PANEL_WIDTH;
|
||||
const formWidth = isMobile ? '100%' : FORM_PANEL_WIDTH;
|
||||
const drawerOffset = isMobile ? 0 : (viewsOpen ? VIEWS_PANEL_WIDTH : 0) + (formOpen ? FORM_PANEL_WIDTH : 0);
|
||||
|
||||
const DRAWER_ROOT = { position: 'absolute' as const, top: 64, height: 'calc(100vh - 64px)' };
|
||||
|
||||
@ -212,7 +217,7 @@ export default function SchedulingCalendarPage() {
|
||||
type={viewsOpen ? 'primary' : 'default'}
|
||||
onClick={() => viewsOpen ? closeViews() : setViewsOpen(true)}
|
||||
>
|
||||
Shared Views
|
||||
{!isMobile && 'Shared Views'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@ -228,13 +233,13 @@ export default function SchedulingCalendarPage() {
|
||||
open={viewsOpen}
|
||||
onClose={closeViews}
|
||||
mask={false}
|
||||
width={VIEWS_PANEL_WIDTH}
|
||||
width={viewsWidth}
|
||||
rootStyle={DRAWER_ROOT}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
wrapper: {
|
||||
transition: 'transform 0.3s ease, width 0.3s ease',
|
||||
transform: formOpen ? `translateX(-${FORM_PANEL_WIDTH}px)` : undefined,
|
||||
transform: formOpen && !isMobile ? `translateX(-${FORM_PANEL_WIDTH}px)` : undefined,
|
||||
},
|
||||
}}
|
||||
extra={
|
||||
@ -246,6 +251,7 @@ export default function SchedulingCalendarPage() {
|
||||
<Table
|
||||
dataSource={views}
|
||||
columns={viewColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={viewsLoading}
|
||||
pagination={false}
|
||||
@ -263,7 +269,7 @@ export default function SchedulingCalendarPage() {
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
mask={false}
|
||||
width={FORM_PANEL_WIDTH}
|
||||
width={formWidth}
|
||||
rootStyle={DRAWER_ROOT}
|
||||
destroyOnHidden
|
||||
extra={
|
||||
|
||||
@ -58,6 +58,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import { PageTour } from '@/components/tour/PageTour';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { SmtpTestResult, SmtpSendTestResult, UpgradeStatusResponse, UpgradeStatus, UpgradeProgress, UpgradeResult, UpgradeHistoryResponse } from '@/types/api';
|
||||
@ -66,6 +67,7 @@ const { Text, Paragraph } = Typography;
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { isMobile } = useMobile();
|
||||
const locationState = useLocation().state as { activeTab?: string } | null;
|
||||
const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();
|
||||
const [form] = Form.useForm();
|
||||
@ -173,7 +175,7 @@ export default function SettingsPage() {
|
||||
label: 'Organization',
|
||||
icon: <SettingOutlined />,
|
||||
children: (
|
||||
<div style={{ maxWidth: 600 }} data-tour-settings-org>
|
||||
<div style={{ maxWidth: isMobile ? undefined : 600 }} data-tour-settings-org>
|
||||
<Form.Item label="Organization Name" name="organizationName">
|
||||
<Input placeholder="Changemaker Lite" />
|
||||
</Form.Item>
|
||||
@ -199,7 +201,7 @@ export default function SettingsPage() {
|
||||
key: 'theme',
|
||||
label: 'Theme Colors',
|
||||
children: (
|
||||
<div style={{ maxWidth: 600 }} data-tour-settings-theme>
|
||||
<div style={{ maxWidth: isMobile ? undefined : 600 }} data-tour-settings-theme>
|
||||
<Text strong style={{ fontSize: 15 }}>Admin Theme</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item label="Primary Color" name="adminColorPrimary">
|
||||
@ -250,7 +252,7 @@ export default function SettingsPage() {
|
||||
const isMailhog = (settings?.smtpActiveProvider || 'mailhog') === 'mailhog';
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600 }} data-tour-settings-email>
|
||||
<div style={{ maxWidth: isMobile ? undefined : 600 }} data-tour-settings-email>
|
||||
{/* Current Configuration Summary */}
|
||||
{eff && (
|
||||
<Card size="small" style={{ marginBottom: 24 }}>
|
||||
@ -682,7 +684,7 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<Form form={form} layout="vertical">
|
||||
<Tabs items={items} defaultActiveKey={locationState?.activeTab || 'organization'} />
|
||||
<Tabs items={items} defaultActiveKey={locationState?.activeTab || 'organization'} size={isMobile ? 'small' : 'middle'} />
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button type="primary" icon={<SaveOutlined />} size="large" onClick={handleSave}>
|
||||
Save Settings
|
||||
|
||||
@ -96,7 +96,7 @@ export default function EventDetailPage() {
|
||||
setJoiningMeeting(true);
|
||||
try {
|
||||
const { data } = await api.post(`/ticketed-events/admin/${id}/meeting-token`);
|
||||
window.open(data.jitsiUrl, '_blank');
|
||||
window.open(data.jitsiUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch {
|
||||
message.error('Failed to generate meeting token');
|
||||
} finally {
|
||||
@ -232,6 +232,7 @@ export default function EventDetailPage() {
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
columns={[
|
||||
{ title: 'Tier', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'Type', dataIndex: 'tierType', key: 'type', render: (v: string) => <Tag>{v}</Tag> },
|
||||
@ -257,7 +258,7 @@ export default function EventDetailPage() {
|
||||
prefix={<UserOutlined />}
|
||||
value={ticketSearch}
|
||||
onChange={e => setTicketSearch(e.target.value)}
|
||||
style={{ width: 300, marginBottom: 16 }}
|
||||
style={{ maxWidth: 300, width: '100%', marginBottom: 16 }}
|
||||
allowClear
|
||||
/>
|
||||
<Table
|
||||
@ -266,6 +267,7 @@ export default function EventDetailPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: ticketPagination.page,
|
||||
pageSize: ticketPagination.limit,
|
||||
@ -285,6 +287,7 @@ export default function EventDetailPage() {
|
||||
columns={checkInColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
),
|
||||
|
||||
@ -334,6 +334,7 @@ export default function TicketedEventsPage() {
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
|
||||
@ -134,6 +134,7 @@ export default function CampaignModerationPage() {
|
||||
{
|
||||
title: 'Creator',
|
||||
key: 'creator',
|
||||
responsive: ['md'] as any,
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<Text style={{ display: 'block' }}>{record.createdByUserName || 'Unknown'}</Text>
|
||||
@ -155,6 +156,7 @@ export default function CampaignModerationPage() {
|
||||
title: 'Levels',
|
||||
dataIndex: 'targetGovernmentLevels',
|
||||
key: 'levels',
|
||||
responsive: ['lg'] as any,
|
||||
render: (levels: GovernmentLevel[]) => (
|
||||
<Space size={4} wrap>
|
||||
{levels.map(l => <Tag key={l} color={GOVERNMENT_LEVEL_COLORS[l]} style={{ fontSize: 11 }}>{GOVERNMENT_LEVEL_LABELS[l]}</Tag>)}
|
||||
@ -165,6 +167,7 @@ export default function CampaignModerationPage() {
|
||||
title: 'Submitted',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
responsive: ['md'] as any,
|
||||
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
|
||||
},
|
||||
{
|
||||
@ -181,7 +184,7 @@ export default function CampaignModerationPage() {
|
||||
style={{ background: '#52c41a', borderColor: '#52c41a' }}
|
||||
onClick={() => handleModerate(record.id, 'approve')}
|
||||
>
|
||||
Approve
|
||||
{!isMobile && 'Approve'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@ -189,14 +192,14 @@ export default function CampaignModerationPage() {
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() => { setSelectedCampaign(record); openActionModal('reject'); }}
|
||||
>
|
||||
Reject
|
||||
{!isMobile && 'Reject'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
onClick={() => { setSelectedCampaign(record); openActionModal('request_changes'); }}
|
||||
>
|
||||
Request Changes
|
||||
{!isMobile && 'Request Changes'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@ -278,7 +281,7 @@ export default function CampaignModerationPage() {
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `${t} campaigns`,
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
|
||||
{/* Detail Drawer */}
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import axios from 'axios';
|
||||
|
||||
const { TextArea } = Input;
|
||||
@ -62,6 +63,7 @@ const statusColors: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function ImpactStoriesPage() {
|
||||
const { isMobile } = useMobile();
|
||||
const [stories, setStories] = useState<ImpactStory[]>([]);
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -197,6 +199,7 @@ export default function ImpactStoriesPage() {
|
||||
title: 'Campaign',
|
||||
dataIndex: ['campaign', 'title'],
|
||||
ellipsis: true,
|
||||
responsive: ['md'] as any,
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
@ -214,12 +217,14 @@ export default function ImpactStoriesPage() {
|
||||
title: 'Milestone',
|
||||
dataIndex: 'milestoneValue',
|
||||
width: 100,
|
||||
responsive: ['lg'] as any,
|
||||
render: (val: number | null) => val ? <Tag color="gold">{val}</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: 'Published',
|
||||
dataIndex: 'publishedAt',
|
||||
width: 140,
|
||||
responsive: ['md'] as any,
|
||||
render: (date: string | null) => date ? dayjs(date).format('MMM D, YYYY') : '-',
|
||||
},
|
||||
{
|
||||
@ -282,6 +287,7 @@ export default function ImpactStoriesPage() {
|
||||
/>
|
||||
|
||||
<Table
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={stories}
|
||||
@ -302,7 +308,7 @@ export default function ImpactStoriesPage() {
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSave}
|
||||
width={640}
|
||||
width={isMobile ? '100%' : 640}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
|
||||
@ -225,6 +225,7 @@ export default function StrawPollsPage() {
|
||||
<Table
|
||||
dataSource={polls}
|
||||
columns={columns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 20, onChange: setPage, showSizeChanger: false }}
|
||||
|
||||
@ -279,6 +279,7 @@ export default function AdAnalyticsDashboardPage() {
|
||||
<Table
|
||||
dataSource={data.topAds}
|
||||
columns={topAdsColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
@ -291,6 +292,7 @@ export default function AdAnalyticsDashboardPage() {
|
||||
<Table
|
||||
dataSource={data.byPlacement}
|
||||
columns={placementColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="placement"
|
||||
pagination={false}
|
||||
size="small"
|
||||
@ -307,6 +309,7 @@ export default function AdAnalyticsDashboardPage() {
|
||||
<Table
|
||||
dataSource={data.daily}
|
||||
columns={dailyColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="date"
|
||||
pagination={false}
|
||||
size="small"
|
||||
|
||||
@ -130,11 +130,11 @@ export default function AnalyticsDashboardPage() {
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={12}>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Skeleton.Button active block style={{ height: 100 }} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Skeleton.Button active block style={{ height: 100 }} />
|
||||
</Col>
|
||||
</Row>
|
||||
@ -185,8 +185,8 @@ export default function AnalyticsDashboardPage() {
|
||||
</Row>
|
||||
|
||||
{/* Additional Stats */}
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={12}>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Average Views per Video"
|
||||
@ -194,7 +194,7 @@ export default function AnalyticsDashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Average Watch Time per Video"
|
||||
@ -230,6 +230,7 @@ export default function AnalyticsDashboardPage() {
|
||||
<Table
|
||||
dataSource={topVideos.videos}
|
||||
columns={topVideosColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
|
||||
@ -399,6 +399,7 @@ export default function GalleryAdsPage() {
|
||||
<Table
|
||||
dataSource={ads}
|
||||
columns={columns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
@ -630,6 +631,7 @@ export default function GalleryAdsPage() {
|
||||
) : (
|
||||
<Table
|
||||
dataSource={analytics.daily}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="date"
|
||||
pagination={false}
|
||||
size="small"
|
||||
|
||||
@ -171,6 +171,7 @@ export default function MediaJobsPage() {
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={jobs}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
onRow={(record) => ({
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import { mediaApi } from '@/lib/media-api';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import CreatePlaylistModal from '@/components/media/CreatePlaylistModal';
|
||||
import EditPlaylistModal from '@/components/media/EditPlaylistModal';
|
||||
import axios from 'axios';
|
||||
@ -86,6 +87,7 @@ const sorterFieldMap: Record<string, string> = {
|
||||
|
||||
export default function PlaylistManagementPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { isMobile } = useMobile();
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Playlist Management' });
|
||||
@ -311,6 +313,7 @@ export default function PlaylistManagementPage() {
|
||||
{
|
||||
title: 'Creator',
|
||||
key: 'creator',
|
||||
responsive: ['md'] as any,
|
||||
render: (_: any, record: PlaylistRow) => (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.creator.name || record.creator.email}
|
||||
@ -330,6 +333,7 @@ export default function PlaylistManagementPage() {
|
||||
dataIndex: 'totalDurationSeconds',
|
||||
key: 'duration',
|
||||
width: 100,
|
||||
responsive: ['lg'] as any,
|
||||
sorter: true,
|
||||
render: (seconds: number) => formatDuration(seconds),
|
||||
},
|
||||
@ -346,6 +350,7 @@ export default function PlaylistManagementPage() {
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 110,
|
||||
responsive: ['md'] as any,
|
||||
sorter: true,
|
||||
render: (date: string) => dayjs(date).format('MMM D, YYYY'),
|
||||
},
|
||||
@ -354,6 +359,7 @@ export default function PlaylistManagementPage() {
|
||||
dataIndex: 'isPublic',
|
||||
key: 'isPublic',
|
||||
width: 90,
|
||||
responsive: ['md'] as any,
|
||||
render: (isPublic: boolean, record: PlaylistRow) => (
|
||||
<Tag
|
||||
color={isPublic ? 'green' : 'default'}
|
||||
@ -453,6 +459,7 @@ export default function PlaylistManagementPage() {
|
||||
{
|
||||
title: 'Featured By',
|
||||
key: 'featuredBy',
|
||||
responsive: ['md'] as any,
|
||||
render: (_: any, record: FeaturedRow) => (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.featuredBy?.name || record.featuredBy?.email || '\u2014'}
|
||||
@ -526,7 +533,7 @@ export default function PlaylistManagementPage() {
|
||||
setPagination((p) => ({ ...p, current: 1 }));
|
||||
}}
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
style={{ width: isMobile ? '100%' : 300 }}
|
||||
/>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
@ -539,7 +546,7 @@ export default function PlaylistManagementPage() {
|
||||
onClick={() => handleBulkAction('public')}
|
||||
loading={bulkLoading}
|
||||
>
|
||||
Make Public
|
||||
{!isMobile && 'Make Public'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@ -547,7 +554,7 @@ export default function PlaylistManagementPage() {
|
||||
onClick={() => handleBulkAction('private')}
|
||||
loading={bulkLoading}
|
||||
>
|
||||
Make Private
|
||||
{!isMobile && 'Make Private'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@ -555,7 +562,7 @@ export default function PlaylistManagementPage() {
|
||||
onClick={() => handleBulkAction('feature')}
|
||||
loading={bulkLoading}
|
||||
>
|
||||
Feature
|
||||
{!isMobile && 'Feature'}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`Delete ${selectedRowKeys.length} playlist(s)?`}
|
||||
@ -582,6 +589,7 @@ export default function PlaylistManagementPage() {
|
||||
<Table
|
||||
dataSource={playlists}
|
||||
columns={allColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loadingAll}
|
||||
rowSelection={{
|
||||
@ -610,6 +618,7 @@ export default function PlaylistManagementPage() {
|
||||
<Table
|
||||
dataSource={featured}
|
||||
columns={featuredColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
loading={loadingFeatured}
|
||||
pagination={false}
|
||||
|
||||
@ -274,6 +274,7 @@ export default function DonationPagesPage() {
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Card, Input, Typography, App, Tag, Button, Space, Modal, Select } from 'antd';
|
||||
import { Table, Card, Input, Typography, App, Tag, Button, Space, Modal, Select, Grid } from 'antd';
|
||||
import { SearchOutlined, DownloadOutlined, RollbackOutlined } from '@ant-design/icons';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
@ -10,6 +10,7 @@ const { TextArea } = Input;
|
||||
|
||||
export default function DonationsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const isMobile = !Grid.useBreakpoint().md;
|
||||
const [donations, setDonations] = useState<Order[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -164,7 +165,7 @@ export default function DonationsPage() {
|
||||
setRefundModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Refund
|
||||
{!isMobile && 'Refund'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
@ -207,6 +208,7 @@ export default function DonationsPage() {
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
|
||||
@ -85,6 +85,7 @@ export default function PaymentsDashboardPage() {
|
||||
columns={donationColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@ -274,6 +274,7 @@ export default function PlansPage() {
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
|
||||
@ -278,6 +278,7 @@ export default function ProductsPage() {
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
|
||||
@ -165,6 +165,7 @@ export default function SubscribersPage() {
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
|
||||
@ -59,7 +59,7 @@ export default function MeetingJoinPage() {
|
||||
const handleJoin = () => {
|
||||
if (!meeting) return;
|
||||
const url = `https://${meeting.domain}/${meeting.jitsiRoom}`;
|
||||
window.open(url, '_blank');
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
@ -5,6 +5,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { api } from '@/lib/api';
|
||||
import type { SmsCampaign, SmsContactList, SmsPaginatedResponse } from '@/types/sms';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
|
||||
const { TextArea } = Input;
|
||||
@ -21,6 +22,7 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
export default function SmsCampaignsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { message } = App.useApp();
|
||||
const { isMobile } = useMobile();
|
||||
|
||||
const [campaigns, setCampaigns] = useState<SmsCampaign[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@ -173,6 +175,7 @@ export default function SmsCampaignsPage() {
|
||||
title: 'Responses',
|
||||
dataIndex: 'totalResponded',
|
||||
width: 100,
|
||||
responsive: ['lg'] as any,
|
||||
render: (v, record) => {
|
||||
const rate = record.totalSent > 0 ? ((v / record.totalSent) * 100).toFixed(1) : '0';
|
||||
return <Text>{v} ({rate}%)</Text>;
|
||||
@ -181,12 +184,14 @@ export default function SmsCampaignsPage() {
|
||||
{
|
||||
title: 'Contact List',
|
||||
width: 150,
|
||||
responsive: ['md'] as any,
|
||||
render: (_, record) => record.contactList?.name || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
width: 100,
|
||||
responsive: ['md'] as any,
|
||||
render: (d) => new Date(d).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
@ -196,14 +201,14 @@ export default function SmsCampaignsPage() {
|
||||
<Space>
|
||||
{record.status === 'DRAFT' && (
|
||||
<Popconfirm title="Start this campaign?" onConfirm={() => handleStart(record.id)}>
|
||||
<Button size="small" type="primary" icon={<PlayCircleOutlined />}>Start</Button>
|
||||
<Button size="small" type="primary" icon={<PlayCircleOutlined />}>{!isMobile && 'Start'}</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{record.status === 'RUNNING' && (
|
||||
<Button size="small" icon={<PauseCircleOutlined />} onClick={() => handlePause(record.id)}>Pause</Button>
|
||||
<Button size="small" icon={<PauseCircleOutlined />} onClick={() => handlePause(record.id)}>{!isMobile && 'Pause'}</Button>
|
||||
)}
|
||||
{record.status === 'PAUSED' && (
|
||||
<Button size="small" icon={<CaretRightOutlined />} onClick={() => handleResume(record.id)}>Resume</Button>
|
||||
<Button size="small" icon={<CaretRightOutlined />} onClick={() => handleResume(record.id)}>{!isMobile && 'Resume'}</Button>
|
||||
)}
|
||||
{record.status === 'DRAFT' && (
|
||||
<Popconfirm title="Delete campaign?" onConfirm={() => handleDelete(record.id)}>
|
||||
@ -215,11 +220,11 @@ export default function SmsCampaignsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const drawerWidth = 480;
|
||||
const drawerWidth = isMobile ? '100%' : 480;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||
<div style={{ marginRight: drawerOpen && !isMobile ? 480 : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
@ -237,6 +242,7 @@ export default function SmsCampaignsPage() {
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
|
||||
size="middle"
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -539,6 +539,7 @@ export default function SmsContactsPage() {
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
),
|
||||
}]}
|
||||
@ -575,6 +576,7 @@ export default function SmsContactsPage() {
|
||||
columns={contactColumns}
|
||||
dataSource={entries}
|
||||
loading={entriesLoading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
|
||||
@ -5,6 +5,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { api } from '@/lib/api';
|
||||
import type { SmsMessageTemplate, SmsPaginatedResponse } from '@/types/sms';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
|
||||
@ -55,6 +56,7 @@ function renderPreview(template: string): string {
|
||||
export default function SmsTemplatesPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { message } = App.useApp();
|
||||
const { isMobile } = useMobile();
|
||||
|
||||
const [templates, setTemplates] = useState<SmsMessageTemplate[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@ -205,6 +207,7 @@ export default function SmsTemplatesPage() {
|
||||
{
|
||||
title: 'Variables',
|
||||
width: 200,
|
||||
responsive: ['lg'] as any,
|
||||
render: (_, record) => (
|
||||
<Space wrap size={2}>
|
||||
{(record.variables || []).map((v) => (
|
||||
@ -219,11 +222,13 @@ export default function SmsTemplatesPage() {
|
||||
dataIndex: 'usageCount',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
responsive: ['md'] as any,
|
||||
},
|
||||
{
|
||||
title: 'Updated',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 100,
|
||||
responsive: ['md'] as any,
|
||||
render: (d) => {
|
||||
const diff = Date.now() - new Date(d).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
@ -268,11 +273,11 @@ export default function SmsTemplatesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const drawerWidth = 480;
|
||||
const drawerWidth = isMobile ? '100%' : 480;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||
<div style={{ marginRight: drawerOpen && !isMobile ? 480 : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
New Template
|
||||
@ -309,6 +314,7 @@ export default function SmsTemplatesPage() {
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage, showSizeChanger: false }}
|
||||
size="middle"
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import { METRIC_MAP, STATUS_COLORS } from '@/components/social/ChallengeCard';
|
||||
import type { PaginationMeta } from '@/types/api';
|
||||
import axios from 'axios';
|
||||
@ -57,6 +58,7 @@ const STATUS_TABS = ['ALL', 'DRAFT', 'UPCOMING', 'ACTIVE', 'COMPLETED', 'CANCELL
|
||||
|
||||
export default function ChallengesAdminPage() {
|
||||
const { message } = App.useApp();
|
||||
const { isMobile } = useMobile();
|
||||
const [challenges, setChallenges] = useState<ChallengeRow[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -166,6 +168,7 @@ export default function ChallengesAdminPage() {
|
||||
dataIndex: 'metric',
|
||||
key: 'metric',
|
||||
width: 160,
|
||||
responsive: ['md'] as any,
|
||||
render: (m: string) => {
|
||||
const info = METRIC_MAP[m];
|
||||
return info ? <Tag icon={info.icon} color={info.color}>{info.label}</Tag> : m;
|
||||
@ -182,6 +185,7 @@ export default function ChallengesAdminPage() {
|
||||
title: 'Period',
|
||||
key: 'period',
|
||||
width: 180,
|
||||
responsive: ['md'] as any,
|
||||
render: (_: unknown, r: ChallengeRow) =>
|
||||
`${dayjs(r.startsAt).format('MMM D')} - ${dayjs(r.endsAt).format('MMM D')}`,
|
||||
},
|
||||
@ -189,6 +193,7 @@ export default function ChallengesAdminPage() {
|
||||
title: 'Teams',
|
||||
key: 'teams',
|
||||
width: 70,
|
||||
responsive: ['lg'] as any,
|
||||
render: (_: unknown, r: ChallengeRow) => r._count?.teams ?? 0,
|
||||
},
|
||||
{
|
||||
@ -199,25 +204,25 @@ export default function ChallengesAdminPage() {
|
||||
<Space size={4} wrap>
|
||||
{r.status === 'DRAFT' && (
|
||||
<Button size="small" icon={<PlayCircleOutlined />} onClick={() => handleAction(r.id, 'activate')}>
|
||||
Activate
|
||||
{!isMobile && 'Activate'}
|
||||
</Button>
|
||||
)}
|
||||
{r.status === 'ACTIVE' && (
|
||||
<>
|
||||
<Button size="small" icon={<CheckCircleOutlined />} onClick={() => handleAction(r.id, 'complete')}>
|
||||
Complete
|
||||
{!isMobile && 'Complete'}
|
||||
</Button>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => handleAction(r.id, 'rescore')}>
|
||||
Rescore
|
||||
{!isMobile && 'Rescore'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(r.status === 'DRAFT' || r.status === 'UPCOMING') && (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>Edit</Button>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>{!isMobile && 'Edit'}</Button>
|
||||
)}
|
||||
{r.status !== 'COMPLETED' && r.status !== 'CANCELLED' && (
|
||||
<Popconfirm title="Cancel this challenge?" onConfirm={() => handleAction(r.id, 'cancel')}>
|
||||
<Button size="small" danger icon={<CloseCircleOutlined />}>Cancel</Button>
|
||||
<Button size="small" danger icon={<CloseCircleOutlined />}>{!isMobile && 'Cancel'}</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{(r.status === 'DRAFT' || r.status === 'CANCELLED') && (
|
||||
@ -232,11 +237,12 @@ export default function ChallengesAdminPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: isMobile ? 'stretch' : 'center', marginBottom: 16, flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 8 : 0 }}>
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => { setStatusFilter(key); setPage(1); }}
|
||||
items={STATUS_TABS.map((s) => ({ key: s, label: s === 'ALL' ? 'All' : s }))}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
@ -247,6 +253,7 @@ export default function ChallengesAdminPage() {
|
||||
<Table
|
||||
dataSource={challenges}
|
||||
columns={columns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
|
||||
@ -112,6 +112,7 @@ export default function ReferralAdminPage() {
|
||||
<Table
|
||||
dataSource={referrals}
|
||||
columns={referralColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
@ -134,6 +135,7 @@ export default function ReferralAdminPage() {
|
||||
<Table
|
||||
dataSource={leaderboard}
|
||||
columns={leaderboardColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
|
||||
@ -185,6 +185,7 @@ export default function SocialDashboardPage() {
|
||||
<Card title={<><HeartOutlined /> Top Connected Users</>} size="small">
|
||||
<Table
|
||||
dataSource={topConnected}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
GiftOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMobile } from '@/hooks/useMobile';
|
||||
import type { AppOutletContext } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
@ -89,6 +90,7 @@ const PRIVACY_LABELS: Record<string, string> = {
|
||||
|
||||
export default function SocialModerationPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { isMobile } = useMobile();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<ModerationData | null>(null);
|
||||
const [blockPage, setBlockPage] = useState(1);
|
||||
@ -188,6 +190,7 @@ export default function SocialModerationPage() {
|
||||
children: (
|
||||
<Table
|
||||
dataSource={data.blocks.items}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{
|
||||
@ -221,6 +224,7 @@ export default function SocialModerationPage() {
|
||||
title: 'Date',
|
||||
dataIndex: 'createdAt',
|
||||
width: 120,
|
||||
responsive: ['md'] as any,
|
||||
render: (v: string) => new Date(v).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
@ -235,7 +239,7 @@ export default function SocialModerationPage() {
|
||||
okType="danger"
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
Remove
|
||||
{!isMobile && 'Remove'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
@ -260,6 +264,7 @@ export default function SocialModerationPage() {
|
||||
</div>
|
||||
<Table
|
||||
dataSource={data.achievementStats}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="achievementId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
@ -287,6 +292,7 @@ export default function SocialModerationPage() {
|
||||
title: 'Unlock Rate',
|
||||
dataIndex: 'unlockRate',
|
||||
width: 200,
|
||||
responsive: ['md'] as any,
|
||||
render: (v: number) => <Progress percent={v} size="small" />,
|
||||
sorter: (a, b) => a.unlockRate - b.unlockRate,
|
||||
},
|
||||
@ -333,6 +339,7 @@ export default function SocialModerationPage() {
|
||||
</Row>
|
||||
<Table
|
||||
dataSource={data.notificationStats}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="type"
|
||||
size="small"
|
||||
pagination={false}
|
||||
@ -362,6 +369,7 @@ export default function SocialModerationPage() {
|
||||
children: (
|
||||
<Table
|
||||
dataSource={data.privacyAdoption}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="setting"
|
||||
size="small"
|
||||
pagination={false}
|
||||
|
||||
@ -177,6 +177,7 @@ export default function ReferralsPage() {
|
||||
<Table
|
||||
dataSource={referrals}
|
||||
columns={referralColumns}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{
|
||||
|
||||
@ -133,14 +133,18 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
},
|
||||
|
||||
hydrate: async () => {
|
||||
const { accessToken } = get();
|
||||
if (!accessToken) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
// Always attempt to restore session via httpOnly refresh cookie.
|
||||
// Access token is no longer persisted to localStorage (XSS protection).
|
||||
try {
|
||||
await get().fetchMe();
|
||||
const { data } = await api.post<AuthResponse>('/auth/refresh');
|
||||
set({
|
||||
accessToken: data.accessToken || null,
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch {
|
||||
// No valid refresh cookie — user is not logged in
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
@ -167,9 +171,9 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
}),
|
||||
{
|
||||
name: 'cml-auth',
|
||||
partialize: (state) => ({
|
||||
accessToken: state.accessToken,
|
||||
}),
|
||||
// Do not persist accessToken to localStorage — XSS could steal it.
|
||||
// Session is restored via httpOnly refresh cookie in hydrate().
|
||||
partialize: () => ({}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@ -38,9 +38,9 @@ const envSchema = z.object({
|
||||
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
|
||||
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
|
||||
|
||||
// Gitea SSO cookie signing secret (falls back to JWT_ACCESS_SECRET if empty)
|
||||
// Gitea SSO cookie signing secret — MUST be unique (key separation from JWT)
|
||||
GITEA_SSO_SECRET: z.string().default(''),
|
||||
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat — falls back to JWT_ACCESS_SECRET if empty)
|
||||
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat) — MUST be unique
|
||||
SERVICE_PASSWORD_SALT: z.string().default(''),
|
||||
|
||||
// Initial Super Admin (auto-created during database seeding)
|
||||
@ -259,7 +259,17 @@ function validateEnv(): Env {
|
||||
console.error(result.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
return result.data;
|
||||
|
||||
// Warn about security-critical key separation issues
|
||||
const data = result.data;
|
||||
if (!data.GITEA_SSO_SECRET) {
|
||||
console.warn('⚠ SECURITY WARNING: GITEA_SSO_SECRET is empty — falling back to JWT_ACCESS_SECRET. This violates key separation. Generate a unique secret with: openssl rand -hex 32');
|
||||
}
|
||||
if (!data.SERVICE_PASSWORD_SALT) {
|
||||
console.warn('⚠ SECURITY WARNING: SERVICE_PASSWORD_SALT is empty — falling back to JWT_ACCESS_SECRET. Rotating JWT_ACCESS_SECRET will invalidate all provisioned service passwords. Generate a unique salt with: openssl rand -hex 32');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
|
||||
@ -209,7 +209,7 @@ export const paymentCheckoutRateLimit = rateLimit({
|
||||
|
||||
export const authRateLimit = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10, // Reduced from 20 to prevent brute force attacks
|
||||
max: 15,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
@ -224,6 +224,25 @@ export const authRateLimit = rateLimit({
|
||||
},
|
||||
});
|
||||
|
||||
// Separate stricter rate limit for login to prevent credential stuffing
|
||||
// Isolated from the general auth budget so register/refresh/logout don't consume login slots
|
||||
export const loginRateLimit = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:login:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many login attempts, please try again later',
|
||||
code: 'LOGIN_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const observabilityRateLimit = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 20, // 20 requests per minute (stricter than global 500/min)
|
||||
|
||||
@ -7,7 +7,7 @@ import { authService } from './auth.service';
|
||||
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { authRateLimit } from '../../middleware/rate-limit';
|
||||
import { authRateLimit, loginRateLimit } from '../../middleware/rate-limit';
|
||||
import { prisma } from '../../config/database';
|
||||
import { verificationTokenService } from '../../services/verification-token.service';
|
||||
import { passwordResetTokenService } from '../../services/password-reset-token.service';
|
||||
@ -99,7 +99,7 @@ function clearSessionCookie(req: Request, res: Response) {
|
||||
// POST /api/auth/login
|
||||
router.post(
|
||||
'/login',
|
||||
authRateLimit,
|
||||
loginRateLimit,
|
||||
validate(loginSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@ -12,7 +12,7 @@ export const docsAnalyticsPublicRouter = Router();
|
||||
|
||||
// Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain)
|
||||
import { env } from '../../config/env';
|
||||
const DOCS_ORIGIN = env.ADMIN_URL || `https://docs.${env.DOMAIN}`;
|
||||
const DOCS_ORIGIN = env.DOMAIN ? `https://docs.${env.DOMAIN}` : (env.ADMIN_URL || 'http://localhost:4003');
|
||||
docsAnalyticsPublicRouter.use((_req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', DOCS_ORIGIN);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
|
||||
@ -397,6 +397,15 @@ router.post(
|
||||
res.status(400).json({ error: { message: 'File path required', code: 'VALIDATION_ERROR' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-file access check (matches PUT/DELETE guards)
|
||||
const userRoles = getUserRoles(req.user!);
|
||||
const canEdit = await docsAccessService.canUserEdit(req.user!.id, userRoles, filePath);
|
||||
if (!canEdit) {
|
||||
res.status(403).json({ error: { message: 'You do not have edit access to this directory', code: 'DOC_ACCESS_DENIED' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const { content, isDirectory } = req.body as { content?: string; isDirectory?: boolean };
|
||||
await docsFilesService.createFile(filePath, content, isDirectory);
|
||||
res.status(201).json({ success: true, path: filePath });
|
||||
|
||||
@ -3,6 +3,7 @@ import { prisma } from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { encrypt } from '../../utils/crypto';
|
||||
import { giteaClient } from '../../services/gitea.client';
|
||||
import { updateEnvFile } from '../../services/env-writer.service';
|
||||
|
||||
const SETUP_TIMEOUT = 15000;
|
||||
|
||||
@ -429,6 +430,17 @@ async function autoSetupIfNeeded(): Promise<{ alreadyComplete: boolean; success:
|
||||
for (const step of result.steps) {
|
||||
logger.info(` ${step.step}: ${step.success ? 'OK' : 'FAILED'}${step.data?.note ? ` (${step.data.note})` : ''}`);
|
||||
}
|
||||
|
||||
// Clear GITEA_ADMIN_PASSWORD from .env — it's no longer needed after setup
|
||||
// (the API token is now encrypted in the database)
|
||||
try {
|
||||
const envResult = updateEnvFile({ GITEA_ADMIN_PASSWORD: '' });
|
||||
if (envResult.success) {
|
||||
logger.info('Gitea auto-setup: cleared GITEA_ADMIN_PASSWORD from .env');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Gitea auto-setup: could not clear GITEA_ADMIN_PASSWORD from .env: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Gitea auto-setup: failed — ${result.error}`);
|
||||
for (const step of result.steps) {
|
||||
|
||||
@ -286,7 +286,12 @@ export const effectivenessService = {
|
||||
}
|
||||
|
||||
// For city/province grouping, we need to join with postal_code_cache
|
||||
const groupCol = query.groupBy === 'province' ? Prisma.raw('pcc.province') : Prisma.raw('pcc.city');
|
||||
// Use pre-validated lookup map to prevent SQL injection if enum expands
|
||||
const GROUP_COL_MAP: Record<string, ReturnType<typeof Prisma.sql>> = {
|
||||
province: Prisma.sql`pcc.province`,
|
||||
city: Prisma.sql`pcc.city`,
|
||||
};
|
||||
const groupCol = GROUP_COL_MAP[query.groupBy!] ?? GROUP_COL_MAP.city;
|
||||
const campaignFilter = query.campaignId
|
||||
? Prisma.sql`AND ce."campaignId" = ${query.campaignId}`
|
||||
: Prisma.sql``;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import { prisma } from '../../config/database';
|
||||
import { env } from '../../config/env';
|
||||
import { logger } from '../../utils/logger';
|
||||
@ -18,7 +19,14 @@ router.post(
|
||||
try {
|
||||
// Accept secret from header only (query param removed — secrets must not appear in logs)
|
||||
const secret = req.headers['x-webhook-secret'] as string;
|
||||
if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) {
|
||||
if (!env.LISTMONK_WEBHOOK_SECRET || !secret) {
|
||||
res.status(403).json({ error: 'Invalid webhook secret' });
|
||||
return;
|
||||
}
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
const incoming = Buffer.from(secret);
|
||||
const expected = Buffer.from(env.LISTMONK_WEBHOOK_SECRET);
|
||||
if (incoming.length !== expected.length || !timingSafeEqual(incoming, expected)) {
|
||||
res.status(403).json({ error: 'Invalid webhook secret' });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -18,13 +18,14 @@ import { locationsService } from '../locations/locations.service';
|
||||
import { geocodingService } from '../geocoding/geocoding.service';
|
||||
import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { requireRole, requireNonTemp } from '../../../middleware/rbac.middleware';
|
||||
import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit';
|
||||
import { MAP_ROLES } from '../../../utils/roles';
|
||||
|
||||
// ─── Volunteer Router ────────────────────────────────────────────────
|
||||
const volunteerRouter = Router();
|
||||
volunteerRouter.use(authenticate);
|
||||
volunteerRouter.use(requireNonTemp);
|
||||
|
||||
// GET /api/map/canvass/my/assignments
|
||||
volunteerRouter.get(
|
||||
|
||||
@ -73,7 +73,7 @@ volunteerRouter.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const session = await trackingService.linkCanvassSession(id, req.body);
|
||||
const session = await trackingService.linkCanvassSession(id, req.user!.id, req.body);
|
||||
res.json(session);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@ -113,8 +113,14 @@ class TrackingService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Link a canvass session to an existing tracking session. */
|
||||
async linkCanvassSession(trackingSessionId: string, data: LinkCanvassInput) {
|
||||
/** Link a canvass session to an existing tracking session (ownership-verified). */
|
||||
async linkCanvassSession(trackingSessionId: string, userId: string, data: LinkCanvassInput) {
|
||||
const session = await prisma.trackingSession.findFirst({
|
||||
where: { id: trackingSessionId, userId, isActive: true },
|
||||
});
|
||||
if (!session) {
|
||||
throw Object.assign(new Error('Active tracking session not found'), { statusCode: 404 });
|
||||
}
|
||||
return prisma.trackingSession.update({
|
||||
where: { id: trackingSessionId },
|
||||
data: { canvassSessionId: data.canvassSessionId },
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../../config/database';
|
||||
import { redis } from '../../config/redis';
|
||||
import { env } from '../../config/env';
|
||||
import { siteSettingsService } from '../settings/settings.service';
|
||||
import { escapeHtml } from '../../utils/escapeHtml';
|
||||
|
||||
@ -65,8 +66,8 @@ router.get('/campaign/:slug', async (req: Request, res: Response, next: NextFunc
|
||||
if (!campaign) { res.status(404).send('Not found'); return; }
|
||||
|
||||
const settings = await siteSettingsService.getPublic();
|
||||
const appDomain = req.get('host') || 'app.cmlite.org';
|
||||
const protocol = req.protocol;
|
||||
const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org';
|
||||
const protocol = env.DOMAIN ? 'https' : req.protocol;
|
||||
const url = `${protocol}://${appDomain}/campaign/${slug}`;
|
||||
|
||||
const html = ogHtml({
|
||||
@ -104,8 +105,8 @@ router.get('/page/:slug', async (req: Request, res: Response, next: NextFunction
|
||||
if (!page) { res.status(404).send('Not found'); return; }
|
||||
|
||||
const settings = await siteSettingsService.getPublic();
|
||||
const appDomain = req.get('host') || 'app.cmlite.org';
|
||||
const protocol = req.protocol;
|
||||
const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org';
|
||||
const protocol = env.DOMAIN ? 'https' : req.protocol;
|
||||
const url = `${protocol}://${appDomain}/p/${slug}`;
|
||||
|
||||
const html = ogHtml({
|
||||
@ -146,8 +147,8 @@ router.get('/gallery/:id', async (req: Request, res: Response, next: NextFunctio
|
||||
if (!video) { res.status(404).send('Not found'); return; }
|
||||
|
||||
const settings = await siteSettingsService.getPublic();
|
||||
const appDomain = req.get('host') || 'app.cmlite.org';
|
||||
const protocol = req.protocol;
|
||||
const appDomain = env.DOMAIN ? `app.${env.DOMAIN}` : 'app.cmlite.org';
|
||||
const protocol = env.DOMAIN ? 'https' : req.protocol;
|
||||
const url = `${protocol}://${appDomain}/gallery/watch/${id}`;
|
||||
|
||||
const html = ogHtml({
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RedisStore from 'rate-limit-redis';
|
||||
import { redis } from '../../config/redis';
|
||||
import { env } from '../../config/env';
|
||||
import { search } from './search.service';
|
||||
|
||||
const router = Router();
|
||||
@ -27,7 +29,16 @@ router.get('/', searchRateLimit, async (req: Request, res: Response, next: NextF
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
const results = await search(q, limit);
|
||||
// Lightweight auth check — shifts only returned for authenticated users
|
||||
let isAuthenticated = false;
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
try {
|
||||
jwt.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] });
|
||||
isAuthenticated = true;
|
||||
} catch { /* unauthenticated — public results only */ }
|
||||
}
|
||||
const results = await search(q, limit, isAuthenticated);
|
||||
res.json(results);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@ -14,7 +14,7 @@ export interface SearchResult {
|
||||
const CACHE_PREFIX = 'search:';
|
||||
const CACHE_TTL = 60; // 60 seconds
|
||||
|
||||
export async function search(query: string, limit = 5): Promise<SearchResult[]> {
|
||||
export async function search(query: string, limit = 5, isAuthenticated = false): Promise<SearchResult[]> {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q || q.length < 2) return [];
|
||||
|
||||
@ -34,7 +34,7 @@ export async function search(query: string, limit = 5): Promise<SearchResult[]>
|
||||
if (settings.enableInfluence !== false) {
|
||||
promises.push(searchCampaigns(q, limit));
|
||||
}
|
||||
if (settings.enableMap !== false) {
|
||||
if (settings.enableMap !== false && isAuthenticated) {
|
||||
promises.push(searchShifts(q, limit));
|
||||
}
|
||||
if (settings.enableLandingPages !== false) {
|
||||
|
||||
@ -260,7 +260,9 @@ router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: R
|
||||
const crypto = await import('crypto');
|
||||
const { env: envConfig } = await import('../../config/env');
|
||||
const nonce = crypto.randomBytes(16);
|
||||
const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY);
|
||||
// Use domain-separated key for ticket HMACs (matches tickets.service.ts)
|
||||
const ticketKey = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY).update('ticket-hmac-v1').digest();
|
||||
const hmac = crypto.createHmac('sha256', ticketKey);
|
||||
hmac.update(ticket.id);
|
||||
hmac.update(nonce);
|
||||
const token = Buffer.concat([
|
||||
|
||||
@ -4,8 +4,9 @@ import { env } from '../../config/env';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
function getEncryptionKey(): string {
|
||||
return env.ENCRYPTION_KEY;
|
||||
/** Derive a domain-separated key for ticket HMACs (not the raw ENCRYPTION_KEY). */
|
||||
function getTicketHmacKey(): Buffer {
|
||||
return crypto.createHmac('sha256', env.ENCRYPTION_KEY).update('ticket-hmac-v1').digest();
|
||||
}
|
||||
|
||||
/** Generate a human-readable ticket code like "ABCD-1234" */
|
||||
@ -19,7 +20,7 @@ function generateTicketCode(): string {
|
||||
/** Generate HMAC token for QR code validation */
|
||||
function generateToken(ticketId: string): { token: string; tokenHash: string } {
|
||||
const nonce = crypto.randomBytes(16);
|
||||
const hmac = crypto.createHmac('sha256', getEncryptionKey());
|
||||
const hmac = crypto.createHmac('sha256', getTicketHmacKey());
|
||||
hmac.update(ticketId);
|
||||
hmac.update(nonce);
|
||||
const token = Buffer.concat([
|
||||
|
||||
@ -2,24 +2,31 @@ import crypto from 'crypto';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/** Hash a token with SHA-256 for storage (raw token is sent to user, hash is stored in DB). */
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export const passwordResetTokenService = {
|
||||
async createToken(userId: string): Promise<string> {
|
||||
// Delete any existing tokens for this user
|
||||
await prisma.passwordResetToken.deleteMany({ where: { userId } });
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: { userId, token, expiresAt },
|
||||
data: { userId, token: tokenHash, expiresAt },
|
||||
});
|
||||
|
||||
logger.info(`Password reset token created for user ${userId}`);
|
||||
return token;
|
||||
return rawToken; // Send raw token in email; DB stores only the hash
|
||||
},
|
||||
|
||||
async validateToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const record = await prisma.passwordResetToken.findUnique({ where: { token } });
|
||||
const tokenHash = hashToken(token);
|
||||
const record = await prisma.passwordResetToken.findUnique({ where: { token: tokenHash } });
|
||||
|
||||
// Use a generic error message for all failure cases to prevent token state enumeration
|
||||
const genericError = 'Invalid or expired reset token';
|
||||
@ -41,8 +48,9 @@ export const passwordResetTokenService = {
|
||||
},
|
||||
|
||||
async markTokenUsed(token: string): Promise<void> {
|
||||
const tokenHash = hashToken(token);
|
||||
await prisma.passwordResetToken.update({
|
||||
where: { token },
|
||||
where: { token: tokenHash },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
},
|
||||
|
||||
@ -2,24 +2,31 @@ import crypto from 'crypto';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/** Hash a token with SHA-256 for storage (raw token is sent to user, hash is stored in DB). */
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export const verificationTokenService = {
|
||||
async createToken(userId: string): Promise<string> {
|
||||
// Delete any existing tokens for this user
|
||||
await prisma.emailVerificationToken.deleteMany({ where: { userId } });
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
await prisma.emailVerificationToken.create({
|
||||
data: { userId, token, expiresAt },
|
||||
data: { userId, token: tokenHash, expiresAt },
|
||||
});
|
||||
|
||||
logger.info(`Verification token created for user ${userId}`);
|
||||
return token;
|
||||
return rawToken; // Send raw token in email; DB stores only the hash
|
||||
},
|
||||
|
||||
async verifyToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
|
||||
const record = await prisma.emailVerificationToken.findUnique({ where: { token } });
|
||||
const tokenHash = hashToken(token);
|
||||
const record = await prisma.emailVerificationToken.findUnique({ where: { token: tokenHash } });
|
||||
|
||||
if (!record) {
|
||||
return { valid: false, error: 'Invalid or expired verification token' };
|
||||
|
||||
@ -34,9 +34,57 @@ interface FetchJobResult {
|
||||
// Shell metacharacters that could enable command injection
|
||||
const SHELL_METACHAR_REGEX = /[`$;|&<>(){}[\]\\!#]/;
|
||||
|
||||
// Hostnames that resolve to internal/cloud-metadata addresses
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'localhost',
|
||||
'metadata.google.internal',
|
||||
'metadata.gcp.internal',
|
||||
'169.254.169.254', // AWS/GCP/Azure metadata
|
||||
'169.254.170.2', // AWS ECS task metadata
|
||||
'kubernetes.default',
|
||||
'kubernetes.default.svc',
|
||||
]);
|
||||
|
||||
// Docker internal container names used in this project
|
||||
const DOCKER_INTERNAL_SUFFIXES = [
|
||||
'-changemaker', // Our container naming convention
|
||||
'-rocketchat',
|
||||
'-listmonk',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if an IP address is in a private/reserved range (SSRF protection).
|
||||
*/
|
||||
function isPrivateIP(hostname: string): boolean {
|
||||
// IPv6 loopback
|
||||
if (hostname === '::1' || hostname === '[::1]') return true;
|
||||
|
||||
// Strip IPv6 brackets
|
||||
const clean = hostname.replace(/^\[|\]$/g, '');
|
||||
|
||||
// IPv4 patterns
|
||||
const parts = clean.split('.').map(Number);
|
||||
if (parts.length === 4 && parts.every(p => !isNaN(p) && p >= 0 && p <= 255)) {
|
||||
const [a, b] = parts;
|
||||
if (a === 127) return true; // 127.0.0.0/8 loopback
|
||||
if (a === 10) return true; // 10.0.0.0/8 private
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
|
||||
if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local / cloud metadata
|
||||
if (a === 0) return true; // 0.0.0.0/8
|
||||
}
|
||||
|
||||
// IPv6 private ranges (simplified check for common prefixes)
|
||||
if (clean.startsWith('fc') || clean.startsWith('fd')) return true; // ULA
|
||||
if (clean.startsWith('fe80')) return true; // Link-local
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and validate a URL for safe shell usage.
|
||||
* Returns the URL if valid, or null if invalid or contains shell metacharacters.
|
||||
* Returns the URL if valid, or null if invalid, contains shell metacharacters,
|
||||
* or targets private/internal network addresses (SSRF protection).
|
||||
*/
|
||||
function sanitizeUrl(url: string): string | null {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
@ -45,8 +93,9 @@ function sanitizeUrl(url: string): string | null {
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Validate URL structure
|
||||
let parsed: URL;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed = new URL(trimmed);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
@ -55,6 +104,16 @@ function sanitizeUrl(url: string): string | null {
|
||||
// Reject shell metacharacters
|
||||
if (SHELL_METACHAR_REGEX.test(trimmed)) return null;
|
||||
|
||||
// SSRF protection: block private/internal IPs and hostnames
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
if (isPrivateIP(hostname)) return null;
|
||||
if (BLOCKED_HOSTNAMES.has(hostname)) return null;
|
||||
if (DOCKER_INTERNAL_SUFFIXES.some(suffix => hostname.endsWith(suffix))) return null;
|
||||
|
||||
// Block numeric IPv6 addresses entirely (too many bypass variants)
|
||||
if (hostname.includes(':')) return null;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
|
||||
@ -1,20 +1,36 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash, hkdfSync } from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const PREFIX = 'enc:';
|
||||
|
||||
/** Derive a 32-byte key from an arbitrary-length secret */
|
||||
function deriveKey(secret: string): Buffer {
|
||||
/** Legacy prefix (SHA-256 derived key) — read-only, never written for new data */
|
||||
const LEGACY_PREFIX = 'enc:';
|
||||
/** V2 prefix (HKDF derived key) — used for all new encryptions */
|
||||
const V2_PREFIX = 'enc2:';
|
||||
|
||||
const HKDF_SALT = 'changemaker-lite-encryption-v2';
|
||||
const HKDF_INFO = 'aes-256-gcm-data-encryption';
|
||||
|
||||
/** Derive a 32-byte key using SHA-256 (legacy, for decrypting old data) */
|
||||
function deriveLegacyKey(secret: string): Buffer {
|
||||
return createHash('sha256').update(secret).digest();
|
||||
}
|
||||
|
||||
/** Derive a 32-byte key using HKDF-SHA256 (RFC 5869) */
|
||||
function deriveKey(secret: string): Buffer {
|
||||
return Buffer.from(
|
||||
hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32)
|
||||
);
|
||||
}
|
||||
|
||||
let _key: Buffer | null = null;
|
||||
let _legacyKey: Buffer | null = null;
|
||||
|
||||
/** Initialize (or re-initialize) the encryption key. Call once at startup. */
|
||||
export function initEncryption(secret: string): void {
|
||||
_key = deriveKey(secret);
|
||||
_legacyKey = deriveLegacyKey(secret);
|
||||
}
|
||||
|
||||
function getKey(): Buffer {
|
||||
@ -24,9 +40,16 @@ function getKey(): Buffer {
|
||||
return _key;
|
||||
}
|
||||
|
||||
function getLegacyKey(): Buffer {
|
||||
if (!_legacyKey) {
|
||||
throw new Error('Encryption not initialized — call initEncryption() first');
|
||||
}
|
||||
return _legacyKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string.
|
||||
* Returns format: `enc:<iv>:<authTag>:<ciphertext>` (all base64).
|
||||
* Returns format: `enc2:<iv>:<authTag>:<ciphertext>` (all base64).
|
||||
*/
|
||||
export function encrypt(plaintext: string): string {
|
||||
if (!plaintext) return plaintext;
|
||||
@ -37,18 +60,31 @@ export function encrypt(plaintext: string): string {
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `${PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
return `${V2_PREFIX}${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value produced by encrypt().
|
||||
* If the value doesn't have the `enc:` prefix, returns it as-is (backward compat for plaintext).
|
||||
* Supports both v2 (HKDF) and legacy (SHA-256) key derivation.
|
||||
* If the value doesn't have a recognized prefix, returns it as-is (backward compat for plaintext).
|
||||
*/
|
||||
export function decrypt(value: string): string {
|
||||
if (!value || !value.startsWith(PREFIX)) return value;
|
||||
if (!value) return value;
|
||||
|
||||
const key = getKey();
|
||||
const parts = value.slice(PREFIX.length).split(':');
|
||||
let key: Buffer;
|
||||
let payload: string;
|
||||
|
||||
if (value.startsWith(V2_PREFIX)) {
|
||||
key = getKey();
|
||||
payload = value.slice(V2_PREFIX.length);
|
||||
} else if (value.startsWith(LEGACY_PREFIX)) {
|
||||
key = getLegacyKey();
|
||||
payload = value.slice(LEGACY_PREFIX.length);
|
||||
} else {
|
||||
return value; // Plaintext fallback
|
||||
}
|
||||
|
||||
const parts = payload.split(':');
|
||||
if (parts.length !== 3) return value;
|
||||
|
||||
const iv = Buffer.from(parts[0], 'base64');
|
||||
@ -64,5 +100,5 @@ export function decrypt(value: string): string {
|
||||
|
||||
/** Check whether a value is already encrypted */
|
||||
export function isEncrypted(value: string): boolean {
|
||||
return !!value && value.startsWith(PREFIX);
|
||||
return !!value && (value.startsWith(V2_PREFIX) || value.startsWith(LEGACY_PREFIX));
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ services:
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-noreply@cmlite.org}
|
||||
- EMAIL_TEST_MODE=${EMAIL_TEST_MODE:-true}
|
||||
- EMAIL_TEST_MODE=${EMAIL_TEST_MODE:-false}
|
||||
- LISTMONK_URL=http://listmonk-app:9000
|
||||
- LISTMONK_ADMIN_USER=${LISTMONK_ADMIN_USER:-admin}
|
||||
- LISTMONK_ADMIN_PASSWORD=${LISTMONK_ADMIN_PASSWORD:-}
|
||||
@ -405,7 +405,7 @@ services:
|
||||
environment:
|
||||
LISTMONK_app__address: 0.0.0.0:9000
|
||||
LISTMONK_db__user: ${LISTMONK_DB_USER:-listmonk}
|
||||
LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:-listmonk}
|
||||
LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env}
|
||||
LISTMONK_db__database: ${LISTMONK_DB_NAME:-listmonk}
|
||||
LISTMONK_db__host: listmonk-db
|
||||
LISTMONK_db__port: 5432
|
||||
@ -427,7 +427,7 @@ services:
|
||||
- "127.0.0.1:${LISTMONK_DB_PORT:-5434}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: ${LISTMONK_DB_USER:-listmonk}
|
||||
POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:-listmonk}
|
||||
POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env}
|
||||
POSTGRES_DB: ${LISTMONK_DB_NAME:-listmonk}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${LISTMONK_DB_USER:-listmonk}"]
|
||||
@ -450,7 +450,7 @@ services:
|
||||
condition: service_started
|
||||
restart: "no"
|
||||
environment:
|
||||
PGPASSWORD: ${LISTMONK_DB_PASSWORD:-listmonk}
|
||||
PGPASSWORD: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env}
|
||||
LISTMONK_API_USER: ${LISTMONK_API_USER:-v2-api}
|
||||
LISTMONK_API_TOKEN: ${LISTMONK_API_TOKEN:-}
|
||||
LISTMONK_SMTP_HOST: ${LISTMONK_SMTP_HOST:-mailhog-changemaker}
|
||||
@ -1222,7 +1222,13 @@ services:
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
privileged: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- SYS_PTRACE
|
||||
- DAC_READ_SEARCH
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
read_only: true
|
||||
devices:
|
||||
- /dev/kmsg
|
||||
|
||||
@ -429,7 +429,7 @@ services:
|
||||
environment:
|
||||
LISTMONK_app__address: 0.0.0.0:9000
|
||||
LISTMONK_db__user: ${LISTMONK_DB_USER:-listmonk}
|
||||
LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:-listmonk}
|
||||
LISTMONK_db__password: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env}
|
||||
LISTMONK_db__database: ${LISTMONK_DB_NAME:-listmonk}
|
||||
LISTMONK_db__host: listmonk-db
|
||||
LISTMONK_db__port: 5432
|
||||
@ -451,7 +451,7 @@ services:
|
||||
- "127.0.0.1:${LISTMONK_DB_PORT:-5434}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: ${LISTMONK_DB_USER:-listmonk}
|
||||
POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:-listmonk}
|
||||
POSTGRES_PASSWORD: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env}
|
||||
POSTGRES_DB: ${LISTMONK_DB_NAME:-listmonk}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${LISTMONK_DB_USER:-listmonk}"]
|
||||
@ -474,7 +474,7 @@ services:
|
||||
condition: service_started
|
||||
restart: "no"
|
||||
environment:
|
||||
PGPASSWORD: ${LISTMONK_DB_PASSWORD:-listmonk}
|
||||
PGPASSWORD: ${LISTMONK_DB_PASSWORD:?LISTMONK_DB_PASSWORD must be set in .env}
|
||||
LISTMONK_API_USER: ${LISTMONK_API_USER:-v2-api}
|
||||
LISTMONK_API_TOKEN: ${LISTMONK_API_TOKEN:-}
|
||||
LISTMONK_SMTP_HOST: ${LISTMONK_SMTP_HOST:-mailhog-changemaker}
|
||||
@ -874,7 +874,7 @@ services:
|
||||
environment:
|
||||
- ROOT_URL=http://chat.${DOMAIN:-cmlite.org}
|
||||
- MONGO_URL=mongodb://${MONGO_ROOT_USER:-rocketchat}:${MONGO_ROOT_PASSWORD}@mongodb-rocketchat:27017/rocketchat?replicaSet=rs0&authSource=admin
|
||||
- MONGO_OPLOG_URL=mongodb://mongodb-rocketchat:27017/local?replicaSet=rs0
|
||||
- MONGO_OPLOG_URL=mongodb://${MONGO_ROOT_USER:-rocketchat}:${MONGO_ROOT_PASSWORD}@mongodb-rocketchat:27017/local?replicaSet=rs0&authSource=admin
|
||||
- TRANSPORTER=monolith+nats://nats-rocketchat:4222
|
||||
- PORT=3000
|
||||
- ADMIN_USERNAME=${ROCKETCHAT_ADMIN_USER:-rcadmin}
|
||||
@ -1247,7 +1247,13 @@ services:
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
privileged: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- SYS_PTRACE
|
||||
- DAC_READ_SEARCH
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
read_only: true
|
||||
devices:
|
||||
- /dev/kmsg
|
||||
|
||||
@ -26,6 +26,19 @@ server {
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Auth endpoints — stricter rate limit to prevent credential stuffing
|
||||
location /api/auth/ {
|
||||
limit_req zone=api_auth burst=10 nodelay;
|
||||
set $upstream_api http://changemaker-v2-api:4000;
|
||||
proxy_pass $upstream_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
# Main API (Express)
|
||||
location / {
|
||||
limit_req zone=api_global burst=60 nodelay;
|
||||
|
||||
@ -44,7 +44,7 @@ info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
run() { if [[ "$DRY_RUN" == "true" ]]; then echo -e "${CYAN}[DRY-RUN]${NC} $*"; else eval "$@"; fi; }
|
||||
run() { if [[ "$DRY_RUN" == "true" ]]; then echo -e "${CYAN}[DRY-RUN]${NC} $*"; else "$@"; fi; }
|
||||
|
||||
# --- Arg parser ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
||||
@ -42,7 +42,7 @@ info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
run() { if [[ "$DRY_RUN" == "true" ]]; then echo -e "${CYAN}[DRY-RUN]${NC} $*"; else eval "$@"; fi; }
|
||||
run() { if [[ "$DRY_RUN" == "true" ]]; then echo -e "${CYAN}[DRY-RUN]${NC} $*"; else "$@"; fi; }
|
||||
|
||||
# --- Arg parser ---
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user