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:
bunker-admin 2026-03-31 18:30:17 -06:00
parent d7ab8f0d99
commit 5a0c4641a1
72 changed files with 684 additions and 241 deletions

221
CLAUDE.md
View File

@ -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

View File

@ -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);
}

View 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>
);
}

View File

@ -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: {

View File

@ -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 {

View 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; // 768991px
return { isMobile, isSmall, isTablet, screens };
}

View File

@ -361,6 +361,7 @@ export default function ActionItemsPage() {
<Table
dataSource={items}
columns={columns}
scroll={{ x: 'max-content' }}
rowKey="id"
loading={loading}
pagination={{

View File

@ -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">

View File

@ -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>

View File

@ -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) => (

View File

@ -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"

View File

@ -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');

View File

@ -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,

View File

@ -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');
}

View File

@ -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}

View File

@ -606,6 +606,7 @@ export default function MeetingPlannerPage() {
<Table
dataSource={polls}
columns={columns}
scroll={{ x: 'max-content' }}
rowKey="id"
loading={loading}
pagination={{

View File

@ -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>

View File

@ -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}

View File

@ -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' },

View File

@ -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={

View File

@ -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

View File

@ -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 }}
/>
),

View File

@ -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,

View File

@ -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 */}

View File

@ -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">

View File

@ -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 }}

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -171,6 +171,7 @@ export default function MediaJobsPage() {
<Table
columns={columns}
dataSource={jobs}
scroll={{ x: 'max-content' }}
rowKey="id"
pagination={false}
onRow={(record) => ({

View File

@ -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}

View File

@ -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,

View File

@ -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,

View File

@ -85,6 +85,7 @@ export default function PaymentsDashboardPage() {
columns={donationColumns}
rowKey="id"
pagination={false}
scroll={{ x: 'max-content' }}
size="small"
/>
</Card>

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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) {

View File

@ -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>

View File

@ -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,

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -177,6 +177,7 @@ export default function ReferralsPage() {
<Table
dataSource={referrals}
columns={referralColumns}
scroll={{ x: 'max-content' }}
rowKey="id"
size="small"
pagination={{

View File

@ -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: () => ({}),
}
)
);

View File

@ -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();

View File

@ -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)

View File

@ -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 {

View File

@ -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');

View File

@ -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 });

View File

@ -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) {

View File

@ -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``;

View File

@ -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;
}

View File

@ -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(

View File

@ -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);

View File

@ -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 },

View File

@ -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({

View File

@ -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);

View File

@ -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) {

View File

@ -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([

View File

@ -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([

View File

@ -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() },
});
},

View File

@ -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' };

View File

@ -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;
}

View File

@ -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));
}

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -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