Compare commits

...

89 Commits
v2.4.0 ... main

Author SHA1 Message Date
5331cdcc67 fix(approach-c): full E2E success on marcelle - byte-identical templates + core-only recreate
This session completed Approach C end-to-end on marcelle (status=COMPLETED,
mkdocs untouched, idempotent on re-run). Four fixes landed:

1. template-engine.ts: dropped nginx/conf.d/*.hbs (default, api, services)
   from renderAllTemplates AND renderAllTemplatesInMemory. The new
   prod-style docker-compose.yml.hbs does NOT mount conf.d/ into the
   nginx container ("Note: conf.d is NOT mounted (configs are generated
   at startup from templates)" — nginx confs are baked into the nginx
   Docker image). Writing them was a no-op orphan that showed up as 3
   "modified" lines in preview unnecessarily.
   Same reason removed nginx/nginx.conf from staticFiles.

2. templates/configs/{pangolin/resources.yml,prometheus/prometheus.yml,
   grafana/datasources/datasources.yml}.hbs: synced byte-identical to
   canonical changemaker.lite/configs/*. These ARE mounted into pangolin
   tunnel + prometheus + grafana respectively. Preview now reports
   "unchanged" for them on install.sh tenants.

3. templates/docker-compose.yml.hbs: dropped the CCP-tenant header
   comment, making the template now BYTE-IDENTICAL (58907 bytes) to
   canonical changemaker.lite/docker-compose.prod.yml. Even a 1-byte
   comment difference caused docker compose to compute new config hashes
   for every service, triggering full-stack recreates (including
   ccp-agent — the Phase 6 self-destruct trap from upgrade.sh).

4. upgrade.service.ts:runReleaseUpgrade — composeUp now restricted to
   core app services [api, admin, media-api, nginx] (same set as
   image-upgrade.sh). Unscoped composeUp would recreate ccp-agent
   mid-apply and orphan the runner. Until Approach C inherits the
   deferred-ccp-agent-restart pattern from upgrade.sh, this restriction
   keeps the apply path safe. Limitation: brand-new services in a
   release won't auto-deploy via Approach C alone — operator must
   follow with Approach A (full upgrade.sh) to pick them up.

E2E verification on marcelle:
  - Apply: status=COMPLETED, duration<10s.
  - mkdocs.yml md5 unchanged (38810d9df8b4258ad46a6739232cf88a).
  - mkdocs/docs file count unchanged (242).
  - docker-compose.yml now byte-identical to canonical (58907 bytes).
  - app + api public sites: 200 both.
  - Re-preview: ALL 10 files show "unchanged" — true idempotency.

Phase 6 acceptance gate met. Approach C now fully operational on the
install.sh fleet.

Bunker Admin
2026-05-23 11:00:38 -06:00
8af11af720 docs: session continuation - env-patch closed, fleet rollout complete, Phase 6 status
Approach C operational across the fleet with the env-patch gap closed.
Apply path code-validated via preview; full E2E apply pending nginx/configs
template sync (separate Phase 0-style mechanical work).

Bunker Admin
2026-05-22 19:30:26 -06:00
bf997e84c1 feat(approach-c): close env-patch gap for install.sh tenants
Approach C persists imageTag in Instance.imageTag and renders the full
.env for CCP-provisioned tenants. For install.sh-registered tenants
(isRegistered=true, no encryptedSecrets), the .env was filtered out of
the rendered file set — so the new imageTag never reached the tenant's
compose, leaving install.sh tenants unable to bump image versions via
Approach C.

Closes the gap with an in-place .env key patch:

- agent/services/file.service.ts: patchEnv(basePath, vars) — reads .env,
  finds existing keys and replaces values, appends unknown keys at end
  under a "# Added by CCP env-patch" comment. Preserves comments and
  unrelated keys. Validates ENV_KEY_RE + rejects newlines in values.
- agent/routes/files.routes.ts: POST /instance/:slug/env/patch.
- api/services/execution-driver.ts: patchEnv added to interface.
- api/services/local-driver.ts + remote-driver.ts: patchEnv methods.
- api/services/upgrade.service.ts:runReleaseUpgrade — for isRegistered
  tenants with newImageTag, calls driver.patchEnv({ IMAGE_TAG }) after
  writeFiles and before composePull. Non-fatal on failure (logs warn).

This makes Approach C functional for the existing install.sh fleet
(marcelle, linda, pia + future). CCP-provisioned tenants still get
the full .env render — unchanged behavior.

All three projects type-check cleanly.

Bunker Admin
2026-05-22 19:18:37 -06:00
35175a7136 docs: session handoff 2026-05-22 — Approach C complete
Captures Phase 0 + Phases 1-5 outcomes, Phase 6 preview-path
end-to-end validation against marcelle, known env-patch gap for
install.sh tenants, fleet rollout status, and the operator path.

Bunker Admin
2026-05-22 09:50:14 -06:00
abb4034e4b feat(upgrade): Approach C - CCP-driven release upgrade (template re-render)
Adds the third upgrade path alongside Approach A (full upgrade.sh) and B
(image-only). For releases that change orchestration (new services, new
nginx routes, new compose env vars) in addition to image versions, CCP
re-renders templates server-side, sends the rendered files to the tenant
via the existing mTLS agent, then composePull + composeUp. Tenant content
(mkdocs/, custom configs/) is never touched.

Pieces:

PHASE 1 — Schema + per-instance imageTag

- prisma/schema.prisma: new Instance.imageTag column (NULL = fall back
  to env.IMAGE_TAG default).
- prisma/migrations/20260522093400_add_instance_image_tag/: SQL.
- services/template-engine.ts:
  - buildTemplateContext now uses instance.imageTag || env.IMAGE_TAG.
  - InstanceForTemplate interface gains imageTag: string | null.

PHASE 2 — Pre-flight diff (read-only "what would change?")

- agent/services/file.service.ts: new diffFiles() helper with a small
  inline LCS-based unified-diff (no new deps). Returns per-file status
  ('unchanged' | 'modified' | 'created') + truncated unified diff.
- agent/routes/files.routes.ts: POST /instance/:slug/files/diff.
- api/services/execution-driver.ts: diffFiles added to interface.
- api/services/local-driver.ts + remote-driver.ts: diffFiles methods
  (local mirrors agent helper inline; remote POSTs to the agent endpoint).
- api/services/upgrade.service.ts: previewReleaseUpgrade() — renders
  templates in-memory with the proposed imageTag, filters out .env for
  isRegistered=true tenants, calls driver.diffFiles, computes envCoverage
  (which env vars the new compose needs vs which the tenant's .env has).

PHASE 3 — Apply path (the actual upgrade)

- api/services/upgrade.service.ts: startReleaseUpgrade() and the inner
  runReleaseUpgrade() runner. Distinct from runRemoteUpgrade because CCP
  does the work directly via the mTLS driver (no agent-side script).
  Flow: persist imageTag in DB → render → writeFiles → composePull →
  composeUp → composePs verify. Status reported via InstanceUpgrade
  rows (same shape the existing CCP polling UI already uses).
- Failure handling: instance.imageTag stays at the new value on failure
  so operator can retry. Manual rollback only.

PHASE 4 — Routes + schemas

- instances.schemas.ts: startReleaseUpgradeSchema (imageTag regex).
- instances.routes.ts:
  - POST /:id/upgrade-release       (apply)
  - POST /:id/upgrade-release/preview (read-only diff)

PHASE 5 — CCP admin UI

- admin/pages/InstanceDetailPage.tsx: third "Upgrade to Release" button
  next to Quick Upgrade + Upgrade Now. Opens a modal with imageTag input,
  Preview button (calls /preview), and Apply button. Preview modal shows:
  - Red alert if envCoverage.missingInTenantEnv is non-empty (compose
    needs vars the tenant's .env doesn't define).
  - Per-file status tags (unchanged / modified / created) + truncated
    unified diff for modified files.
- admin/types/api.ts: Instance.imageTag added.

Constraints applied:
- Remote-only initial scope: throws "currently supported only for remote
  instances" if instance.isRemote === false.
- isRegistered=true tenants (install.sh fleet): .env is filtered out
  of the render set (CCP can't render env without secrets in DB), the
  tenant's existing .env stays as-is. envCoverage warns the operator
  if the new compose references env vars their .env doesn't define.
- Shared in-progress guard with Approach A/B (one upgrade at a time).

Per the plan: see ~/.claude/plans/insight-temporal-bachman.md.

All three projects type-check cleanly (api, agent, admin).

Bunker Admin
2026-05-22 09:45:37 -06:00
97444645cb chore(approach-c): Phase 0 complete - templates byte-equivalent to canonical
This commit completes Phase 0 of Approach C: the CCP template/env/static
files now produce output structurally byte-identical to canonical
docker-compose.prod.yml + .env.example. Verified by rendering against
marcelle, linda, and pia and diffing against their actual files — all
three show only the 30-line CCP-tenant header comment differing,
zero service/env-var structural differences.

Changes:

- templates/docker-compose.yml.hbs: reverted {{imageTag}} substitutions
  back to ${IMAGE_TAG:-latest} so the compose template is now byte-
  equivalent to docker-compose.prod.yml (modulo header). CCP controls
  per-instance image tag selection via the rendered .env's IMAGE_TAG,
  which compose-up picks up at runtime. This single-source-of-truth
  via env-substitution matches install.sh tenants exactly.

- templates/env.hbs: rewritten as a near-mirror of .env.example. Adds
  27 missing keys (IMAGE_TAG, GITEA_REGISTRY, COMPOSE_PROFILES,
  ENABLE_CCP_AGENT, GITEA_ADMIN_*, ENABLE_HLS_TRANSCODE, TZ, etc.)
  plus 15 CCP-specific extras (embed ports, dev-mode helpers, etc.).
  All 145 compose-template env-var references are now covered.

- templates/nginx/nginx.conf: synced from canonical. Includes recent
  security additions: redacted access-log format for token/secret
  query params, rate-limit zones (api_global, api_auth, upload),
  conditional HSTS via X-Forwarded-Proto map.

- api/scripts/render-for-instance.ts (new): one-off CLI that loads
  an Instance row, decrypts secrets if present (or uses empty object
  for isRegistered=true tenants), and calls renderAllTemplates() to
  a scratch dir. Used in Phase 0.4 to verify the template-vs-prod
  contract per tenant.

  Usage:
    docker compose exec ccp-api npx tsx scripts/render-for-instance.ts \
      --slug changemakerlite

Phase 0 acceptance gate met:
  - marcelle (release v2.10.2 install): 30-line diff, header-only
  - linda (release v2.9.14 install):    30-line diff, header-only
  - pia (release v2.9.10 install):      30-line diff, header-only
  - env.hbs key coverage: 0 missing vs marcelle's .env

Next phases unblocked:
  - Phase 1: add Instance.imageTag column (Prisma migration)
  - Phase 2: pre-flight diff endpoint
  - Phase 3: startReleaseUpgrade runner
  - Phase 4: routes + schemas
  - Phase 5: CCP UI "Upgrade to Release" button
  - Phase 6: E2E test on marcelle (v2.10.2 -> v2.10.3)

Bunker Admin
2026-05-22 09:35:30 -06:00
f34382ebdd chore(approach-c): Phase 0 initial template overlay + session handoff
This session shipped:
- Approach B end-to-end (commit 4a3d9d7): full rollout to all 7 tenants;
  marcelle E2E validated twice (121s + 100s).
- v2.10.2 surgical update applied to 6 remaining tenants.

This commit lands the kickoff for Approach C (template re-render path):

scripts/templates changes:
- docker-compose.yml.hbs.OLD-style-pre-approach-c: preserved old CCP
  template (Handlebars-heavy, dynamic container names, secrets rendered
  at template-time).
- docker-compose.yml.hbs: REWRITTEN as a near-mirror of canonical
  docker-compose.prod.yml. Minimal Handlebars overlay:
    - Header comment lists {{name}}, {{slug}}, {{composeProject}}.
    - 5 image refs: ${IMAGE_TAG:-latest} -> {{imageTag}}, so CCP can
      per-instance override once Phase 1 lands the Instance.imageTag column.
  All other variation flows through env-var substitution from tenant's
  .env. Container names are now hardcoded (matching prod), feature flags
  are deferred to COMPOSE_PROFILES gating (matching prod).

Why a rewrite: the old CCP template and prod compose used fundamentally
different conventions (dynamic vs hardcoded names, render-time vs
substitute-time secrets, Handlebars vs profiles gating). Sync-by-addition
couldn't reconcile them. The rewrite makes Approach C re-render safe for
the install.sh-installed fleet (marcelle, linda, pia and future).

docs/SESSION_HANDOFF_2026-05-21.md: full session handoff covering fleet
state, Approach B rollout, Approach C plan, and where to start next
session. force-added because /docs is gitignored (same precedent as
docs/SESSION_HANDOFF_2026-05-20.md from prior session).

Phase 0 remaining work (next session):
- Audit env.hbs against new compose env-var expectations
- Sync static config files (nginx/, configs/prometheus/, etc.)
- Build api/scripts/render-for-instance.ts harness
- Iterate template until rendered output is per-instance-only diff
  against marcelle/linda/pia actual compose.

Then Phases 1-6 per plan in subsequent sessions (~11-14 hours total).

Bunker Admin
2026-05-21 19:32:21 -06:00
4a3d9d7c41 feat(upgrade): Approach B - image-only upgrade mode
Add a "Quick Upgrade" path that pulls latest container images and recreates
only the core app services (api, admin, media-api, nginx) without touching
any tracked files. Tenant content (mkdocs/, configs/, scripts/) is implicitly
preserved because the script never writes outside docker.

Faster (~2 min vs ~4-5 min for full upgrade) and structurally safer for
releases that don't change orchestration/templates.

Pieces:
- scripts/image-upgrade.sh: new ~350-line script. Phases: pre-flight +
  mkdocs snapshot, image pull, targeted recreate (broad up -d would cascade
  on misconfigured infra containers — proven on marcelle), light health
  checks, deferred ccp-agent restart. Writes the same progress.json +
  result.json schema as upgrade.sh so the CCP poll loop is unchanged.
- agent/src/routes/upgrade.routes.ts: POST /instance/:slug/upgrade/start-image-only.
  Same lock + staleness guards as the existing /upgrade/start endpoint.
- api/src/services/remote-driver.ts: RemoteDriver.startImageUpgrade().
- api/src/services/upgrade.service.ts: startImageUpgrade() entry point;
  reuses runRemoteUpgrade with mode='image-only' (only the initial agent
  call differs — result schema and polling are identical).
- api/src/modules/instances/instances.routes.ts: POST /:id/upgrade-images
  + startImageUpgradeSchema.
- admin/src/pages/InstanceDetailPage.tsx: secondary "Quick Upgrade" button
  next to "Upgrade Now" on the Updates tab. Tooltip explains when to use it.

Tested locally on marcelle (v2.10.2 idempotent run): 1m 49s, mkdocs.yml md5
unchanged, file count unchanged, only api/admin/media-api/nginx touched.
Subtle bug found and fixed: `set -o pipefail` + `grep -q` shorts pipe and
SIGPIPEs the writer — captured services list once instead.

Bunker Admin
2026-05-21 15:20:35 -06:00
731e70ee42 docs: session handoff for the upgrade-flow redesign work
Captures the full state of the 2026-05-20/21 working session for the
next agent or future-self: fleet status, what landed in v2.10.2,
remaining Phase B + C work from the plan, surgical-update procedures
for the 6 remaining tenants (proven on pia 2026-05-21), bug inventory,
and "don't repeat my mistakes" notes.

Plan reference: /home/bunker-admin/.claude/plans/okay-so-we-can-enumerated-hejlsberg.md

Force-added because docs/ is gitignored but the handoff needs to be
discoverable in-repo (same pattern as COMPETITIVE_ANALYSIS.md).

Bunker Admin
2026-05-21 13:42:08 -06:00
a7d3dd772b chore(release): ship scripts/lib/ + classify upgrade-stash-cleanup.sh
Two release-build fixes paired with the Approach A changes:

1. Add upgrade-stash-cleanup.sh to RUNTIME_SCRIPTS so it ships in the
   release tarball. Tenants need it to be able to recover from stale
   upgrade-* git stashes on their own hosts.

2. Copy scripts/lib/ wholesale into the staged release tree. Without this,
   upgrade.sh's `. scripts/lib/mkdocs-snapshot.sh` source line silently
   fails on release installs (the file isn't there), and the pre-upgrade
   tenant-docs snapshot wouldn't fire — defeating the no-regrets fallback.

Bunker Admin
2026-05-21 10:36:28 -06:00
9613c3ec81 fix(upgrade): Phase 1 of upgrade-flow redesign (Approach A)
Three coordinated fixes from the upgrade-flow redesign plan
(/home/bunker-admin/.claude/plans/okay-so-we-can-enumerated-hejlsberg.md):

1. scripts/lib/mkdocs-snapshot.sh (NEW): pre-upgrade tarball snapshot of
   the entire mkdocs/ directory into the install root as
   mkdocs-backup-<timestamp>.tar.gz. Discoverable via `ls`, retained last 5.
   No-regrets fallback if anything in the upgrade goes sideways. Sourced
   by upgrade.sh (and later by image-upgrade.sh under Approach B).

2. scripts/upgrade.sh Phase 6 self-destruct fix: previously, the broad
   `docker compose up -d` recreated the ccp-agent container that was
   running the script, sending SIGKILL to the bash process before
   write_result could land result.json. Marcelle's test upgrade hit this
   tonight. Fix: temporarily remove `ccp-agent` from COMPOSE_PROFILES
   during Phase 6's broad up -d, then schedule a detached `nohup ... &
   disown` restart at the very end of the script (after write_result and
   archive_success_to_history). The deferred subshell sleeps 3s, then
   recreates ccp-agent under its profile, picking up the new image.

3. scripts/upgrade-stash-cleanup.sh (NEW): one-shot utility to list and
   drop accumulated `upgrade-*` git stashes left over by older upgrade.sh
   runs whose pop failed silently (Pride Corner has three from 2026-03-09
   alone). Warns loudly if any stash holds tenant mkdocs.yml content so
   operators verify recovery before dropping.

The .gitignore now excludes /mkdocs-backup-*.tar.gz so the rescue
archives don't leak into commits.

This is Phase 1 of three: Approach B (image-only upgrade mode) and
Approach C (CCP template re-render) follow in subsequent commits.

Bunker Admin
2026-05-20 20:43:34 -06:00
e88ac79ae8 fix(ccp-agent): export COMPOSE_PROJECT_NAME so upgrade.sh sees correct project
The agent already passed COMPOSE_PROJECT in env, but Docker Compose actually
reads COMPOSE_PROJECT_NAME. When upgrade.sh (running inside the agent
container at cwd=/app/instance) shelled out to `docker compose up -d` in
Phase 5, compose defaulted the project name to "instance" (cwd basename),
collided with the host's existing containers under "changemakerlite", and
the upgrade aborted with "Container ... already in use by container ..."
errors.

Discovered when triggering the first end-to-end CCP "Upgrade Now" on
marcelle (v2.9.15 → v2.10.1). Backup/code/rebuild phases all succeeded;
migration phase failed instantly. Rollback restored marcelle cleanly.

This commit adds COMPOSE_PROJECT_NAME alongside the existing COMPOSE_PROJECT
(which the agent's TypeScript still reads for its own slug derivation).

Bunker Admin
2026-05-20 15:57:30 -06:00
1b80e8294c fix(ccp-agent): whitelist /app/instance for git safe.directory
The agent container runs as root but the bind-mounted instance directory
is owned by the host user (UID 1000 = `node` in the container). Modern
git refuses to operate on such repos without an explicit safe.directory
entry, breaking upgrade-check.sh's `git fetch/log` calls on source-installed
tenants. Verified empirically on soroush after the previous fix landed.

Bunker Admin
2026-05-20 12:14:39 -06:00
a531f9b9ce fix(ccp): make agent functional + fix Gitea release timestamp bug
Three related fixes uncovered during a marcelle CCP registration test:

1. ccp-agent image was missing bash + curl + jq + python3, so every
   spawn('bash', ...) in upgrade.routes.ts and backup.routes.ts failed
   silently with ENOENT. CCP kept reading stale status.json files from
   disk, masking that no agent had successfully checked for updates in
   weeks. apk-add the missing tools.

2. ccp-agent's /app/instance mount was :ro, blocking the agent from
   writing data/upgrade/status.json (and result/progress/backups).
   Agent already has docker.sock — removing :ro is not a security
   escalation. Patched both docker-compose.yml and docker-compose.prod.yml.

3. Gitea 1.23.x only initializes Release.CreatedUnix inside its
   createTag() helper, which is skipped if the tag already exists on
   origin. The old DEV_WORKFLOW pattern (push tag, then run
   build-release.sh --upload) was triggering this — releases got
   created_unix=0 and lost /releases/latest sort order to v2.9.14.
   build-release.sh now removes the remote tag first and POSTs with
   target_commitish so Gitea creates the tag and release atomically.

After these fixes, CCP's "Check for Updates" path returns truthful
data end-to-end (verified on marcelle: v2.9.15 -> v2.10.1, 1 behind).

Bunker Admin
2026-05-20 11:59:35 -06:00
a82e95946b fix(gancio): pre-start config-init sidecar prevents restart loop
Gancio refuses to start when its DB has tables but the data volume has no
config.json ("Non empty db! Please move your current db elsewhere than retry"),
which produces an infinite restart loop. This hit production tenants bnkops
and trbh (>1200 restart cycles each) — proximate cause was a missing
config.json in changemakerlite_gancio-data with the DB fully populated.

Add gancio-config-init alpine sidecar that runs on every `up`:
  - no-op when config.json exists
  - regenerates from .env when missing (1000:1000 ownership)
  - gancio service now depends on its service_completed_successfully

Also harden verify_gancio_config in upgrade.sh to error loudly when
multiple gancio-data volumes match (silent head -1 could pick the wrong
one after a compose project rename).
2026-05-19 17:02:55 -06:00
3f6102cf6d docs: refresh DEV_WORKFLOW for 5-image build + tweak campaign card layout
- DEV_WORKFLOW.md: reflect that build-and-push.sh now produces 5 images
  (api, admin, media-api, nginx, ccp-agent), not 4.
- CampaignsListPage: move card title below cover photo instead of
  overlaying it, so titles remain legible when no cover image is set.

Bunker Admin
2026-05-18 14:16:25 -06:00
1f240ad518 Docs updates 2026-04-30 19:07:17 -06:00
21208b58c7 feat(media): HLS adaptive bitrate streaming with MP4 fallback
Replaces single-MP4 + range-request streaming with HLS multi-bitrate
segments to fix video stutter through the Newt tunnel. Range-request
bursts were the root cause; HLS chunks are small and tunnel-friendly,
plus the player adapts bitrate to bandwidth.

Backend
- New BullMQ `hls-transcode` queue (in-process worker, concurrency 1)
- FFmpeg single-pass transcode → 360p/720p/1080p variants with aligned
  keyframes; output at /media/local/hls/{id}/master.m3u8
- New /api/{videos|public}/{id}/hls/* routes serving signed manifests
  and segments (URLs emitted as /media/* so nginx rewrites to media-api)
- Prisma: HlsStatus enum + 6 fields on Video + index, migration
- Upload + yt-dlp fetch paths enqueue transcode jobs
- ENABLE_HLS_TRANSCODE flag (default off; gates enqueue only)
- Backfill script: `npm run backfill:hls`
- media-api bumped to 4 CPU / 2G for FFmpeg headroom

Frontend
- New useHls hook: lazy-imports hls.js (kept out of main bundle),
  native HLS on Safari/iOS, gives up after 2 NETWORK_ERRORs so MP4
  fallback engages cleanly
- VideoPlayer, VideoViewerModal, ShortsPage, ProductDetailPage now
  prefer HLS when ready; MP4 fallback is automatic
- ShortsPage prefetches next-3 master manifests via <link rel="prefetch">
- PublicVideoCard hover preview stays MP4 (avoids hls.js init latency)

Bunker Admin
2026-04-30 19:03:29 -06:00
2ae7d8b968 Bug fixes for video serving and updats to documentation for mobile use screenshots 2026-04-30 14:17:50 -06:00
aba935c8ac fix(gitea): healthz probe + DB-first token + honest banner copy
Three related bugs surfaced by the Gitea Setup wizard:

1. checkStatus probed /api/v1/version unauthenticated, which returns 403
   under REQUIRE_SIGNIN_VIEW=true (Gitea's default). Result: giteaOnline
   and installComplete always read false on any REQUIRE_SIGNIN_VIEW=true
   install, producing a green "Setup Complete" banner over two red-X
   foundational rows.

   Fix: use /api/healthz (public, exempt from sign-in requirement).
   Bonus: split installComplete from giteaOnline — online now honestly
   means "process responding", installComplete means "admin user exists"
   (verified either by DB flag or by Basic Auth probe on /user). Status
   code is now logged on healthz failure for debuggability.

2. docs-history.service.ts read the API token from env only, bypassing
   the DB. After the setup wizard wrote the token to siteSettings,
   docs-history still saw env.GITEA_API_TOKEN empty and silently did
   nothing. Same file also had a second /api/v1/version bug in
   isAvailable().

   Fix: route token lookup through giteaClient.getConfig() (DB-first,
   env fallback — same pattern as the rest of the codebase). Switch
   isAvailable() to /api/healthz.

3. UI banner confidently claimed "Gitea is not running" on any
   non-200 response, including the 403 case above. Misleading, and
   dangerous to point users at `docker compose up -d gitea` when the
   container is already running.

   Fix: softer "not reachable" copy that points users at the API log
   for the actual status code.

Bunker Admin
2026-04-23 11:47:02 -06:00
4ccc433eb9 fix(gitea): fall back to INITIAL_ADMIN_PASSWORD for gitea-init
Previously both compose files defaulted GITEA_ADMIN_PASSWORD to empty,
and scripts/gitea-init.sh silently skipped admin creation on blank input.
If config.sh failed to propagate the password (e.g. Docs Comments not
enabled, or --admin-password omitted), fresh installs ended up with a
Gitea container running but zero users — and the admin GUI's Gitea setup
wizard had no token to progress.

Changes:
- docker-compose.yml + docker-compose.prod.yml: GITEA_ADMIN_PASSWORD now
  falls back to INITIAL_ADMIN_PASSWORD when unset
- .env.example: declare GITEA_ADMIN_PASSWORD= with explanatory comment so
  users discover the override
- scripts/gitea-init.sh: silent skip becomes a loud WARN so a broken
  config is visible in compose logs

Bunker Admin
2026-04-23 10:54:24 -06:00
94451f9aa0 A bunch of documentation updates 2026-04-19 16:32:38 -06:00
6d562da4b2 Merge branch 'chore/squash-prisma-migrations' 2026-04-16 17:36:36 -06:00
3f8c064649 chore(prisma): squash 50 migrations into single init
Replaces all 50 prior migrations with one fresh init.sql generated via
`prisma migrate diff --from-empty --to-schema-datamodel`. Schema is
unchanged; this is purely a consolidation of migration history.

Result:
- 50 → 1 migration (50 dirs deleted, 1 new init created)
- 6304 → 6075 lines of migration SQL (-229; redundant ALTERs collapsed)
- Fresh installs apply one transaction instead of 50 chained
- 192 tables / 103 enums / 516 indexes — verified against current schema

Verified locally:
- prisma migrate reset → applied cleanly, seed succeeded
- 193 tables in public schema (192 models + _prisma_migrations)
- /api/health → database+redis ok
- Login as admin@bnkops.ca → SUPER_ADMIN role returned
- Authenticated GET /api/settings + /api/users → working

Existing deployments need `prisma migrate reset` (acceptable: no
production data of consequence currently).
2026-04-16 17:34:07 -06:00
5082fe7b76 chore: untrack api/dist + tsbuildinfo build artifacts
api/dist/ (468 files, 11MB) and admin/tsconfig.tsbuildinfo were committed
before being added to .gitignore — the rule had no effect on the existing
tracked copies. Untrack them now so future Docker rebuilds stop showing
spurious diffs. Files stay on disk; rebuild regenerates everything.

Also add *.tsbuildinfo to .gitignore so future tsc incremental caches stay
out of git.
2026-04-16 16:54:55 -06:00
3a528d9a49 chore: gitignore hygiene + untrack stale artifacts
- Add gitignore rules for code-server data/, mkdocs cache,
  scheduled_tasks.lock, and archive/ (closes routine git status noise)
- Widen data/upgrade ignore from *.json to all files
- Delete archive/ (3.6GB of legacy V3.x release zips)
- Untrack .playwright-mcp/ logs and mkdocs.yml.bak (already in .gitignore;
  predate the rules)
- Remove empty root package.json/package-lock.json stubs
2026-04-16 16:38:45 -06:00
8a2b82a4e8 docs(blog): v2.9.8 + v2.9.9 post — "A smoother fresh install"
High-level recap of the install-UX sprint for the MkDocs blog. Covers:
- What the three-round fresh-install test surfaced
- Install-day improvements that shipped in v2.9.8 (host-port
  preflight, Pangolin smoke test, admin password file,
  --enable-all installing systemd units, Next Steps verify
  pointer)
- Teardown scripts (pangolin-teardown.sh, ccp-deregister.sh) and
  CCP's deleteInstance tunnel cleanup
- CCP registration polish (slug-conflict 409, poll-rate-limit
  split, agent exponential backoff)
- Release hygiene in v2.9.9 (--replace safety, whitelist parity,
  install.sh extract preservation on TTY failure)
- Brief "what's next / deferred" section

Placed under mkdocs/docs/blog/posts/ with the date-prefix filename
convention used by 2026-03-27-test-blog-post.md.

Bunker Admin
2026-04-16 16:11:08 -06:00
5968df5b42 release: scripts/*.sh whitelist parity check in build-release.sh
Replace the implicit runtime-scripts for-loop with two explicit arrays
(RUNTIME_SCRIPTS + DEV_ONLY_SCRIPTS) and a pre-build assertion that
aborts if any scripts/*.sh isn't classified.

Motivation: during the v2.9.8 sprint we added three new scripts
(pangolin-teardown.sh, ccp-deregister.sh, validate-env.sh) and had to
manually remember to include each in the whitelist. Missing one is
silent — the script just doesn't ship, and the release tarball is
subtly broken in a way you only notice when the docs reference the
missing script.

The parity check surfaces the decision: every new scripts/*.sh must
be deliberately classified as ship (RUNTIME_SCRIPTS) or don't-ship
(DEV_ONLY_SCRIPTS). Adding a script and forgetting aborts the build
with a clear message naming the unclassified file.

Side-effect fixes from this audit:
  - register-with-ccp.sh is now shipped (was only in source; needed
    for retrofitting CCP onto existing installs)
  - update-env.sh is now shipped (safe .env updater used by upgrade
    flows)

Bunker Admin
2026-04-16 15:17:54 -06:00
824f3cce99 install: preserve extracted dir when config wizard can't start
scripts/install.sh cleanup trap previously removed $INSTALL_DIR on
any non-zero exit if .env wasn't written yet. That made sense for
half-extracted state, but also bit us when config.sh failed at
/dev/tty (the common "curl | ssh bash" non-interactive case) — the
15MB tarball had already extracted cleanly and the user was forced
to re-download to retry on a console.

New EXTRACT_COMPLETE state flag:
  - Set to true after the tar xzf step verifies docker-compose.yml.
  - cleanup() distinguishes "extract OK, config wizard didn't run"
    from "extraction never completed":
      * First case: preserve the dir, print a resumption hint
        (cd $INSTALL_DIR && bash config.sh on an interactive console).
      * Second case: unchanged behaviour — remove the partial dir.

Typical SSH-without-tty recovery path now costs zero re-download.

Bunker Admin
2026-04-16 14:25:53 -06:00
450b5ad4ba docs: sync getting-started + README with install UX improvements
Updates the user-facing docs to match the install flow after the
friction fixes landed:

README.md
  Quick Start block now reflects reality: install.sh host-port
  check, test-deployment.sh verify step, password file location,
  and the useful-tools block (validate-env, test-deployment,
  pangolin-teardown, ccp-deregister).

mkdocs prerequisites.md
  New warning block under Linux Server covering the cockpit-on-9090
  class of port collisions, pointing at the installer's ss-based
  preflight and validate-env.sh for manual checks. Checklist gains
  a host-port line.

mkdocs installation.md
  "What install.sh does" now enumerates the new port check and disk
  check. Configuration Wizard Step 4 notes the
  data/admin-credentials.txt persistence for auto-generated
  passwords. "Verifying Installation" rewritten around
  test-deployment.sh. New "Clean reset before reinstall" block with
  the teardown sequence.

mkdocs first-steps.md
  Log In step tells users where to find the generated password when
  they ran config.sh -y without --admin-password.

mkdocs control-panel.md
  New "Registering an Existing Install (Phone-Home)" section
  covering invite code, --ccp-* flags, approval, rate-limit + backoff
  behaviour, and the ccp-deregister.sh teardown path with the
  slug-conflict rationale.

Bunker Admin
2026-04-16 13:21:44 -06:00
d2da13929a ccp: split /register and /poll rate limits; agent backoff on 429
Problem: the agent polled /poll every 30s while waiting for admin
approval. At 10 req/15min, the 11th poll hit 429 after ~5 min and
every subsequent one also failed — recovery required an agent
restart. A human-paced approval SLA is longer than 5 minutes.

CCP side (agents.routes.ts):
  Split the one-size-fits-all agentRegistrationLimiter into two.
  /register stays tight (10/15min — invite-code brute force is the
  real attack surface). /poll gets a new agentPollLimiter at 180/15min
  (one poll per ~5s upper bound), scoped to registrationId+slug so
  blast radius is bounded.

Agent side (server.ts):
  Replaced fixed 30s setInterval with a self-scheduling setTimeout
  loop that backs off exponentially on HTTP 429 (30s → 60s → 120s →
  300s cap) and resets to 30s on any 2xx. Stop-flag protects against
  re-entry after approval. Fixes the "agent wedged at 429, restart to
  recover" workaround.

Bunker Admin
2026-04-16 13:11:39 -06:00
6504598752 ccp: surface slug-collision as 409, not raw Prisma error
agents.routes.ts approve handler: wrap prisma.instance.create in
try/catch. When a PrismaClientKnownRequestError P2002 with target
including 'slug' is thrown, convert to a 409 AppError with a clear
message directing the admin at DELETE /api/instances/:id or the new
scripts/ccp-deregister.sh on the target host.

Before this, re-registering a previously-registered host (after the
operator tore down the underlying stack without cleaning CCP's DB
row) returned 500 with a raw Prisma error string — the operator had
to read a stack trace to understand the cause. Now the error is
self-describing and points at the fix.

Matches the pattern other CCP error paths use.

Bunker Admin
2026-04-16 13:08:23 -06:00
ce8c5aaf1f install: add scripts/ccp-deregister.sh + ship in tarball
Pairs with pangolin-teardown.sh. For instances that were phone-home
registered with a CCP, this script removes the CCP-side Instance row
during teardown so the slug is freed for re-registration.

Without it, tearing down a CCP-registered instance leaves a stale
Instance row in CCP's DB. The next phone-home-registration with the
same slug hits the unique-constraint violation we saw in marcelle
testing.

Matching: by agentUrl (default from .env CCP_AGENT_URL), --slug, or
--instance-id. Dry-run by default; --yes to execute. CCP admin token
via --token or CCP_ADMIN_TOKEN env var.

Added to build-release.sh whitelist so release tarballs include it
alongside pangolin-teardown.sh and validate-env.sh.

Bunker Admin
2026-04-16 13:07:12 -06:00
c2f12aa2bf release: refuse upload over existing tag unless --replace
scripts/build-release.sh --upload now checks for an existing release
at the given tag before POSTing a new one. If found and --replace is
not set, errors out with a clear message.

This prevents the silent-overwrite problem: a user on v2.9.7 running
./scripts/upgrade.sh sees "no update available" when the v2.9.7
release's tarball contents have silently changed. Version tags should
be immutable once published.

--replace is still available for deliberate test-bench iteration
(DELETEs the existing release, then POSTs). Documented as destructive
in the --help output and DEV_WORKFLOW.md.

Bunker Admin
2026-04-16 13:06:06 -06:00
6e01d580b2 install: persist generated admin password + Pangolin credential smoke test
config.sh: two more pieces of first-time-user UX polish surfaced
during fresh-install testing.

- When configure_admin() auto-generates the admin password (no
  --admin-password given), also write a locked-down
  data/admin-credentials.txt (mode 0600) containing email + password
  + timestamp. Users who pipe config.sh output or miss the single
  stdout print no longer lose the password forever. The file is only
  written on the auto-generate path — explicit --admin-password
  leaves it alone.

- configure_pangolin() now smoke-tests --pangolin-api-key +
  --pangolin-org-id against /org/:id/resources before writing them to
  .env. Catches typos, revoked keys, or wrong org IDs while recovery
  is cheap (rather than later, when Newt fails to connect and
  symptoms look like a tunnel outage). New flag:
  --skip-pangolin-check for offline bootstrap scenarios.

Bunker Admin
2026-04-16 12:59:10 -06:00
dbbff8adc9 install: host-port preflight in install.sh + surface verify/teardown tools
scripts/install.sh: inline ss -Htln check before tarball download so
cockpit-on-9090 (and friends) fail early instead of breaking the stack
mid-compose-up. Culprit-specific hints for :9090 (cockpit.socket) and
:80/:443. Gracefully skipped if iproute2 not installed.

config.sh: Next Steps in release mode now surfaces
  - test-deployment.sh --wait 60 (verify step)
  - validate-env.sh (re-check ports/.env)
  - pangolin-teardown.sh (clean reset before reinstall)
Also documents the ~3min first-pull + ~90s stabilization window so
brief "unhealthy" statuses don't panic new users.

Bunker Admin
2026-04-16 12:56:55 -06:00
f9d566bd84 install: preflight + teardown tooling + CCP tunnel cleanup on delete
Fixes surfaced by three rounds of fresh-install testing on marcelle:

- config.sh: add host-port preflight check (ss -tln) to catch
  cockpit-on-9090 style collisions before compose up; add
  --skip-port-check escape hatch; add --install-watcher /
  --no-install-watcher / --install-backup-timer /
  --no-install-backup-timer flags; -y --enable-all now installs both
  systemd units by default (previously silently skipped); print
  resolved admin email in Configuration Complete block.

- scripts/validate-env.sh: new section 5b "Host Port Availability"
  using ss-based detection, with process-name surfacing when run as
  root.

- scripts/pangolin-teardown.sh: new wrapper. Reads credentials from
  .env or takes --api-url/--api-key/--org-id flags. Dry-run by
  default; --yes to execute. Deletes resources before sites (avoids
  orphans). --keep-site-ids for safety.

- scripts/build-release.sh: include validate-env.sh and
  pangolin-teardown.sh in release tarball whitelist.

- CCP instances.service.ts: deleteInstance() now calls
  teardownTunnel() before composeDown when pangolinSiteId is set.
  Previously an admin clicking "Delete Instance" orphaned the
  Pangolin site + all its resources. Best-effort with try/catch
  matching the existing Docker-cleanup tolerance pattern.

- CLAUDE.md: sync drift — 44 → 50 migrations, 186 → 192 models,
  40 → 44 modules.

Bunker Admin
2026-04-16 12:50:48 -06:00
13513aeca5 Fix VERSION promotion regression: don't gate on soft health-check warnings
Prior commit (ac901c9e, Fix B) gated VERSION.pending promotion behind
VERIFY_FAILED=false, but VERIFY_FAILED is a soft warning signal — it
also fires when the admin container's 30s verify budget is tight
(which was the cry-wolf case Fix 3 addressed in the same commit).

Observed on marcelle during v2.9.5 → v2.9.6: the upgrade completed
successfully (tarball extracted, containers pulled and running new
image), but because the admin healthcheck warned at 30s (still using
v2.9.5's upgrade.sh with its 30s budget), VERIFY_FAILED=true pinned
VERSION back to v2.9.5 despite everything else having advanced. result.json showed success=true but newCommit=v2.9.5.

Hard failures still prevent promotion via on_failure's rm -f of
VERSION.pending before the promotion site is reached. Reaching the
promotion site means Phase 7 completed without exit-code or trap —
that's the correct gate.

Bunker Admin
2026-04-15 18:33:13 -06:00
ac901c9e53 Update system hardening: breaking-release gate + release-mode rollback + health budgets + success archival
Four fixes building on the prior upgrade-path work. All observed on
marcelle across today's v2.9.2 → v2.9.5 cycles and addressed here.

- Fix 1 (breaking-release gate). upgrade-check.sh now parses the first
  line of each Gitea release body for `BREAKING: <reason>` and threads
  `breaking`/`breakingReason` through status.json into the API status
  response. Admin UI renders a red Alert with a typed-tag confirmation
  input and gates the Start Upgrade button. auto-upgrade.service.ts
  refuses to apply breaking releases, logging a skip and holding off
  until the operator confirms manually.

- Fix 2 (release-mode rollback). print_rollback_help and the --rollback
  flow both used `git checkout`, which silently fails in release
  installs (no .git). Added INSTALL_MODE branches: release mode
  downloads the prior tarball from Gitea using a new VERSION.rollback
  marker seeded at Phase 3 start. Source mode retains the existing
  git-based flow.

- Fix 3 (Phase 7 health budgets). admin verify_service_health budget
  30s → 90s (matches the admin container's start_period from commit
  47704667). Gancio + MkDocs switched from one-shot to the existing
  verify_service_health retry wrapper. Cuts the cry-wolf
  "services may still be starting" warning from every upgrade result.

- Fix 4 (symmetric success archival). Bash archive_failure_to_history
  already logs failures on exit; added a matching archive_success_to_
  history called after write_result on the success path. API-side
  archiveResult now dedupes on completedAt so double-recording (bash
  + post-restart handler) can't land twice in history.json.

Release the bundle as v2.9.6.

Bunker Admin
2026-04-15 16:57:13 -06:00
47704667b1 Upgrade failure visibility + atomic VERSION + external smoke test
Three fixes to harden the admin-UI upgrade path, all in scripts/upgrade.sh.
Root-caused by yesterday's v2.9.2 → v2.9.3 on marcelle which was killed by
systemd mid-Phase-4 and left the system in a misleading half-upgraded state
(VERSION bumped, container pre-upgrade, result.json stale from 24h prior).

- Fix A (failure visibility): stop silencing stderr on the five docker
  compose pull sites so timeouts / auth failures / network errors flow
  into upgrade-watcher.log. Add explicit SIGTERM/SIGINT traps alongside
  the existing EXIT trap. Track CURRENT_PHASE_NAME globally so the
  failure message reports "during Phase 4: Container Rebuild" rather
  than just an exit code. Introduce write_result_force (bypasses
  API_MODE guard) + archive_failure_to_history so a killed upgrade
  always leaves a truthful result.json + history.json entry, and the
  progress.json is cleared so the admin UI stops showing a phantom
  in-progress phase.

- Fix B (atomic VERSION): Phase 3 rsync now --excludes VERSION and
  stashes the new one at data/upgrade/VERSION.pending. Phase 7 promotes
  it to VERSION only after VERIFY_FAILED stays false. on_failure deletes
  the pending file. upgrade-check.sh needs no changes — its head -1
  VERSION read sees actual state instead of a mid-upgrade promise.

- Fix C (external smoke): after Phase 7 localhost checks, curl
  https://api.${DOMAIN}/api/health with --max-time 10 and warn (not
  fail) on non-200. Catches Pangolin resource misassignments that the
  localhost-only checks miss. Appends to UPGRADE_WARNINGS so the admin
  UI surfaces it in result.json.

Bunker Admin
2026-04-15 16:13:04 -06:00
12708e5824 Bump upgrade watcher TimeoutStartSec 900s → 3600s
Admin-UI-triggered v2.9.2 → v2.9.3 upgrade on marcelle was killed by
systemd at 15min mid `docker compose pull` because Gitea registry
pulls over the Pangolin tunnel routinely exceed that budget on a
cold cache. 1hr gives enough headroom for slow pulls without
hiding genuinely hung upgrades.

Bunker Admin
2026-04-15 15:40:59 -06:00
23df6a8b52 Fresh-install + upgrade-path hardening bundle
Six independent fixes surfaced during the v2.9.1 → v2.9.2 admin-UI
upgrade validation today. Together they make a clean install on a new
box work end-to-end without in-session patching.

- Fix 1: scripts/validate-compose-parity.sh + build-release.sh hook —
  fail release builds when api/admin/media-api/nginx healthcheck
  blocks drift between docker-compose.yml and docker-compose.prod.yml.
  Previous boot-race fix had to be applied to both files manually.

- Fix 2: scripts/systemd/install.sh chowns logs/ to the install user
  (the API container creates subdirs there as root, locking the
  host-side watcher out), pre-creates logs/upgrade-watcher.log, and
  changemaker-upgrade.service adds StartLimitIntervalSec=0 so a
  single transient failure can't wedge the .path unit permanently.

- Fix 3: /api/upgrade/status now returns a `watcher` sub-object that
  flags the host systemd watcher as stalled when trigger.json has
  been pending >30s. Admin SettingsPage SystemUpgradeTab renders a
  warning Alert with the systemctl recovery command when unhealthy.

- Fix 4: scripts/upgrade.sh write_result() — prefer head -1 VERSION
  over `git rev-parse HEAD` so release-mode upgrades report the new
  tag in result.json instead of "unknown".

- Fix 5: admin container healthcheck start_period 20s → 60s in both
  compose files, same class as the earlier api fix. Matches Gancio
  convention.

- Fix 7: /api/pangolin/sync now detects resources bound to a stale
  siteId (common after --pangolin-site new rotations), deletes and
  recreates them against the current site, and reports them under
  a new `reassigned` response field.

Bunker Admin
2026-04-15 11:57:50 -06:00
5115c65691 Fix nginx/newt boot race by raising API healthcheck start_period to 120s
On a fresh install, the api container's docker-entrypoint.sh does
migrations + seed + MaxMind GeoLite2 download synchronously before
binding port 4000, which routinely exceeds the previous 75s tolerance
(start_period 30s + 3x15s retries). When the API flipped to unhealthy,
nginx and newt (which depend on api: condition: service_healthy)
aborted, leaving the tunnel offline and requiring a manual
'docker compose up -d nginx newt' second pass.

Bumping start_period to 120s gives first-boot enough slack, matches
convention (Rocket.Chat 90s, Gancio 60s), and leaves steady-state
restarts unaffected. Change applied to both dev (docker-compose.yml)
and prod (docker-compose.prod.yml) since the release tarball ships
the latter.

Bunker Admin
2026-04-15 10:05:20 -06:00
e55bc07eb6 Security hardening: red-team remediation + CCP/WIP updates
## Security (red-team audit 2026-04-12)

Public data exposure (P0):
- Public map converted to server-side heatmap, 2-decimal (~1.1km) bucketing,
  no addresses/support-levels/sign-info returned
- Petition signers endpoint strips displayName/signerComment/geoCity/geoCountry
- Petition public-stats drops recentSigners entirely
- Response wall strips userComment + submittedByName
- Campaign createdByUserEmail + moderation fields gated to SUPER_ADMIN

Access control (P1):
- Campaign findById/update/delete/email-stats enforce owner === req.user.id
  (SUPER_ADMIN bypasses), return 404 to avoid enumeration
- GPS tracking session route restricted to session owner or SUPER_ADMIN
- Canvass volunteer stats restricted to self or SUPER_ADMIN
- People household endpoints restricted to INFLUENCE + MAP roles (was ADMIN*)
- CCP upgrade.service.ts + certificate.service.ts gate user-controlled
  shell inputs (branch, path, slug, SAN hostname) behind regex validators

Token security (P2):
- Query-param JWT auth replaced with HMAC-signed short-lived URLs
  (utils/signed-url.ts + /api/media/sign endpoint); legacy ?token= removed
  from media streaming, photos, chat-notifications, and social SSE
- GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT now REQUIRED (min 32 chars);
  JWT_ACCESS_SECRET fallback removed — BREAKING for existing deployments
- Refresh tokens bound to device fingerprint (UA + /24 IP) via `df` JWT
  claim; mismatch revokes all user sessions
- Refresh expiry reduced 7d → 24h
- Refresh/logout via request body removed — httpOnly cookie only
- Password-reset + verification-resend rate limits now keyed on (IP, email)
  composite to prevent both IP rotation and email enumeration

Defense-in-depth (P3):
- DOMPurify sanitization applied to GrapesJS landing page HTML/CSS
- /api/health?detailed=true disk-space leak removed
- Password-reset/verification token log lines no longer include userId

## Deployment

- docker-compose.yml + docker-compose.prod.yml: media-api now receives
  GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT; empty fallbacks removed
- CCP templates/env.hbs adds both new secrets; refresh expiry → 24h
- CCP secret-generator.ts generates giteaSsoSecret + servicePasswordSalt
- leaflet.heat added to admin/package.json for heatmap rendering

## Operator action required on existing installs

Run `./config.sh` once (idempotent — only fills empty values) or manually
add GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT to .env via
`openssl rand -hex 32`. Startup fails with a clear Zod error otherwise.

See SECURITY_REDTEAM_2026-04-12.md for full audit and verification matrix.

## Other

Includes in-flight CCP work: instance schema tweaks, agent server updates,
health service, tunnel service, DEV_WORKFLOW doc updates, and new migration
dropping composeProject uniqueness.

Bunker Admin
2026-04-12 15:17:00 -06:00
26ec925d9b CCP restore/tunnel/upgrade + upgrade.sh release-mode fixes + volunteer dashboard polish
- Add instance restore model, routes, and agent backup/restore endpoints
- Add Pangolin tunnel service (subdomain prefix, teardown action, CCP client)
- Add slug mutex for concurrent operation safety in agent
- Expand upgrade service with remote driver orchestration
- Fix upgrade.sh to properly handle release-mode installs (no git operations)
- Add CCP registration flags to config.sh (--ccp-url, --ccp-invite-code, --ccp-agent-url)
- Auto-detect JVB advertise IP in non-interactive mode
- Polish volunteer dashboard ActionStepsList with highlighted step component
- Add ticketed event description field + volunteer dashboard query refinements

Bunker Admin
2026-04-12 11:09:46 -06:00
29d1f3998a Visual polish of volunteer dashboard components to match FAFC reference
TakeActionCard: replaced standard Card with a bold red gradient panel
(linear-gradient #e74c3c → #c0392b) with white text, uppercase header,
and a white CTA button — matches the FAFC "Take Action" visual weight.

ActionCampaignCard: tightened padding, thicker progress bar (10px),
compact card header with gold trophy icon, and a "Next:" hint with
the upcoming step label separated by a subtle divider.

ActionStepsList: replaced Antd List with manual flex rows for tighter
control. Each row shows a kind prefix label (Email:, Sign:, RSVP:)
in muted text above the step label. "Take Action" buttons are type=link
for visual lightness. Completed steps show a compact "Done" tag.

TrainingList: added count badge ("2 Upcoming" in green) in the card
header extra slot. Each row uses a format tag ("In Person" / "Virtual")
matching FAFC's colored badges. RSVP buttons use danger variant (red)
to match FAFC's prominent red RSVP buttons.

ActivityCard: centered hero layout with large gold point number and
"points earned" subtitle. Achievement count only shown when > 0,
separated by a subtle divider.

MyEventsCard: consistent row layout with flex instead of Antd List.
Details button uses type=link for visual consistency with the steps
list.

ResourcesGrid: renamed to "Tools & Resources". Resource cards have
taller thumbnails (120px), cleaner padding, removed the type label
to reduce clutter — the icon in the placeholder already communicates
the type.

All cards now use consistent header styling (14px bold, 12px padding,
subtle 6% white bottom border) for visual rhythm across the page.

Bunker Admin
2026-04-11 21:35:58 -06:00
054902b9f9 Restructure volunteer dashboard to FAFC two-column layout
Matches the For Alberta For Canada reference: profile + goal progress
on the left, take-action CTA + action steps on the right. Specifics:

- ProfileCard now embeds the referral link (copy button + code) below
  the stats row, eliminating the standalone ReferralCard from the grid.

- ActionCampaignCard slimmed to progress-only: title, description,
  progress bar, reward text, and a "Next: {step}" hint. The steps
  list is extracted into a new ActionStepsList component.

- ActionStepsList renders as a compact card with kind-prefixed labels
  (Email:, Sign:, RSVP:) and Take Action / Mark done buttons, matching
  the FAFC action list on the right column.

- Dashboard page uses lg=12/12 for the top two-column section. Falls
  back to full-width single column when no campaign and no featured
  event exist. Mobile stacks vertically as before. The 3-up middle
  row (trainings / my events / activity) and full-width resources grid
  remain unchanged.

Bunker Admin
2026-04-11 21:27:58 -06:00
82db26fcef Auto-mint referral codes + action-based points for volunteer dashboard
Referral: if the user has no active invite code when the dashboard
loads, one is auto-created via referralService.createInviteCode.
Every volunteer now sees a ready-to-share referral link on first
visit without needing to manually create one.

Points: replaced the media-engagement placeholder (videos watched +
upvotes + comments) with a weighted tally of actual volunteer actions:
shift signups (10pts), influence campaign emails (5pts), verified
petition signatures (5pts), event tickets (10pts), action step
completions (5pts), and achievements (15pts). This reflects
organizing work rather than gallery browsing.

Bunker Admin
2026-04-11 21:15:36 -06:00
df65b1b72e Add documents volume mount + harden compose health checks
Mount ${MEDIA_ROOT}/local/documents:/media/local/documents:rw on the
media-api service so the document upload route can write PDFs to
disk. Without this mount, uploads fail on fresh deployments.

Also hardens several depends_on entries from service_started to
service_healthy (redis for media-api, api for admin, listmonk-app
for listmonk-init, gitea-db for gitea, nginx for newt) so
containers wait for actual readiness, not just process start.

Bunker Admin
2026-04-11 21:05:01 -06:00
80321f04e7 Link event staffing shifts to ticketed events with auto pre-fill
Shift.ticketedEventId (nullable FK to TicketedEvent) persists the
link between a staffing shift and its parent event. The shift list
response now includes the linked event (id, slug, title, date) so
the admin UI can show context without a second request.

The Create/Edit Shift drawer gains a conditional Event picker that
only appears when kind is EVENT_STAFFING. Picking an event pre-fills
the form's date, startTime, endTime, and location (venue name +
address) — and seeds the title with "Staff: {event.title}" only if
the title field is still empty, so hand-typed overrides aren't
stomped. Switching kind away from EVENT_STAFFING clears the link.

The shifts table's Kind tag wraps in a tooltip showing the linked
event title when one is present, so organizers can see at a glance
which staffing shifts belong to which event without opening the
drawer.

Bunker Admin
2026-04-11 11:45:15 -06:00
96ff2a85d6 Expose ShiftKind in admin panel with Dropdown.Button picker
Shift.kind existed in the schema and on the volunteer dashboard's
training filter but there was no way to create or edit a shift's
kind from the admin UI — every shift landed as the default CANVASS.
This wires the full loop:

Backend: createShiftSchema / updateShiftSchema / listShiftsSchema
and their series counterparts now accept a kind field. The shifts
service passes it through on create and filters by it on list.
Series shift templates propagate kind to every generated shift
instance so a training series produces training shifts.

Admin UI: the Create Shift button becomes a Dropdown.Button. The
main action creates a Canvass shift (default); the menu offers
Training, Event Staffing, Phone Bank, and Other. Each menu item
pre-fills the form's kind field. A kind Select appears at the top
of the form so admins can change it mid-creation or on edit. The
shifts table gets a color-coded Kind column and the toolbar gets
a kind filter.

Bunker Admin
2026-04-11 11:09:23 -06:00
76fd3c7065 Fix action-campaigns list shape + widen volunteer dashboard on desktop
The admin list endpoint returned a bare array but the frontend list
page expected { items: [] }. Newly-created campaigns were saving
successfully but rendering as an empty list. Wrap the response to
match the contract that the frontend was already coded against.

VolunteerLayout capped content at 800px wide, which suits the
phone-first volunteer tool pages but left most of the desktop screen
empty on the new dashboard. Bump the cap to 1280px for /volunteer
only so other volunteer pages keep their narrow sizing.

Bunker Admin
2026-04-11 10:37:40 -06:00
c00b4432d7 Add volunteer dashboard page + ActionCampaigns admin editor
VolunteerDashboardPage replaces the old direct-to-map landing at
/volunteer with a personalized action hub modeled on the For Alberta
For Canada layout: profile + referral on top, action campaign goal
tile next to a featured event CTA, training shifts + my events +
points + resources. The map moves to /volunteer/map as a fullscreen
route outside VolunteerLayout. CutRedirect updated to match.

VolunteerFooterNav and VolunteerLayout drawer get Home/Map split
tabs. AppLayout sidebar gets an Action Campaigns link under the
Advocacy menu.

ActionCampaignsPage lists campaigns; ActionCampaignEditorPage edits
metadata + steps with type-aware target pickers per ActionStepKind
(video picker, petition picker, ticketed-event picker, etc).
CUSTOM/VISIT_LINK steps get a free-form target URL. Reorder via
up/down buttons.

Bunker Admin
2026-04-11 10:21:10 -06:00
ae5a90d8d4 Add Document model upload + download routes (PDFs as first-class media)
Documents are a separate media type from Video/Photo because the
Photo pipeline assumes raster images (sharp metadata, EXIF, variant
generation). The new routes mirror the photo upload pattern but
target the Document Prisma model and serve files with
Content-Disposition: attachment so browsers download instead of
inline-rendering. Tag-based categorization (e.g. 'volunteer-resource')
lets the volunteer dashboard surface curated downloads alongside
videos and photos.

Admin Library page gets a Documents tab for upload/list/edit/delete
with the same affordances as the existing photo and video tabs.

Bunker Admin
2026-04-11 10:20:54 -06:00
ed011a762b Add action-campaigns module + volunteer-dashboard aggregator
ActionCampaign service exposes admin CRUD plus a per-user composer
(getActiveForUser) that fans out a per-step completion check against
the existing per-user model for each ActionStepKind: VideoView for
WATCH_VIDEO, CampaignEmail for SUBMIT_INFLUENCE, PetitionSignature
for SIGN_PETITION (matched by signer email), Ticket for RSVP_EVENT,
ShiftSignup for SIGNUP_SHIFT, ChallengeTeamMember for JOIN_CHALLENGE.
CUSTOM and VISIT_LINK steps complete only via explicit self-report.
An existing ActionStepCompletion row also short-circuits the check
so manual marking and idempotency both work.

Volunteer dashboard aggregator at GET /api/volunteer/dashboard
composes the active campaign with the user's profile, referral info,
upcoming featured event, training shifts (Shift.kind='TRAINING'),
ticketed events the user holds, an engagement-counter point total
(placeholder until the Redis engagement score is wired in), and
resources tagged 'volunteer-resource' across Document/Video/Photo.

Bunker Admin
2026-04-11 10:20:26 -06:00
3fc67cd81a Add ActionCampaign + Document models for volunteer dashboard
Foundation schema for the FAFC-style volunteer dashboard. Adds:
- ActionCampaign / ActionStep / ActionStepCompletion (stacked-action
  mini-campaigns where steps reference existing entities like videos,
  petitions, ticketed events; completion is detected at query time
  against the per-user model for each step kind)
- Document model for downloadable resources (PDFs etc.) — Photo's
  EXIF/sharp pipeline can't host non-image files
- Shift.kind discriminator (ShiftKind enum) so training shifts can
  surface separately on the dashboard
- TicketedEvent.featured for the "Take Action" CTA tile

Bunker Admin
2026-04-10 21:26:56 -06:00
5f0ae6bc5a Revert NocoDB auto sign-in, keep CSP fix for embed proxy
NocoDB v2 stores auth tokens in-memory (Pinia store), not in cookies
accessible to external pages. The auth bridge approach can't inject
tokens into NocoDB's SPA state. Reverted to the original banner
approach ("sign in to NocoDB in a new tab").

Kept: CSP fix (frame-ancestors http://localhost:* instead of just
localhost, which only matched port 80).

Bunker Admin
2026-04-09 14:01:02 -06:00
aa69048024 Fix Gitea init: must-change-password flag syntax + auth check
- Use --must-change-password=false (equals syntax) in gitea CLI
  The space-separated form was parsed as boolean flag + extra arg
- Fix auto-setup readiness check: use Basic Auth instead of
  unauthenticated /version endpoint (blocked by REQUIRE_SIGNIN_VIEW)
- Increase retries to 8 to accommodate gitea-init container startup

Bunker Admin
2026-04-09 13:32:44 -06:00
c180bb5ace Rework Gitea init to use separate init container pattern
The previous approach (custom CMD on gitea-app) failed because:
- Gitea's entrypoint generates app.ini as root, then drops to git user
- Overriding CMD ran our script before app.ini was generated
- su-exec to git user lost access to the entrypoint-generated config

Now uses the same pattern as nocodb-init: a separate container that
depends on gitea-app being healthy, shares the gitea-data volume
(which has app.ini), and runs gitea admin user create.

Bunker Admin
2026-04-09 13:25:56 -06:00
c5209887cc Fix gitea-init.sh running as root — drop to git user via su-exec
The Gitea Docker entrypoint sets up directories as root then exec's
the CMD still as root. Gitea refuses to run as root, so our init
script must re-exec itself as the 'git' user via su-exec before
running any gitea commands.

Bunker Admin
2026-04-09 13:14:48 -06:00
ca446136a1 Fix set -e crash in pangolin_create_resources arithmetic
((created++)) returns exit code 1 when created=0 (post-increment
evaluates to 0, which is falsy), killing the script under set -e.
Use x=$((x + 1)) instead.

Bunker Admin
2026-04-09 13:03:12 -06:00
0510420772 Fix pangolin_create_site blocking on read in non-interactive mode
The site name prompt used read -rp which blocks when stdin is piped.
Now uses default name automatically when NON_INTERACTIVE=true.

Bunker Admin
2026-04-09 12:57:54 -06:00
36b709b911 Automate Gitea init, NocoDB auto sign-in, and fix prod compose
- Add scripts/gitea-init.sh: runs migrations + creates admin user on
  first boot, replacing the manual installation wizard
- Set GITEA__security__INSTALL_LOCK=true in both compose files
- Add NocoDB auth bridge (nginx) + /api/services/nocodb-auth proxy
  endpoint so the admin iframe auto-authenticates
- Update NocoDBPage.tsx to fetch token and use auth bridge flow
- Fix docker-compose.prod.yml missing Gitea env vars for API container
  (GITEA_URL, GITEA_API_TOKEN, GITEA_ADMIN_PASSWORD, etc.)
- Pass NC_ADMIN_EMAIL/PASSWORD to API for NocoDB auth proxy
- Increase Gitea auto-setup retries from 3 to 6 with admin auth check
- Update config.sh non-interactive mode to set GITEA_ADMIN_USER
- Include gitea-init.sh in release tarball (build-release.sh)

Bunker Admin
2026-04-09 12:49:33 -06:00
0a8e1fe46b Remove deployment report from repo (moved to gitignored docs/)
Bunker Admin
2026-04-09 12:08:15 -06:00
f8c8a939d7 Add full non-interactive mode to config.sh
New CLI flags for scripted deployments:
  --smtp-host/port/user/pass   Production SMTP configuration
  --pangolin-api-url/key/org-id/endpoint/site  Full Pangolin tunnel setup
  --mapbox-key                 Mapbox API key
  --maxmind-account-id/license-key  MaxMind GeoIP credentials

With --pangolin-site=new, config.sh creates a Pangolin site, fetches
Newt credentials, and creates all resources+targets automatically.
With --pangolin-site=existing, it connects to the first available site.

Bunker Admin
2026-04-09 12:08:05 -06:00
bca4cb8227 Fix Pangolin site creation: omit address field from payload
The clientAddress from pickSiteDefaults lacks CIDR notation and gets
rejected by the Pangolin API. Omitting it lets Pangolin auto-assign.

Bunker Admin
2026-04-09 11:58:14 -06:00
f0d994074d Update admin modals and page components for mobile responsiveness
Bunker Admin
2026-04-09 11:43:23 -06:00
849dea7ce2 Fix config.sh Pangolin setup and MongoDB init for fresh deployments
- Fix Pangolin endpoint: ask separately from API URL (different hostnames)
- Add pangolin_create_resources() to create resources + targets during setup
- Set all resources as public (no SSO/blockAccess) automatically
- Fix API health check URL to use actual org endpoint
- Fix MongoDB entrypoint: delegate to docker-entrypoint.sh so INITDB user
  creation works on fresh volumes (was bypassing Docker's init sequence)

Bunker Admin
2026-04-09 11:43:13 -06:00
72dbd0189c Pass GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT to API container
These env vars were defined in .env but never mapped into the API
container's environment block, causing silent fallback to
JWT_ACCESS_SECRET and security warnings on startup.

Bunker Admin
2026-04-09 09:26:51 -06:00
0b0c33cfee Add ccp-agent to build pipeline and fix registry image name
- Added ccp-agent as 5th service in build-and-push.sh (builds from
  changemaker-control-panel/agent/Dockerfile)
- Fixed prod compose image name to match registry convention:
  changemaker-ccp-agent (consistent with changemaker-api, etc.)

Bunker Admin
2026-04-08 16:12:53 -06:00
c6f8a49925 Add CCP registration page to CML admin panel
Operators can now register with a Control Panel directly from the admin
GUI (Services → Control Panel) without SSH access. Uses the existing
updateEnvFile + dockerService pattern from the Pangolin setup.

New endpoint: /api/ccp-registration (status, register, unregister)
New page: ControlPanelPage with form for CCP URL, invite code, agent URL
Also passes CCP env vars through docker-compose to the API container.

Bunker Admin
2026-04-08 15:13:28 -06:00
215da79284 Add register-with-ccp.sh for existing installations
Interactive script that configures .env, adds ccp-agent to COMPOSE_PROFILES,
and starts the agent container. Supports --ccp-url, --invite-code, --agent-url
flags for non-interactive use, and --unregister to remove registration.

Bunker Admin
2026-04-08 15:04:38 -06:00
145ba4268f Update DEV_WORKFLOW.md with Gitea token docs and release tag checking
Bunker Admin
2026-04-07 17:26:02 -06:00
d010993994 Add pagination to public endpoints, Pangolin site picker, and docs editor toolbar
- Paginate public APIs: campaigns, petitions, shifts, products, pages, shop
- Add safety caps (take limits) to gallery ads, cuts, plans, donation pages
- Add Pangolin connect-site endpoint with .env writer and site ID validation
- Add formatting toolbar + keyboard shortcuts to shared doc editor
- Fix Dockerfile to support su-exec privilege dropping for mounted volumes
- Fix duplicate WebSocket headers in nginx API location block
- Update MkDocs site build and social card assets

Bunker Admin
2026-04-07 16:50:20 -06:00
513b8cfea5 Add openssl to CCP API container for certificate generation
The certificate service uses openssl CLI to generate CA and agent certs.
Alpine base image doesn't include it by default.

Bunker Admin
2026-04-07 15:41:17 -06:00
38ccaa8a5b Add remote instance management with mTLS agent and phone-home registration
Enables the CCP to manage CML instances on remote servers via a lightweight
HTTP agent. Key components:

- ExecutionDriver abstraction (local-driver.ts / remote-driver.ts) routes
  operations to local Docker or remote agent transparently
- Remote agent package (agent/) with mTLS authentication, Docker Compose
  operations, file management, backup/upgrade delegation
- Certificate service using openssl CLI for CA management and cert issuance
- Phone-home registration: remote agents register via invite code, CCP admin
  approves, agent receives mTLS cert bundle automatically
- config.sh integration with configure_control_panel() section
- ccp-agent Docker Compose service (profile-gated)
- Frontend: AgentRegistrationsPage, InviteCodesPage, Remote Agents sidebar menu
- Security hardened: cert bundle wiped after delivery, shell injection prevention
  via execFile, command allowlist with metachar rejection, rate-limited public
  endpoints, auto-populated fingerprint pinning

Also wires ENABLE_SOCIAL/PEOPLE/ANALYTICS through env.ts, seed.ts, and
docker-compose env passthrough (from previous session).

Bunker Admin
2026-04-07 15:24:33 -06:00
d17e197a1b Fix Vite allowedHosts blocking production domains
Set allowedHosts to true since nginx handles Host header
validation. The previous `.${domain}` pattern used the
build-time DOMAIN value, which breaks when the same image
is deployed to a different domain.

Bunker Admin
2026-04-07 14:21:16 -06:00
cbfa4f9e28 Add uninstall.sh and test-deployment.sh to release tarball
Bunker Admin
2026-04-07 14:14:45 -06:00
530551f568 Fix deployment issues found during end-to-end testing
- install.sh: Use tar --strip-components=1 instead of mv for robust
  extraction when install dir partially exists (root-owned Docker
  artifacts)
- config.sh: Add --non-interactive mode (--domain, --admin-password,
  --enable-all flags) for CI/CD and automated deployments
- docker-entrypoint.sh: Validate critical env vars on startup, fail
  early with clear messages instead of silent failures
- docker-compose.yml: Change Redis eviction policy from allkeys-lru
  to noeviction (required by BullMQ job queues)
- Prisma: Add missing petitions.coverVideoId migration (schema had
  the column but migration omitted it, causing 500 on public endpoint)
- Add scripts/uninstall.sh for clean removal including root-owned files
- Add scripts/test-deployment.sh for automated post-install verification

Bunker Admin
2026-04-07 14:06:05 -06:00
74e5fa6475 Clean up obsolete files and refresh MkDocs site
Remove unused planning docs (FEDERATION_PLAN.md, SERVICE_INTEGRATIONS.md),
temporary screenshots, update .gitignore for Playwright MCP logs, and
refresh MkDocs site build with updated repo data.

Bunker Admin
2026-04-03 08:52:15 -06:00
72622671a2 Add petition/action pages with signature collection, CRM integration, and campaign linking
New influence submodule for public petitions with configurable sign forms,
email verification, GeoIP tracking, dedup, CSV export, admin moderation,
and post-sign CTA linking to advocacy campaigns. Includes competitive
analysis document covering 30+ campaign tech platforms.

Bunker Admin
2026-04-03 08:49:49 -06:00
08bd1f92b0 Add unified analytics system with GeoIP geo-tracking
Full analytics platform with MaxMind GeoLite2 IP-to-location resolution,
cross-module dashboard (docs, video, photo), user drill-down, volunteer
self-service stats, and ANALYTICS_ADMIN role with feature flag controls.

- ANALYTICS_ADMIN role + ANALYTICS_ROLES group across backend and frontend
- GeoIP service (MaxMind GeoLite2, lazy-loaded, graceful degradation)
- Geo fields (country, region, city, lat/lng) on DocsPageView, VideoView, PhotoView
- IP resolved to geo before SHA-256 hashing (privacy-preserving)
- Unified analytics module: overview, geo, content, user engagement endpoints
- 4 admin dashboard pages: Overview, Geography (Leaflet map), Content, Users
- Volunteer MyAnalyticsPage for self-service activity stats
- Settings UI: enableAnalytics, analyticsGeoEnabled, trackAuthenticatedUsers, retentionDays
- Scheduled cleanup job respecting configurable retention period
- config.sh: Analytics + MaxMind prompt in configure_features()
- Control panel: enableAnalytics flag, template, discovery, wizard, detail page
- Docker: geoip volume mount, MaxMind env vars, entrypoint auto-download
- Nginx: X-Forwarded-For fix ($proxy_add_x_forwarded_for) for real client IP
- Express trust proxy set to 2 for Pangolin/Newt tunnel chain
- CORS updated for docs origin (cmlite.org + docs.cmlite.org)
- Lander page: added docs-analytics tracking snippet
- Prisma migration: 20260402100000_add_analytics_system

Bunker Admin
2026-04-03 08:47:44 -06:00
0a20444a74 Archive addition 2026-04-02 15:14:27 -06:00
610f547dbf Fix dashboard mobile layout: header overflow, welcome banner, and stats grid
Hide nav icon bar and volunteer button on mobile (accessible via drawer),
stack welcome banner vertically with proper nowrap, replace status bar
flex row with 3-column CSS grid using MobileQuickStat component.

Bunker Admin
2026-04-02 15:12:27 -06:00
6db44eadc6 Fix mobile layout shift from typewriter text wrapping in hero section
Bunker Admin
2026-04-02 15:12:25 -06:00
5a0c4641a1 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
2026-03-31 18:30:17 -06:00
d7ab8f0d99 Add file move capabilities to docs editor file tree
Drag-and-drop tree nodes to move files/folders between directories (desktop),
and right-click "Move to..." with searchable directory picker modal (desktop + mobile).

Bunker Admin
2026-03-31 13:44:03 -06:00
c306e061ab Generate GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT in config wizard
New installs now get dedicated secrets for Gitea SSO cookie signing and
service password derivation, rather than falling back to JWT_ACCESS_SECRET.
Existing installs are unaffected (update_env_var_if_empty preserves values).

Bunker Admin
2026-03-31 12:13:32 -06:00
f378db89b5 Separate local vs remote Gitea API tokens to prevent credential collision
GITEA_API_TOKEN is for the local platform Gitea (docs comments, user
provisioning, SSO). New GITEA_REGISTRY_API_TOKEN is for the remote
registry at gitea.bnkops.com (release uploads via build-release.sh).

Previously both contexts shared one variable, causing auth failures
when the token for one instance was used against the other.

Bunker Admin
2026-03-31 11:53:20 -06:00
1325 changed files with 65679 additions and 76036 deletions

View File

@ -46,20 +46,27 @@ JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32 JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32 JWT_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32
JWT_ACCESS_EXPIRY=15m JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d # Reduced from 7d → 24h on 2026-04-12 (P2-3 hardening). Combined with
# device-fingerprint binding in the JWT payload, this tightens the
# exploitation window for stolen refresh tokens.
JWT_REFRESH_EXPIRY=24h
# Encryption key for DB-stored secrets (SMTP password, etc.) # Encryption key for DB-stored secrets (SMTP password, etc.)
# REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET # REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32 ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32
# Gitea SSO cookie signing secret (separate from JWT — falls back to JWT_ACCESS_SECRET if empty) # BREAKING CHANGE (2026-04-12): both GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
# Generate with: openssl rand -hex 32 # are now REQUIRED (min 32 chars). The previous fallback to JWT_ACCESS_SECRET
GITEA_SSO_SECRET= # has been removed — a JWT leak must not compromise SSO cookies or service
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat) # account passwords. Both values must be distinct from each other and from
# Falls back to JWT_ACCESS_SECRET if empty — set a dedicated value to isolate secret rotation # all JWT_* secrets. Generate with: openssl rand -hex 32
# Generate with: openssl rand -hex 32
SERVICE_PASSWORD_SALT= # Gitea SSO cookie signing secret (required, ≥32 chars, distinct from JWT secrets)
GITEA_SSO_SECRET=GENERATE_WITH_openssl_rand_hex_32
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat).
# Required, ≥32 chars, distinct from all other secrets.
SERVICE_PASSWORD_SALT=GENERATE_WITH_openssl_rand_hex_32
# --- Initial Super Admin User (auto-created during database seeding) --- # --- Initial Super Admin User (auto-created during database seeding) ---
# These credentials are used to create the initial super admin account # These credentials are used to create the initial super admin account
@ -181,6 +188,13 @@ MEDIA_API_PORT=4100
MEDIA_API_PUBLIC_URL=http://media-api:4100 MEDIA_API_PUBLIC_URL=http://media-api:4100
# Used during admin Docker build to set the media API endpoint for Vite # Used during admin Docker build to set the media API endpoint for Vite
VITE_MEDIA_API_URL=http://changemaker-media-api:4100 VITE_MEDIA_API_URL=http://changemaker-media-api:4100
# HLS adaptive bitrate transcoding. When 'true', uploaded videos are queued
# for FFmpeg transcoding into 360p/720p/1080p HLS variants and the player
# prefers HLS over the MP4 range-request stream. When 'false' (default),
# uploads are tagged SKIPPED and the player falls back to MP4 — no DB or
# disk impact, fully reversible. The worker is always registered so existing
# PENDING jobs from a prior run still process if you flip the flag back on.
ENABLE_HLS_TRANSCODE=false
MEDIA_ROOT=/media/library MEDIA_ROOT=/media/library
MEDIA_UPLOADS=/media/uploads MEDIA_UPLOADS=/media/uploads
MAX_UPLOAD_SIZE_GB=10 MAX_UPLOAD_SIZE_GB=10
@ -212,12 +226,21 @@ COMPOSE_PROFILES=
# For docker push/pull, run: docker login gitea.bnkops.com # For docker push/pull, run: docker login gitea.bnkops.com
GITEA_REGISTRY_USER=admin GITEA_REGISTRY_USER=admin
GITEA_REGISTRY_PASS= GITEA_REGISTRY_PASS=
# API token for the REMOTE registry (gitea.bnkops.com) — used by build-release.sh --upload
# Create at: https://gitea.bnkops.com/user/settings/applications
# This is NOT the same as GITEA_API_TOKEN (which is for the local platform Gitea below)
GITEA_REGISTRY_API_TOKEN=
# --- Gitea --- # --- Gitea (Local Platform Instance) ---
GITEA_URL=http://gitea-changemaker:3000 GITEA_URL=http://gitea-changemaker:3000
GITEA_PORT=3030 GITEA_PORT=3030
GITEA_WEB_PORT=3030 GITEA_WEB_PORT=3030
GITEA_SSH_PORT=2222 GITEA_SSH_PORT=2222
# Admin user (auto-created on first boot by gitea-init.sh)
GITEA_ADMIN_USER=admin
# Leave blank to reuse INITIAL_ADMIN_PASSWORD (compose resolves the fallback).
# Set only if you want a distinct password for the Gitea admin account.
GITEA_ADMIN_PASSWORD=
GITEA_DB_TYPE=mysql GITEA_DB_TYPE=mysql
GITEA_DB_HOST=gitea-db:3306 GITEA_DB_HOST=gitea-db:3306
GITEA_DB_NAME=gitea GITEA_DB_NAME=gitea
@ -230,7 +253,9 @@ GITEA_DOMAIN=git.cmlite.org
# --- Gitea Docs Comments --- # --- Gitea Docs Comments ---
# Enable comments on MkDocs pages (backed by Gitea Issues) # Enable comments on MkDocs pages (backed by Gitea Issues)
GITEA_COMMENTS_ENABLED=false GITEA_COMMENTS_ENABLED=false
# Personal access token with repo write scope (create in Gitea → Settings → Applications) # Personal access token for the LOCAL Gitea instance (docs comments, user provisioning, SSO)
# Create at: http://localhost:3030/user/settings/applications (or https://git.DOMAIN/...)
# This is NOT the same as GITEA_REGISTRY_API_TOKEN (which is for the remote registry above)
GITEA_API_TOKEN= GITEA_API_TOKEN=
# Repository owner (Gitea username that will own the docs-comments repo) # Repository owner (Gitea username that will own the docs-comments repo)
GITEA_COMMENTS_REPO_OWNER= GITEA_COMMENTS_REPO_OWNER=
@ -263,6 +288,7 @@ MKDOCS_DOCS_PATH=/mkdocs/docs
# --- Code Server --- # --- Code Server ---
CODE_SERVER_PORT=8888 CODE_SERVER_PORT=8888
CODE_SERVER_URL=http://code-server-changemaker:8443 CODE_SERVER_URL=http://code-server-changemaker:8443
USER_NAME=coder
# --- Homepage --- # --- Homepage ---
HOMEPAGE_PORT=3010 HOMEPAGE_PORT=3010
@ -397,6 +423,26 @@ SMS_MAX_RETRIES=3
SMS_RESPONSE_SYNC_INTERVAL_MS=120000 SMS_RESPONSE_SYNC_INTERVAL_MS=120000
SMS_DEVICE_MONITOR_INTERVAL_MS=300000 SMS_DEVICE_MONITOR_INTERVAL_MS=300000
# --- Social, People & Analytics ---
# ENABLE_SOCIAL is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_SOCIAL=false
# ENABLE_PEOPLE is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_PEOPLE=false
# ENABLE_ANALYTICS is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_ANALYTICS=false
# --- Control Panel Agent ---
# Set to true to enable the CCP remote management agent
ENABLE_CCP_AGENT=false
# URL of the Changemaker Control Panel
CCP_URL=
# One-time invite code for registration
CCP_INVITE_CODE=
# How the CCP can reach this agent (must be externally accessible)
CCP_AGENT_URL=
# Agent port (default 7443)
CCP_AGENT_PORT=7443
# --- Monitoring (only used with --profile monitoring) --- # --- Monitoring (only used with --profile monitoring) ---
PROMETHEUS_PORT=9090 PROMETHEUS_PORT=9090
GRAFANA_PORT=3005 GRAFANA_PORT=3005
@ -414,3 +460,8 @@ GOTIFY_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN) INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN)
BUNKER_OPS_ENABLED=false # Enable remote metrics push to central server BUNKER_OPS_ENABLED=false # Enable remote metrics push to central server
BUNKER_OPS_REMOTE_WRITE_URL= # VictoriaMetrics remote_write endpoint (e.g., https://ops.example.com/api/v1/write) BUNKER_OPS_REMOTE_WRITE_URL= # VictoriaMetrics remote_write endpoint (e.g., https://ops.example.com/api/v1/write)
# --- GeoIP (MaxMind GeoLite2) ---
# Free account: https://www.maxmind.com/en/geolite2/signup
MAXMIND_ACCOUNT_ID= # MaxMind account ID
MAXMIND_LICENSE_KEY= # MaxMind license key (auto-downloads GeoLite2-City DB at startup)

28
.gitignore vendored
View File

@ -9,6 +9,9 @@ node_modules/
/configs/code-server/.config/* /configs/code-server/.config/*
!/configs/code-server/.config/.gitkeep !/configs/code-server/.config/.gitkeep
/configs/code-server/data/*
!/configs/code-server/data/.gitkeep
# Root assets (generated by containers) # Root assets (generated by containers)
/assets/ /assets/
@ -33,7 +36,8 @@ node_modules/
# NAR data directory (large voter registry files) # NAR data directory (large voter registry files)
/data/* /data/*
!/data/upgrade/ !/data/upgrade/
/data/upgrade/*.json /data/upgrade/*
!/data/upgrade/.gitkeep
# Media files (managed by Docker volumes, not git) # Media files (managed by Docker volumes, not git)
/media/ /media/
@ -60,13 +64,35 @@ core.*
/backups/ /backups/
.upgrade.lock .upgrade.lock
# Pre-upgrade mkdocs snapshots (created by scripts/lib/mkdocs-snapshot.sh).
# These are the tenant-content rescue archives written before every upgrade;
# discoverable in the install root via `ls`. Retention: last 5 (see helper).
/mkdocs-backup-*.tar.gz
# Release tarballs (generated by build-release.sh) # Release tarballs (generated by build-release.sh)
/releases/ /releases/
# API compiled output (generated by tsc, baked into Docker images) # API compiled output (generated by tsc, baked into Docker images)
/api/dist/ /api/dist/
# TypeScript incremental build cache (machine-specific)
*.tsbuildinfo
# Control Panel runtime data (managed deployments + backups) # Control Panel runtime data (managed deployments + backups)
/changemaker-control-panel/instances/ /changemaker-control-panel/instances/
/changemaker-control-panel/backups/ /changemaker-control-panel/backups/
logs/ logs/
# Playwright MCP browser automation logs
.playwright-mcp/
/docs
# MkDocs build cache (regenerated each build)
/mkdocs/.cache/
# Claude scheduler lock file
.claude/scheduled_tasks.lock
# Old release zip archive (no longer tracked, see chore: gitignore hygiene)
/archive/

View File

@ -1,2 +0,0 @@
[ 288ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:2287
[ 288ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,6 +0,0 @@
[ 92ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:0
[ 92ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 496039ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:0
[ 496039ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 498038ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:0
[ 498038ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,26 +0,0 @@
[ 121ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:885
[ 121ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 497669ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2201
[ 497669ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 499981ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2302
[ 499981ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 503949ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:0
[ 503949ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 506409ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2302
[ 506409ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 510957ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2302
[ 510957ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 523501ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2304
[ 523501ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 534339ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:891
[ 534339ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 536931ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:0
[ 536931ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 543415ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2312
[ 543415ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 545948ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2209
[ 545948ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 552080ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:0
[ 552080ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 554689ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2313
[ 554689ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,2 +0,0 @@
[ 101ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:2313
[ 101ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1 +0,0 @@
[ 287ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4004/favicon.ico:0

View File

@ -1,2 +0,0 @@
[ 118ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2272
[ 118ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,2 +0,0 @@
[ 49ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2101
[ 49ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,2 +0,0 @@
[ 52ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/getting-started/:2582
[ 52ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,2 +0,0 @@
[ 59ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2313
[ 59ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,2 +0,0 @@
[ 40ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2226
[ 40ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,6 +0,0 @@
[ 269ms] ReferenceError: Missing element: expected "[data-md-component=header]" to be present
at j (http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:35799)
at Ce (http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:42721)
at http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:94068
at http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:95391
[ 418ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4000/favicon.ico:0

View File

@ -1,2 +0,0 @@
[ 339ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:511
[ 339ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,2 +0,0 @@
[ 36ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2212
[ 36ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,2 +0,0 @@
[ 64ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2315
[ 64ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,2 +0,0 @@
[ 189ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:511
[ 189ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,2 +0,0 @@
[ 150ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2315
[ 151ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,14 +0,0 @@
[ 64ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:893
[ 65ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 926012ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?_=1773266458361:933
[ 926012ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 1794181ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?_=1773267326487:2359
[ 1794181ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 1857070ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?v=1773267389387:2391
[ 1857070ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 2018066ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?r=1773267550383:2406
[ 2018066ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 2115925ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?final=1773267648297:571
[ 2115925ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 2810593ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?ff=1773268342997:961
[ 2810593ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,21 +0,0 @@
[ 1411ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0
[ 11195ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/connectivity:0
[ 11196ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/services/status:0
[ 11197ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/weather:0
[ 11197ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/docs-analytics/summary?days=30:0
[ 11198ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/chat-summary:0
[ 11199ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/rocketchat-stats:0
[ 11199ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/upcoming-shifts:0
[ 11200ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/jitsi/meetings:0
[ 11201ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/influence/effectiveness/overview:0
[ 11201ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/top-videos:0
[ 11203ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/recent-signups:0
[ 11204ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/recent-comments:0
[ 11205ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/listmonk/stats:0
[ 11206ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/listmonk-campaigns:0
[ 11206ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/listmonk:0
[ 11207ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/observability/alerts:0
[ 11208ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/payments/admin/dashboard:0
[ 11209ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/gitea-activity:0
[ 11209ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/vaultwarden-adoption:0
[ 11210ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/map/canvass/analytics/cuts:0

View File

@ -1,8 +0,0 @@
[ 788ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/auth/me:0
[ 789ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/settings:0
[ 791ms] [ERROR] Unexpected auth error: AxiosError: Request failed with status code 500
at settle (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1281:12)
at XMLHttpRequest.onloadend (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1638:7)
at Axios.request (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:2255:41)
at async Object.fetchMe (http://localhost:3002/src/stores/auth.store.ts:101:28)
at async hydrate (http://localhost:3002/src/stores/auth.store.ts:118:11) @ http://localhost:3002/src/stores/auth.store.ts:105

View File

@ -1,30 +0,0 @@
[ 960624ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1920622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2880624ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 3840624ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4800623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5760623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 6720616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 7680622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 8640625ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 9600615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[10560615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[11520625ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[12480623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[13440615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[14400616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[15360616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[16320615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[17280618ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[18240616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[19200622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[20160621ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21120618ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[22080623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23040622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24000616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24960616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[25920615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[26880613ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[27840614ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[28800615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

View File

@ -1,2 +0,0 @@
[ 92ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/admin/dashboard/:1574
[ 92ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -1,44 +0,0 @@
[ 1044ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0
[ 1045ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0
[ 957294ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1915502ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2875494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 3835503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4795505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5755494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 6715495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 7675495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 8635495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 9595539ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[10555496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[11515504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[12475494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[13435504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[14395501ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[15355503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[16315505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[17275496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[18235494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[19195496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[20155502ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21115501ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[22075494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23035502ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23995496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24955494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[25915495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[26875500ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[27835504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[28795505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[29755503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[30715505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[31675500ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[32635503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[33595504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[34555501ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[35515495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[36475494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[37435493ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[38395495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[39355494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[40315488ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

View File

@ -1 +0,0 @@
[ 915ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0

View File

@ -1,442 +0,0 @@
[ 719376ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0
[ 949197ms] [ERROR] ReferenceError: MeetingAgendaPage is not defined
at App (http://localhost:3002/src/App.tsx?t=1773363079750:663:127)
at renderWithHooks (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:3520:25)
at updateFunctionComponent (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:5151:19)
at beginWork (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:5762:18)
at performUnitOfWork (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8567:18)
at workLoopSync (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8465:41)
at renderRootSync (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8449:11)
at performWorkOnRoot (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8124:44)
at performSyncWorkOnRoot (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:9134:7)
at flushSyncWorkAcrossRoots_impl (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:9042:153) @ http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:4778
[ 953711ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/src/App.tsx?t=1773363084913:0
[ 1676461ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Error during WebSocket handshake: net::ERR_CONNECTION_RESET @ http://localhost:3002/@vite/client:1034
[ 1677465ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/@vite/client:1034
[ 1678466ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/@vite/client:1034
[ 1679810ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ListmonkPage.tsx:0
[ 1679810ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/LandingPagesPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/DocsPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MkDocsSettingsPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/CodeEditorPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NocoDBPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/N8nPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/GiteaPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MailHogPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MiniQRPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ExcalidrawPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/VaultwardenPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/RocketChatPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/GancioPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/JitsiMeetPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/SettingsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NavigationSettingsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/PangolinPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ObservabilityPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/DocsAnalyticsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/DocsCommentsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/PaymentsDashboardPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/SubscribersPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/ProductsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/DonationsPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/DonationPagesPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/PlansPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/PaymentSettingsPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/LibraryPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/AnalyticsDashboardPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/MediaJobsPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/CommentModerationPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/GalleryAdsPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/AdAnalyticsDashboardPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/CampaignModerationPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/CampaignEffectivenessPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/LandingPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PagesIndexPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/EventsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/HomePage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/CampaignsListPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/CampaignPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/CreateCampaignPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyCampaignsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ResponseWallPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MapPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShiftsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaGalleryPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShortsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaViewerPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistBrowsePage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistViewerPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/PlaylistManagementPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyStatsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MySettingsPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerChatPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PricingPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShopPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ProductDetailPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlanDetailPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonatePage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonationPagesListPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PaymentSuccessPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyActivityPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerShiftsPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyRoutesPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerMapPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendsPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialProfilePage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/NotificationsPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialFeedPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/DiscoverPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/GroupDetailPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/AchievementsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/types/api.ts:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/roles.ts:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/QuickJoinPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/VerifyEmailPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ResetPasswordPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsDashboardPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsContactsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsCampaignsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsConversationsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsTemplatesPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsSetupPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/PeoplePage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ContactProfilePage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialDashboardPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialGraphPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialModerationPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ReferralAdminPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SpotlightAdminPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ChallengesAdminPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/ImpactStoriesPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ReferralsPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengesPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengeDetailPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/WallOfFamePage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MeetingJoinPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingPlannerPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingAgendaPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ActionItemsPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/SchedulingPollPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PollsListPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/JitsiAuthPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/SchedulingCalendarPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/AdminCalendarViewPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/TicketedEventsPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/EventDetailPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/CheckInScannerPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketedEventDetailPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketConfirmationPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyTicketsPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyCalendarPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarsPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarViewPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendCalendarPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NotFoundPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/command-palette/CommandPalette.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/api.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/VolunteerFooterNav.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/PublicNavBar.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useSSE.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useLocalStorage.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/service-url.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/nav-defaults.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/command-palette.store.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/favorites.store.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/menu-items.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/chat/RocketChatWidget.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaSidebar.tsx:0
[ 1679830ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaBottomNav.tsx:0
[ 1679830ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/ChatNotificationToast.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBarContext.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBar.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useChatNotifications.ts:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/color.ts:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/AuthModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/public/NewsletterSignup.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/CampaignEmailsDrawer.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/ExportContactsModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/QrCodeModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoPickerModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemGauges.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MiniDonutChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RequestTrafficChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/LatencyBandsChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerPopover.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerMemoryChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ActivityFeedCard.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TodayEventsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ChatNotifierCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TopVideosCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentCommentsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DocsAnalyticsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingShiftsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MyActionItemsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/CampaignEffectivenessCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentSignupsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/NewsletterStatsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DonationSummaryCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemAlertsCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/GiteaActivityCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/VaultwardenAdoptionCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingMeetingsCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/CutCampaignAnalyticsCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/TestEmailModal.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/VersionHistoryDrawer.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/EmailTemplateEditor.tsx:0
[ 1685249ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/settings:0
[ 1685251ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/auth/me:0
[ 1685252ms] [ERROR] Unexpected auth error: AxiosError: Request failed with status code 500
at settle (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1281:12)
at XMLHttpRequest.onloadend (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1638:7)
at Axios.request (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:2255:41)
at async Object.fetchMe (http://localhost:3002/src/stores/auth.store.ts:101:28)
at async hydrate (http://localhost:3002/src/stores/auth.store.ts:118:11) @ http://localhost:3002/src/stores/auth.store.ts:105
[ 1685344ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/payments/plans:0
[758179964ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Connection closed before receiving a handshake response @ http://localhost:3002/@vite/client:1034
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyCampaignsPage.tsx:0
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ResponseWallPage.tsx:0
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MapPage.tsx:0
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShiftsPage.tsx:0
[758181440ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaGalleryPage.tsx:0
[758181441ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShortsPage.tsx:0
[758181441ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaViewerPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistBrowsePage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistViewerPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/PlaylistManagementPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyStatsPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MySettingsPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerChatPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PricingPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShopPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ProductDetailPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlanDetailPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonatePage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonationPagesListPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PaymentSuccessPage.tsx:0
[758181442ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyActivityPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerShiftsPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyRoutesPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerMapPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendsPage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialProfilePage.tsx:0
[758181443ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/NotificationsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialFeedPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/DiscoverPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/GroupDetailPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/AchievementsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/types/api.ts:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/roles.ts:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/QuickJoinPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/VerifyEmailPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ResetPasswordPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsDashboardPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsContactsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsCampaignsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsConversationsPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsTemplatesPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsSetupPage.tsx:0
[758181444ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/PeoplePage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ContactProfilePage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialDashboardPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialGraphPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialModerationPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ReferralAdminPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SpotlightAdminPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ChallengesAdminPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/ImpactStoriesPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ReferralsPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengesPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengeDetailPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/WallOfFamePage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MeetingJoinPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingPlannerPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingAgendaPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ActionItemsPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/SchedulingPollPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PollsListPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/JitsiAuthPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/SchedulingCalendarPage.tsx:0
[758181445ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/AdminCalendarViewPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/TicketedEventsPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/EventDetailPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/CheckInScannerPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketedEventDetailPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketConfirmationPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyTicketsPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyCalendarPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarsPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarViewPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendCalendarPage.tsx:0
[758181446ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NotFoundPage.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/command-palette/CommandPalette.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/api.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/VolunteerFooterNav.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/PublicNavBar.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useSSE.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useLocalStorage.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/service-url.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/nav-defaults.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/command-palette.store.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/favorites.store.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/menu-items.ts:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/chat/RocketChatWidget.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaSidebar.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaBottomNav.tsx:0
[758181447ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/ChatNotificationToast.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBarContext.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBar.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useChatNotifications.ts:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/color.ts:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/AuthModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/public/NewsletterSignup.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/CampaignEmailsDrawer.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/ExportContactsModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/QrCodeModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoPickerModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/TestEmailModal.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/VersionHistoryDrawer.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/EmailTemplateEditor.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/map/CutEditorMap.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemGauges.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MiniDonutChart.tsx:0
[758181448ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RequestTrafficChart.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/LatencyBandsChart.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerPopover.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerMemoryChart.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ActivityFeedCard.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TodayEventsCard.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ChatNotifierCard.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TopVideosCard.tsx:0
[758181449ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentCommentsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DocsAnalyticsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingShiftsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MyActionItemsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/CampaignEffectivenessCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentSignupsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/NewsletterStatsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DonationSummaryCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemAlertsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/GiteaActivityCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/VaultwardenAdoptionCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingMeetingsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/CutCampaignAnalyticsCard.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/shifts/EditModeModal.tsx:0
[758181450ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/shifts/ShiftsCalendar.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/map/AdminMapView.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/map/AreaImportWizard.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/types/canvass.ts:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/AdminLiveMap.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/HistoricalRoutesDrawer.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/CanvassTrendsCard.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useMkDocsBuild.ts:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/landing-pages/LandingPageEditor.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useDocsEditor.ts:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/docs/MobileDocsEditor.tsx:0
[758181451ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PhotoPickerModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PhotoInsertModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/videoCardHtml.ts:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/photoCardHtml.ts:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/DonateInsertModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/ProductInsertModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AdPickerModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/scheduling/PollInsertModal.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useDocsCollaboration.ts:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/docs/CollaboratorAvatars.tsx:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/wikiLinkCompletion.ts:0
[758181452ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/docs/WikiLinkPickerModal.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/observability/ServiceStatusCard.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/observability/MetricsGrid.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/observability/AlertsTable.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/observability/IframeErrorBoundary.tsx:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/media-api.ts:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/standalone/browser/standalone-tokens.css:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/aria/aria.css:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/codeEditor/editor.css:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/scrollbar/media/scrollbars.css:0
[758181453ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/blockDecorations/blockDecorations.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/decorations/decorations.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/glyphMargin/glyphMargin.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/indentGuides/indentGuides.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/lineNumbers/lineNumbers.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/mouseCursor/mouseCursor.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/viewLines/viewLines.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/linesDecorations/linesDecorations.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/margin/margin.css:0
[758181454ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/marginDecorations/marginDecorations.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/minimap/minimap.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/overlayWidgets/overlayWidgets.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/rulers/rulers.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/selections/selections.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/viewCursors/viewCursors.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/whitespace/whitespace.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/gpu/css/media/decorationCssRuleExtractor.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/controller/editContext/native/nativeEditContext.css:0
[758181455ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/viewParts/gpuMark/gpuMark.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/platform/hover/browser/hover.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/hover/hoverWidget.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/contextview/contextview.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/selectBox/selectBox.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/list/list.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/dnd/dnd.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/selectBox/selectBoxCustom.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/actionbar/actionbar.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/dropdown/dropdown.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/platform/actions/browser/menuEntryActionViewItem.css:0
[758181456ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/standalone/browser/quickInput/standaloneQuickInput.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/toggle/toggle.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/platform/quickinput/browser/media/quickInput.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/button/button.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/countBadge/countBadge.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/progressbar/progressbar.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/inputbox/inputBox.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/findinput/findInput.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/iconLabel/iconlabel.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/keybindingLabel/keybindingLabel.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/tree/media/tree.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/sash/sash.css:0
[758181457ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/splitview/splitview.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/table/table.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/diffEditor/components/accessibleDiffViewer.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/base/browser/ui/toolbar/toolbar.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/diffEditor/style.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/markdownRenderer/browser/renderedMarkdown.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/node_modules/monaco-editor/esm/vs/editor/browser/widget/multiDiffEditor/style.css:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useDebounce.ts:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoCard.tsx:0
[758181458ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PhotoCard.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AlbumCard.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/BulkActionsBar.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PublishModal.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/DeleteConfirmModal.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/UploadVideoDrawer.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/UploadPhotoDrawer.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoViewerModal.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/PhotoViewerModal.tsx:0
[758181459ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/EditPhotoModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AlbumDetailDrawer.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/CreateAlbumModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/QuickAnalyticsModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/SchedulePublishModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/ScheduleCalendarDrawer.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/EditVideoModal.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/FetchVideosDrawer.tsx:0
[758181460ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AddToPlaylistModal.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/BulkAddToPlaylistModal.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/BulkAccessLevelModal.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/types/gallery-ads.ts:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/GalleryAdCard.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoPlayer.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/AdvancedVideoPlayer.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/DonationWidget.tsx:0
[758181462ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/PricingWidget.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/payments/ProductWidget.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/influence/CampaignFormWidget.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/scheduling/SchedulingPollWidget.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useDocumentTitle.ts:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/usePageAds.ts:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/AdBanner.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/calendar/UnifiedCalendar.tsx:0
[758181463ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/public/ShiftSignupModal.tsx:0

View File

@ -1,50 +0,0 @@
[ 840406ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1800416ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2760412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 3720412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4680414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5640416ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 6600415ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 7560413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 8520411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 9480406ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[10440412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[11400412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[12360413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[13320416ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[14280412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[15240418ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[16200413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[17160406ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[18120407ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[19080428ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[20040413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21000417ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21960412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[22920413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23880411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24840409ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[25800407ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[26760411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[27720411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[28680412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[29640407ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[30600412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[31560405ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[32520418ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[33480412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[34440414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[35400411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[36360450ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[37320412ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[38280418ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[39240414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[40200413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[41160456ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[42120417ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[43080416ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[44040414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[45000413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[45960411ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[46920413ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[47880405ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

View File

@ -1 +0,0 @@
[ 567ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0

View File

@ -1,66 +0,0 @@
[ 156566ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 159562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 162561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 165562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 168561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 171561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 174562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 177561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 180561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 183561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 186561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 189561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 192561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 195561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 198562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 201561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 204561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 207561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 210561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 213562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 216562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 219561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 222561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 225562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 228561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 231562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 234562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 237562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 240390ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/dashboard/summary:0
[ 240562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 243562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 246561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 249561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 252562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 255561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 258561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 261562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 264562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 267562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 270562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 273561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 276562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 279563ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 282561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 285562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 288562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 291562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 294562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 297561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 300562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 303561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 306562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 309561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 312562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 315561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 318561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 321561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 324561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 327561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 330562ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 333561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 336561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 339561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 342561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 345561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 348561ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0

View File

@ -1,2 +0,0 @@
[ 480462ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1440463ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

View File

@ -1,14 +0,0 @@
[ 140214ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 143212ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 146211ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 149211ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/upgrade/status:0
[ 360382ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 960378ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/dashboard/summary:0
[ 1320377ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1920379ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/dashboard/summary:0
[ 2040375ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/api/dashboard/summary:0
[ 2280391ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2400379ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/summary:0
[ 3240382ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4200393ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5160392ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

View File

@ -1,2 +0,0 @@
[ 616ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0
[ 628ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0

View File

@ -1 +0,0 @@
[ 605ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0

View File

@ -1 +0,0 @@
[ 1538ms] [ERROR] Failed to load resource: the server responded with a status of 400 (Bad Request) @ http://localhost:8091/auth/token/refresh:0

View File

@ -1 +0,0 @@
[ 32ms] [WARNING] Manifest: property 'start_url' ignored, should be same origin as document. @ data:application/json;base64,eyJuYW1lIjoiR2l0ZWE6IEdpdCB3aXRoIGEgY3VwIG9mIHRlYSIsInNob3J0X25hbWUiOiJHaXRlYTogR2l0IHdpdGggYSBjdXAgb2YgdGVhIiwic3RhcnRfdXJsIjoiaHR0cHM6Ly9naXQuY21saXRlLm9yZy8iLCJpY29ucyI6W3sic3JjIjoiaHR0cHM6Ly9naXQuY21saXRlLm9yZy9hc3NldHMvaW1nL2xvZ28ucG5nIiwidHlwZSI6ImltYWdlL3BuZyIsInNpemVzIjoiNTEyeDUxMiJ9LHsic3JjIjoiaHR0cHM6Ly9naXQuY21saXRlLm9yZy9hc3NldHMvaW1nL2xvZ28uc3ZnIiwidHlwZSI6ImltYWdlL3N2Zyt4bWwiLCJzaXplcyI6IjUxMng1MTIifV19:0

View File

@ -1 +0,0 @@
[ 871ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5678/rest/login:0

View File

@ -1 +0,0 @@
[ 343ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/favicon.ico:0

View File

@ -1 +0,0 @@
[ 238ms] [WARNING] Simple Analytics: Set hostname on localhost:8090. See https://docs.simpleanalytics.com/overwrite-domain-name @ https://scripts.simpleanalyticscdn.com/latest.js:2

View File

@ -1,5 +0,0 @@
[ 967ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/node_modules/vsda/rust/web/vsda_bg.wasm:0
[ 969ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/node_modules/vsda/rust/web/vsda.js:0
[ 1271ms] [WARNING] The web worker extension host is started in a same-origin iframe! @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:4591
[ 1304ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html?&vscodeWebWorkerExtHostId=2ab26743-4428-4df3-944e-d603e9a82c44:0
[ 2145ms] [WARNING] AI generated workspace trust dialog contents not available. @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:4552

View File

@ -1,11 +0,0 @@
[ 5ms] [ERROR] %c ERR color: #f33 [lifecycle] Long running operations during shutdown are unsupported in the web (id: join.disconnectRemote) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:37
[ 6ms] [ERROR] %c ERR color: #f33 [lifecycle] Long running operations during shutdown are unsupported in the web (id: join.chatSessionStore) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:37
[ 7ms] [ERROR] %c ERR color: #f33 [lifecycle] Long running operations during shutdown are unsupported in the web (id: join.chatEditingSession) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:37
[ 27ms] [ERROR] %c ERR color: #f33 Error creating chat editing session content folder vscode-remote:/home/coder/.local/share/code-server/User/workspaceStorage/4a334e63/chatEditingSessions/266a91b3-2e96-499a-bfc1-6d451b72bd57/contents Canceled: Canceled
at Object.call (http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:612:1374)
at http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:613:2181
at async vOt.W (http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:4587:115075)
at async vOt.createFolder (http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:4587:114875)
at async ice.storeState (http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:2978:15032)
at async Promise.all (index 0) @ http://localhost:8888/stable-13ca0e47310505f4ab1ac59e891e9a3c5e5eec04/static/out/vs/code/browser/workbench/workbench.js:37
[ 137ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:8025/api/v2/jim:0

View File

@ -1 +0,0 @@
[ 1014ms] [WARNING] GPS permission denied — enable location access in your browser settings @ http://localhost:3002/src/components/canvass/GPSTracker.tsx:32

View File

@ -1,2 +0,0 @@
[ 600748ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/social/notifications/count:0
[ 1560748ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/social/notifications/count:0

View File

@ -1,12 +0,0 @@
[ 127ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 127ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3242:41)
at async initSearch (http://localhost:4003/lander/:3307:9) @ http://localhost:4003/lander/:3254
[ 626262ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 626262ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3212:41)
at async initSearch (http://localhost:4003/lander/:3277:9) @ http://localhost:4003/lander/:3224
[ 628485ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 628485ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3212:41)
at async initSearch (http://localhost:4003/lander/:3277:9) @ http://localhost:4003/lander/:3224

View File

@ -1,60 +0,0 @@
[ 86ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 87ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3212:41)
at async initSearch (http://localhost:4003/lander/:3277:9) @ http://localhost:4003/lander/:3224
[ 426459ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 426459ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3213:41)
at async initSearch (http://localhost:4003/lander/:3278:9) @ http://localhost:4003/lander/:3225
[ 433706ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 433706ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3214:41)
at async initSearch (http://localhost:4003/lander/:3279:9) @ http://localhost:4003/lander/:3226
[ 436108ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 436108ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3214:41)
at async initSearch (http://localhost:4003/lander/:3279:9) @ http://localhost:4003/lander/:3226
[ 445396ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 445396ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3237:41)
at async initSearch (http://localhost:4003/lander/:3302:9) @ http://localhost:4003/lander/:3249
[ 447757ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 447757ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3237:41)
at async initSearch (http://localhost:4003/lander/:3302:9) @ http://localhost:4003/lander/:3249
[ 455113ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 455113ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3237:41)
at async initSearch (http://localhost:4003/lander/:3302:9) @ http://localhost:4003/lander/:3249
[ 457733ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 457733ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3237:41)
at async initSearch (http://localhost:4003/lander/:3302:9) @ http://localhost:4003/lander/:3249
[ 489124ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 489124ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3252:41)
at async initSearch (http://localhost:4003/lander/:3317:9) @ http://localhost:4003/lander/:3264
[ 491509ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 491509ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3252:41)
at async initSearch (http://localhost:4003/lander/:3317:9) @ http://localhost:4003/lander/:3264
[ 510185ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 510185ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3257:41)
at async initSearch (http://localhost:4003/lander/:3322:9) @ http://localhost:4003/lander/:3269
[ 528536ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 528536ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3262:41)
at async initSearch (http://localhost:4003/lander/:3327:9) @ http://localhost:4003/lander/:3274
[ 530976ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 530976ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3262:41)
at async initSearch (http://localhost:4003/lander/:3327:9) @ http://localhost:4003/lander/:3274
[ 541596ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 541596ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3322:41)
at async initSearch (http://localhost:4003/lander/:3387:9) @ http://localhost:4003/lander/:3334
[ 543950ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 543950ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3322:41)
at async initSearch (http://localhost:4003/lander/:3387:9) @ http://localhost:4003/lander/:3334

View File

@ -1,28 +0,0 @@
[ 96ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 97ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3322:41)
at async initSearch (http://localhost:4003/lander/:3387:9) @ http://localhost:4003/lander/:3334
[ 320202ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 320202ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3335:41)
at async initSearch (http://localhost:4003/lander/:3400:9) @ http://localhost:4003/lander/:3347
[ 329151ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 329151ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349
[ 331533ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 331533ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349
[ 628619ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 628619ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349
[ 631005ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 631005ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349
[ 633385ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4003/lander/search/search_index.json:0
[ 633385ms] [ERROR] Search init failed: Error: Failed to load search index
at MkDocsSearch.initialize (http://localhost:4003/lander/:3337:41)
at async initSearch (http://localhost:4003/lander/:3402:9) @ http://localhost:4003/lander/:3349

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

280
CLAUDE.md
View File

@ -10,15 +10,19 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
**Status Summary:** **Status Summary:**
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps) - ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
- ✅ Security Audit Complete (13 findings addressed, Feb 2026) - ✅ Drizzle to Prisma Migration Complete (single-ORM, Feb 2026)
- ✅ NAR 2025 Server Import (Canadian electoral data) - ✅ Automated Pangolin Setup (one-command tunnel deployment)
- ✅ Media Manager Integration (dual API architecture) - ✅ 3 Security Audits Complete (Feb 2025 + Mar 22/27/30 2026)
- ✅ Email Templates System - ✅ Social Connections + Calendar (friendship, shared views, availability finder)
- ✅ Data Quality Dashboard - ✅ Payments + Ticketed Events (Stripe integration, check-in scanner)
- ✅ Observability Dashboard - ✅ Meeting Planner + Straw Polls (scheduling, voting)
- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026) - ✅ SMS Campaign Connector (Termux Android bridge)
- ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026) - ✅ Docs CMS (blog authoring, access policies, collaboration, version history)
- ✅ **Migration Drift Fixed** (Baseline catch-up migration, 14 migrations cover full schema, Feb 2026) - ✅ 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 + 6 on-demand packs (~65 tools))
- 🚧 Phase 15 (Testing + Polish) - Next - 🚧 Phase 15 (Testing + Polish) - Next
--- ---
@ -59,10 +63,9 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
changemaker.lite/ changemaker.lite/
├── api/ # Dual API servers (Express + Fastify) ├── api/ # Dual API servers (Express + Fastify)
│ ├── prisma/ │ ├── prisma/
│ │ ├── schema.prisma # 30+ models: User, Campaign, Location, Shift, etc. │ │ ├── schema.prisma # 192 models: User, Campaign, Location, Shift, Payment, Social, etc.
│ │ ├── migrations/ # Prisma migration history │ │ ├── migrations/ # 50 Prisma migrations (full schema history)
│ │ └── seed.ts # Admin user, settings, page blocks │ │ └── seed.ts # Admin user, settings, page blocks
│ ├── drizzle/ # Media tables (Drizzle ORM)
│ ├── Dockerfile.media # Fastify media server container │ ├── Dockerfile.media # Fastify media server container
│ └── src/ │ └── src/
│ ├── server.ts # Express API entry point (port 4000) │ ├── server.ts # Express API entry point (port 4000)
@ -70,10 +73,10 @@ changemaker.lite/
│ ├── config/ │ ├── config/
│ │ └── env.ts # Zod-validated environment config (100+ vars) │ │ └── env.ts # Zod-validated environment config (100+ vars)
│ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler │ ├── middleware/ # auth, rbac, rate-limit, validate, error-handler
│ ├── modules/ │ ├── modules/ # 44 modules total
│ │ ├── auth/ # JWT login, register, refresh, logout │ │ ├── auth/ # JWT login, register, refresh, logout
│ │ ├── users/ # User CRUD + pagination + search │ │ ├── users/ # User CRUD + pagination + search
│ │ ├── settings/ # Site settings singleton │ │ ├── settings/ # Site settings singleton (20+ feature flags)
│ │ ├── services/ # Service health checks │ │ ├── services/ # Service health checks
│ │ ├── influence/ │ │ ├── influence/
│ │ │ ├── campaigns/ # Campaign CRUD + public routes │ │ │ ├── campaigns/ # Campaign CRUD + public routes
@ -90,16 +93,39 @@ changemaker.lite/
│ │ │ ├── canvass/ # Canvassing sessions + visits + routes │ │ │ ├── canvass/ # Canvassing sessions + visits + routes
│ │ │ ├── tracking/ # GPS tracking sessions (volunteer + admin routes) │ │ │ ├── tracking/ # GPS tracking sessions (volunteer + admin routes)
│ │ │ └── settings/ # Map settings singleton │ │ │ └── settings/ # Map settings singleton
│ │ ├── pages/ │ │ ├── pages/ # Landing page CRUD + block library + public renderer
│ │ │ ├── pages-admin.routes.ts # Landing page CRUD
│ │ │ ├── pages-public.routes.ts # Public page renderer
│ │ │ └── blocks.routes.ts # Block library API
│ │ ├── email-templates/ # Email template CRUD + rendering │ │ ├── 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 │ │ ├── listmonk/ # Newsletter sync admin routes
│ │ ├── pangolin/ # Tunnel management (Newt integration) │ │ ├── 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) │ │ ├── qr/ # QR code PNG generation (public)
│ │ ├── dashboard/ # Admin dashboard data
│ │ ├── activity/ # Activity feed
│ │ └── observability/ # Prometheus/Grafana/Alertmanager integration │ │ └── observability/ # Prometheus/Grafana/Alertmanager integration
│ ├── services/ # email, email-queue, geocode-queue, listmonk, pangolin, docker │ ├── services/ # email, email-queue, geocode-queue, listmonk, pangolin, docker
│ ├── types/ # express.d.ts (Request augmentation) │ ├── types/ # express.d.ts (Request augmentation)
@ -119,34 +145,50 @@ changemaker.lite/
│ │ ├── media/ # VideoCard, BulkActions, gallery components │ │ ├── media/ # VideoCard, BulkActions, gallery components
│ │ ├── email-templates/ # Email template components │ │ ├── email-templates/ # Email template components
│ │ └── observability/ # Monitoring components │ │ └── observability/ # Monitoring components
│ ├── pages/ │ ├── pages/ # 52 root pages + 8 subdirectories
│ │ ├── auth/ # LoginPage │ │ ├── influence/ # Campaign moderation, effectiveness, impact stories, straw polls
│ │ ├── influence/ # CampaignsPage, ResponsesPage, RepresentativesPage, EmailQueuePage │ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboard
│ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboardPage │ │ ├── media/ # Library, Playlists, Analytics, Gallery Ads, Comment Moderation
│ │ ├── volunteer/ # VolunteerMapPage, VolunteerShiftsPage, MyActivityPage, MyRoutesPage │ │ ├── payments/ # Dashboard, Products, Plans, Donations, Subscribers, Settings
│ │ ├── public/ # CampaignsListPage, CampaignPage, ResponseWallPage, MapPage, ShiftsPage, LandingPage, MediaGalleryPage, MediaViewerPage │ │ ├── social/ # Dashboard, Graph, Moderation, Referrals, Spotlights, Challenges
│ │ ├── media/ # LibraryPage, SharedMediaPage, MediaJobsPage, AnalyticsDashboardPage │ │ ├── sms/ # Dashboard, Contacts, Campaigns, Conversations, Templates, Setup
│ │ ├── services/ # MiniQRPage, MailHogPage, CodeEditorPage, N8nPage, GiteaPage, NocoDBPage │ │ ├── events/ # Ticketed Events, Event Detail, Check-in Scanner
│ │ └── (root) # DashboardPage, UsersPage, SettingsPage, CanvassDashboardPage, WalkSheetPage, CutExportPage, LandingPagesPage, PageEditorPage, EmailTemplatesPage, ListmonkPage, PangolinPage, ObservabilityPage │ │ ├── volunteer/ # Map, Shifts, Routes, Calendar, Friends, Profile, Groups, Achievements
│ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand) │ │ ├── public/ # Homepage, Campaigns, Map, Events, Media Gallery, Pricing, Donations, Meet
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios) │ │ └── (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 │ ├── hooks/ # useDebounce, useLocalStorage
│ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces) │ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces)
├── media-manager/ # Legacy media manager (reference) ├── mcp-server/ # Claude Code MCP server (27 core + 6 on-demand packs (~65 tools))
├── nginx/ # Reverse proxy config (subdomain routing + CSP) ├── nginx/ # Reverse proxy config (subdomain routing + CSP)
├── configs/ # Prometheus, Grafana, Alertmanager configs ├── configs/ # Prometheus, Grafana, Alertmanager, Pangolin configs
├── scripts/ # Deployment, backup, upgrade, registry scripts ├── scripts/ # Deployment, backup, upgrade, registry scripts
│ ├── install.sh # Curl-friendly installer (downloads tarball + runs config.sh) │ ├── install.sh # Curl-friendly installer (downloads tarball + runs config.sh)
│ ├── uninstall.sh # Remove containers, volumes, and install dir
│ ├── build-and-push.sh # Build production images → push to Gitea registry │ ├── build-and-push.sh # Build production images → push to Gitea registry
│ ├── build-release.sh # Package runtime files into release tarball │ ├── build-release.sh # Package runtime files into release tarball
│ ├── mirror-images.sh # Mirror third-party images to Gitea │ ├── mirror-images.sh # Mirror third-party images to Gitea
│ ├── upgrade.sh # 6-phase upgrade (git or release-tarball mode) │ ├── upgrade.sh # 6-phase upgrade (git or release-tarball mode)
│ ├── upgrade-check.sh # Check for updates (git or Gitea API) │ ├── upgrade-check.sh # Check for updates (git or Gitea API)
│ ├── upgrade-watcher.sh # Systemd bridge for admin GUI upgrades │ ├── upgrade-watcher.sh # Systemd bridge for admin GUI upgrades
│ └── backup.sh # PostgreSQL + Listmonk + uploads backup │ ├── update-env.sh # Merge new variables from .env.example into existing .env
├── docker-compose.yml # V2 orchestration (20+ services) │ ├── backup.sh / restore.sh # PostgreSQL + Listmonk + uploads backup/restore
├── docker-compose.v1.yml # V1 backup (reference) │ ├── validate-env.sh # Required env variable validation
│ ├── validate-compose-parity.sh # Check docker-compose.yml ↔ docker-compose.prod.yml parity
│ ├── test-deployment.sh # Post-deploy smoke tests (auth, services, health)
│ ├── register-with-ccp.sh # Register instance with a Control Panel via invite code
│ ├── ccp-deregister.sh # Deregister instance from its CCP
│ ├── pangolin-teardown.sh # Delete Pangolin resources/sites (dry-run by default)
│ ├── gitea-init.sh # Bootstrap Gitea admin user + SSO app
│ ├── nocodb-init.sh # Bootstrap NocoDB project + base connection
│ ├── mkdocs-entrypoint.sh # MkDocs container entrypoint (live + built modes)
│ ├── mkdocs-build-trigger.py # Trigger MkDocs rebuild from API hooks
│ ├── legacy/ # Archived Cloudflare tunnel configs (pre-Pangolin)
│ └── systemd/ # Systemd unit files (backup timer, upgrade watcher)
├── docker-compose.yml # V2 orchestration (40+ services)
├── docker-compose.prod.yml # Production (image-only, no source mounts)
├── .env.example # All required environment variables ├── .env.example # All required environment variables
└── V2_PLAN.md # Full 14-phase roadmap └── V2_PLAN.md # Full 14-phase roadmap
``` ```
@ -238,27 +280,35 @@ cd api && npm run dev:media
|---------|-----|---------------------| |---------|-----|---------------------|
| Admin GUI | http://localhost:3000 | See INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env | | Admin GUI | http://localhost:3000 | See INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env |
| API | http://localhost:4000 | - | | API | http://localhost:4000 | - |
| Media API | http://localhost:4100 | - |
| NocoDB | http://localhost:8091 | See `NC_ADMIN_EMAIL`/`NC_ADMIN_PASSWORD` in .env | | 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 | - | | MailHog | http://localhost:8025 | - |
| Grafana | http://localhost:3001 | admin / admin | | Grafana | http://localhost:3001 | admin / admin |
| Prometheus | http://localhost:9090 | - | | Prometheus | http://localhost:9090 | - |
| Listmonk | http://localhost:9001 | See `LISTMONK_WEB_ADMIN_USER`/`PASSWORD` in .env | | 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 ### 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 ```bash
# Media Manager # .env feature flags (env-level)
ENABLE_MEDIA_FEATURES=true ENABLE_MEDIA_FEATURES=true # Media manager
ENABLE_HLS_TRANSCODE=true # HLS adaptive bitrate transcoding (off by default)
# Listmonk Newsletter Sync ENABLE_PAYMENTS=true # Stripe integration
LISTMONK_SYNC_ENABLED=true ENABLE_SMS=true # SMS campaigns
ENABLE_CHAT=true # Rocket.Chat
# Email Test Mode (sends to MailHog instead of SMTP) ENABLE_MEET=true # Jitsi meetings
EMAIL_TEST_MODE=true 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 ## Development Commands
@ -272,7 +322,6 @@ cd api && npm run dev:media # Fastify media dev server (port 4100)
cd api && npx tsc --noEmit # Type-check cd api && npx tsc --noEmit # Type-check
cd api && npx prisma migrate dev # Run/create Prisma migrations cd api && npx prisma migrate dev # Run/create Prisma migrations
cd api && npx prisma studio # Browse database cd api && npx prisma studio # Browse database
cd api && npx drizzle-kit push # Push Drizzle schema changes (media)
``` ```
### Admin Development ### Admin Development
@ -295,7 +344,6 @@ docker compose logs -f media-api
# Database operations # Database operations
docker compose exec api npx prisma migrate dev docker compose exec api npx prisma migrate dev
docker compose exec api npx drizzle-kit push
# Stop services # Stop services
docker compose down docker compose down
@ -442,9 +490,13 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
**Files:** **Files:**
- `api/src/modules/media/` — Fastify media API (videos, reactions, jobs, analytics) - `api/src/modules/media/` — Fastify media API (videos, reactions, jobs, analytics)
- `api/src/modules/media/services/` — FFprobe, video analytics service - `api/src/modules/media/services/` — FFprobe, thumbnail, **HLS transcode** services
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload - `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload, **HLS streaming**
- `api/src/services/video-schedule-queue.service.ts` — BullMQ queue for scheduled publishing - `api/src/services/video-schedule-queue.service.ts` — BullMQ queue for scheduled publishing
- `api/src/services/hls-transcode-queue.service.ts` — BullMQ queue for HLS adaptive bitrate transcoding (concurrency 1, in-process worker)
- `api/src/modules/media/routes/hls.routes.ts` — Master/variant playlist + segment serving with signed URLs
- `api/scripts/backfill-hls.ts` — Backfill HLS for pre-existing videos (`npm run backfill:hls`)
- `admin/src/lib/use-hls.ts` — React hook attaching hls.js (Chrome/FF/Edge) or native (Safari/iOS)
- `admin/src/lib/media-api.ts` — Dedicated axios instance for Media API - `admin/src/lib/media-api.ts` — Dedicated axios instance for Media API
- `admin/src/pages/media/LibraryPage.tsx` — Video library with quick actions + calendar - `admin/src/pages/media/LibraryPage.tsx` — Video library with quick actions + calendar
- `admin/src/pages/media/AnalyticsDashboardPage.tsx` — Global analytics dashboard - `admin/src/pages/media/AnalyticsDashboardPage.tsx` — Global analytics dashboard
@ -452,7 +504,15 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
- `admin/src/pages/public/MediaGalleryPage.tsx` — Public video gallery - `admin/src/pages/public/MediaGalleryPage.tsx` — Public video gallery
- `admin/src/components/media/` — VideoCard, VideoActions, modals, charts - `admin/src/components/media/` — VideoCard, VideoActions, modals, charts
**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts **Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts, **HLS adaptive bitrate streaming (360p/720p/1080p, MP4 fallback)**.
**HLS adaptive bitrate streaming:**
- On upload, a BullMQ `hls-transcode` job runs FFmpeg to produce a master playlist + 3 keyframe-aligned variants under `/media/local/hls/{videoId}/`. Concurrency is 1; the worker runs in-process with the media-api Fastify server.
- Player prefers HLS over MP4 when `Video.hlsStatus === 'READY'`. MP4 streaming routes stay as the always-on fallback for un-transcoded videos and for hover-preview cards (where 200ms hls.js init defeats the UX — `PublicVideoCard` stays MP4).
- `useHls()` hook lazy-imports hls.js (~75 KB gzipped, never enters main bundle), uses native HLS on Safari/iOS, gives up after 2 NETWORK_ERROR retries so the MP4 fallback can kick in.
- Manifest URLs are HMAC-signed (`?sig=&exp=&uid=`) per existing `signMediaPath()` pattern. Variant playlists rewrite their segment URIs server-side at fetch time so each segment carries a fresh signature.
- Feature flag: `ENABLE_HLS_TRANSCODE` (default `false`). When off, uploads are tagged `SKIPPED` and the player falls back to MP4 — fully reversible. The worker stays registered so existing `PENDING` jobs still process if the flag flips back on.
- Backfill: `docker compose exec api npm run backfill:hls` enqueues all `hlsStatus IS NULL` videos. Bypasses the flag (operator opt-in). At ~2 min per 1080p video, throughput is ~30/hour.
**Routes:** **Routes:**
- Admin: `/app/media/library`, `/app/media/analytics`, `/app/media/shared`, `/app/media/jobs` - Admin: `/app/media/library`, `/app/media/analytics`, `/app/media/shared`, `/app/media/jobs`
@ -471,10 +531,12 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
- `api/src/services/pangolin.client.ts` — Pangolin Integration API client - `api/src/services/pangolin.client.ts` — Pangolin Integration API client
- `api/src/modules/pangolin/pangolin.routes.ts` — Tunnel management routes (includes `/setup-automated`) - `api/src/modules/pangolin/pangolin.routes.ts` — Tunnel management routes (includes `/setup-automated`)
- `admin/src/pages/PangolinPage.tsx` — Setup wizard + status dashboard + automated setup button - `admin/src/pages/PangolinPage.tsx` — Setup wizard + status dashboard + automated setup button
- `scripts/pangolin-setup.sh` — CLI wrapper for automated setup - `scripts/register-with-ccp.sh` — Register this instance with a Control Panel (CCP) using an invite code
- `scripts/pangolin-teardown.sh` — Delete all Pangolin resources/sites for an org (dry-run by default, idempotent)
- `scripts/ccp-deregister.sh` — Deregister instance from its CCP
- `configs/pangolin/resources.yml` — Central resource definitions (12 services) - `configs/pangolin/resources.yml` — Central resource definitions (12 services)
- Newt container integration (Cloudflare alternative) - Newt container integration (Cloudflare alternative)
- **Automated setup:** One-command deployment (creates site, updates .env, restarts Newt) - **Automated setup:** One-command deployment via CCP registration (creates site, updates .env, restarts Newt)
- **Continuous sync:** Hourly resource sync via nginx cron job - **Continuous sync:** Hourly resource sync via nginx cron job
**MkDocs + Code Server:** **MkDocs + Code Server:**
@ -513,20 +575,25 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
| **Core Services** | | | | **Core Services** | | |
| 3000 | Admin GUI | Vite dev / React production | | 3000 | Admin GUI | Vite dev / React production |
| 4000 | Express API | Main V2 API (Prisma) | | 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) | | 5433 | V2 PostgreSQL | Localhost (container: 5432) |
| 6379 | Redis | Cache, rate limit, BullMQ | | 6379 | Redis | Cache, rate limit, BullMQ |
| **Supporting Services** | | | | **Supporting Services** | | |
| 3001 | Grafana | Metrics visualization | | 3001 | Grafana | Metrics visualization |
| 3010 | Homepage | Service dashboard | | 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 | | 4001 | MkDocs Site | Served docs |
| 4003 | MkDocs Dev | Live preview | | 4003 | MkDocs Dev | Live preview |
| 5432 | Listmonk PostgreSQL | Listmonk DB | | 5432 | Listmonk PostgreSQL | Listmonk DB |
| 5678 | n8n | Workflow automation | | 5678 | n8n | Workflow automation |
| 8025 | MailHog | Email capture (dev) | | 8025 | MailHog | Email capture (dev) |
| 8089 | Mini QR | QR generator | | 8089 | Mini QR | QR generator |
| 8090 | Excalidraw | Collaborative whiteboard |
| 8091 | NocoDB | Data browser | | 8091 | NocoDB | Data browser |
| 8092 | Gancio | Event management |
| 8093 | Vaultwarden | Password manager |
| 8443 | Jitsi Web | Video conferencing |
| 8885 | Mini QR Proxy | Iframe-friendly | | 8885 | Mini QR Proxy | Iframe-friendly |
| 8888 | Code Server | Web IDE | | 8888 | Code Server | Web IDE |
| 9001 | Listmonk | Newsletter platform | | 9001 | Listmonk | Newsletter platform |
@ -551,11 +618,17 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
| `docs.cmlite.org` | MkDocs (4003) | Docs site | | `docs.cmlite.org` | MkDocs (4003) | Docs site |
| `code.cmlite.org` | Code Server (8888) | Web IDE | | `code.cmlite.org` | Code Server (8888) | Web IDE |
| `n8n.cmlite.org` | n8n (5678) | Workflow automation | | `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 | | `home.cmlite.org` | Homepage (3010) | Dashboard |
| `grafana.cmlite.org` | Grafana (3001) | Metrics viz | | `grafana.cmlite.org` | Grafana (3001) | Metrics viz |
| `listmonk.cmlite.org` | Listmonk (9001) | Newsletters | | `listmonk.cmlite.org` | Listmonk (9001) | Newsletters |
| `qr.cmlite.org` | Mini QR (8089) | QR generator | | `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** | | `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. **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 +637,7 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
## Common Patterns ## 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 ### API Router Structure
- Service layer (`*.service.ts`) — business logic, database queries - Service layer (`*.service.ts`) — business logic, database queries
@ -579,47 +652,57 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
- Login redirects: ADMIN_ROLES → `/app`, USER/TEMP → `/volunteer` - Login redirects: ADMIN_ROLES → `/app`, USER/TEMP → `/volunteer`
### Frontend Architecture ### Frontend Architecture
- Admin pages: `admin/src/pages/` (AppLayout) - Admin pages: `admin/src/pages/` + subdirs (AppLayout)
- Public pages: `admin/src/pages/public/` (PublicLayout, dark theme) - Public pages: `admin/src/pages/public/` (PublicLayout, dark theme)
- Volunteer pages: `admin/src/pages/volunteer/` (VolunteerLayout) - 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` - API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts`
### Database ORMs ### Database ORM
- **Prisma** (main API): Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays - **Prisma** (both APIs): 192 models in single `schema.prisma`. 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
### Prisma Migration Workflow ### 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 - **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) - **Migration history:** 50 migrations in `api/prisma/migrations/` fully cover the schema
- **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
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`) - **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 - **Prisma migrations:** Never use `db push` — always `migrate dev` to keep history in sync
- Nginx media API block must come BEFORE general API block - 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 - `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` - **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` - **`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 & Configuration
### Security Audit ### Security Audits
Comprehensive security audit completed 2025-02-11, addressing 13 findings. See `SECURITY_AUDIT_2025-02-11.md` for full report. 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) - Password policy: 12+ chars, uppercase, lowercase, digit (schema-enforced)
- Rate limits on auth endpoints (10/min per IP) - Rate limits on auth endpoints (10/min per IP) + nginx rate limiting
- Refresh token rotation (atomic transaction) - Refresh token rotation (atomic Prisma transaction)
- JWT algorithm locked to HS256, separate invite secret
- User enumeration prevention (401 not 404) - User enumeration prevention (401 not 404)
- Redis authentication required - Redis authentication required
- XSS/injection prevention (HTML escaping) - XSS/injection prevention (HTML escaping, DOMPurify, SSTI protection)
- Path traversal protection - Path traversal protection (resolve + startsWith checks)
- Encryption key for DB secrets (`ENCRYPTION_KEY` required in all environments) - 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 ### Required Environment Variables
See `.env.example` for all 100+ variables. Critical ones: See `.env.example` for all 100+ variables. Critical ones:
@ -642,8 +725,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`: When deploying to a production domain via Pangolin tunnel, you MUST update the `.env` file to include the production domain in `CORS_ORIGINS`:
```bash ```bash
# Example for betteredmonton.org # Example for cmlite.org
CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.org,http://localhost:3000,http://localhost CORS_ORIGINS=http://app.cmlite.org,https://app.cmlite.org,http://localhost:3000,http://localhost
# Also set production mode # Also set production mode
NODE_ENV=production NODE_ENV=production
@ -672,18 +755,16 @@ docker compose restart api
4. Save changes 4. Save changes
**Critical resources to fix first:** **Critical resources to fix first:**
- `api.betteredmonton.org` - Main API (all endpoints fail without this) - `api.${DOMAIN}` - Main API (all endpoints fail without this)
- `app.betteredmonton.org` - Admin GUI + public pages - `app.${DOMAIN}` - Admin GUI + public pages
- `media.betteredmonton.org` - Media API - `media.${DOMAIN}` - Media API
**Verification:** **Verification:**
```bash ```bash
# Should return JSON, NOT a 302 redirect # 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 ### CORS Errors in Production
**Symptom:** Browser console shows CORS errors when accessing production domain. **Symptom:** Browser console shows CORS errors when accessing production domain.
@ -702,29 +783,46 @@ Check in order:
### Database/Redis Connection Failures ### Database/Redis Connection Failures
Check container status (`docker compose ps`), verify credentials in `.env`, check logs (`docker compose logs <service> --tail 50`). Test DB: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"`. Test Redis: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping`. Check container status (`docker compose ps`), verify credentials in `.env`, check logs (`docker compose logs <service> --tail 50`). Test DB: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"`. Test Redis: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping`.
### Video Stuck in HLS PROCESSING / FAILED with EACCES
**Symptom:** A video shows `hlsStatus = 'PROCESSING'` for many minutes; or `'FAILED'` with `hls_transcode_error LIKE '%EACCES%'`. Player keeps falling back to MP4.
Check in order:
1. **First-run perms.** If `hls_transcode_error` contains `EACCES: permission denied, mkdir '/media/local/hls/<id>'`, the bind-mount got created as `root:root` but the Node process runs as `node` (UID 1000). One-time fix:
```
docker compose exec -u 0 media-api chown -R 1000:1000 /media/local/hls
```
Then reset and re-enqueue:
```
docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -c "UPDATE videos SET hls_status = NULL, hls_transcode_error = NULL WHERE hls_status = 'FAILED';"
docker compose exec api npm run backfill:hls
```
2. **Worker running:** `docker compose logs media-api --tail 100 | grep -i hls` — expect `[hls]` lines for the queue worker startup and per-job progress.
3. **FFmpeg in container:** `docker compose exec media-api ffmpeg -version` — should print FFmpeg version. (Already in `Dockerfile.media`.)
4. **Queue depth:** `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD LLEN bull:hls-transcode:wait` — non-zero means jobs are queued behind a slow one.
5. **Disk space at output:** `docker compose exec media-api df -h /media/local/hls` — transcoding can consume several GB per video.
6. **Failure record:** `docker compose exec api npx prisma studio` → Video table → check `hlsTranscodeError`.
To force a re-transcode of a failed video, set `hlsStatus = NULL` in the DB and run `npm run backfill:hls`.
--- ---
## V1 Reference (Legacy) ## 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: V1 code has been removed from the repo. History preserved as `v1-archive` git tag. `docker-compose.v1.yml` remains as reference only.
- `influence/README.MD` — Features, config, campaign management
- `map/README.md` — Features, config, setup instructions
- Both use session-based auth, bcryptjs passwords, Bull job queues
--- ---
## Key Configuration Files ## Key Configuration Files
### Infrastructure ### 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`) - `docker-compose.prod.yml` — Production orchestration (image-only, no source mounts, `IMAGE_TAG:-latest`)
- `.env` / `.env.example` — Environment variables (100+ vars) - `.env` / `.env.example` — Environment variables (100+ vars)
- `config.sh` — Interactive setup wizard (14 steps, release-mode aware) - `config.sh` — Interactive setup wizard (14 steps, release-mode aware)
### Database ### Database
- `api/prisma/schema.prisma` — Main schema (30+ Prisma models) - `api/prisma/schema.prisma` — Main schema (192 Prisma models)
- `api/prisma/migrations/` — 14 migration files (fully cover schema as of Feb 2026) - `api/prisma/migrations/` — 50 migration files (full schema history)
- `api/drizzle.config.ts` — Drizzle config for media tables
- `api/prisma/seed.ts` — Database seeding - `api/prisma/seed.ts` — Database seeding
### Nginx ### Nginx
@ -742,5 +840,5 @@ V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two indep
### Documentation ### Documentation
- `CLAUDE.md` — Project-wide instructions (this file) - `CLAUDE.md` — Project-wide instructions (this file)
- `V2_PLAN.md` — Full 14-phase roadmap - `V2_PLAN.md` — Full 14-phase roadmap
- `SECURITY_AUDIT_2025-02-11.md`Security audit report - `SECURITY_AUDIT_2025-02-11.md`Initial security audit report
- `MEMORY.md` — Development patterns and gotchas - `.mcp.json` — MCP server configuration for Claude Code

View File

@ -33,8 +33,9 @@ All three methods share the same Gitea container registry at `gitea.bnkops.com/a
│ BUILD & PUBLISH │ │ BUILD & PUBLISH │
│ │ │ │
│ Step 1: ./scripts/build-and-push.sh │ │ Step 1: ./scripts/build-and-push.sh │
│ Builds 4 production images, pushes to Gitea registry │ │ Builds 5 production images, pushes to Gitea registry │
│ (api, admin, media-api, nginx) tagged :SHA + :latest │ │ (api, admin, media-api, nginx, ccp-agent) │
│ tagged :SHA + :latest │
│ │ │ │
│ Step 2: ./scripts/mirror-images.sh (run once/rarely) │ │ Step 2: ./scripts/mirror-images.sh (run once/rarely) │
│ Mirrors 36 third-party images to Gitea registry │ │ Mirrors 36 third-party images to Gitea registry │
@ -43,7 +44,7 @@ All three methods share the same Gitea container registry at `gitea.bnkops.com/a
│ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │ │ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │
│ Packages runtime files into ~9MB tarball, uploads to │ │ Packages runtime files into ~9MB tarball, uploads to │
│ Gitea Releases │ │ Gitea Releases │
└──────────────────┬───────────────────────────────────────────────┘ └──────────────────┬─────────────────100.90.78.47──────────────────────────────┘
┌───────────┴───────────┐ ┌───────────┴───────────┐
▼ ▼ ▼ ▼
@ -98,7 +99,7 @@ After code changes are tested locally:
./scripts/build-and-push.sh ./scripts/build-and-push.sh
``` ```
This builds **4 services** with multi-stage Dockerfiles (production target, no dev dependencies), tags each image with `:SHA` and `:latest`, and pushes to `gitea.bnkops.com/admin/changemaker-{service}`: This builds **5 services** with multi-stage Dockerfiles (production target, no dev dependencies), tags each image with `:SHA` and `:latest`, and pushes to `gitea.bnkops.com/admin/changemaker-{service}`:
| Service | Dockerfile | What it produces | | Service | Dockerfile | What it produces |
|---------|-----------|-----------------| |---------|-----------|-----------------|
@ -106,6 +107,7 @@ This builds **4 services** with multi-stage Dockerfiles (production target, no d
| `admin` | `admin/Dockerfile` | Nginx serving React build output | | `admin` | `admin/Dockerfile` | Nginx serving React build output |
| `media-api` | `api/Dockerfile.media` | Fastify + FFmpeg (compiled JS) | | `media-api` | `api/Dockerfile.media` | Fastify + FFmpeg (compiled JS) |
| `nginx` | `nginx/Dockerfile` | Nginx with `envsubst` domain templating | | `nginx` | `nginx/Dockerfile` | Nginx with `envsubst` domain templating |
| `ccp-agent` | `../changemaker-control-panel/agent/Dockerfile` | Remote management agent (sibling repo) |
```bash ```bash
# Build specific services only # Build specific services only
@ -157,8 +159,17 @@ Packages only runtime files (~9 MB) — no source code, no node_modules:
# Preview contents without creating tarball # Preview contents without creating tarball
./scripts/build-release.sh --dry-run ./scripts/build-release.sh --dry-run
# --upload refuses to overwrite an existing tag. To deliberately replace
# a release (destructive — users on that tag see no upgrade signal):
./scripts/build-release.sh --tag v2.2.0 --upload --replace
``` ```
**Version hygiene:** bump the tag when changing release contents. Overwriting
an existing release silently breaks upgrade checks for users already on that
version — they see "no update available" even though the tarball they'd
download differs.
The tarball contains: The tarball contains:
- `docker-compose.yml` (copy of `docker-compose.prod.yml` — image-only, no build blocks) - `docker-compose.yml` (copy of `docker-compose.prod.yml` — image-only, no build blocks)
- `.env.example`, `config.sh` (configuration wizard) - `.env.example`, `config.sh` (configuration wizard)
@ -260,9 +271,10 @@ docker compose logs -f api # Watch API logs
docker compose exec api npx prisma migrate dev # Create migration docker compose exec api npx prisma migrate dev # Create migration
# ── Build & Publish ── # ── Build & Publish ──
./scripts/build-and-push.sh # Build + push 4 images ./scripts/build-and-push.sh # Build + push 5 images
./scripts/mirror-images.sh # Mirror 36 third-party images ./scripts/mirror-images.sh # Mirror 36 third-party images
./scripts/build-release.sh --tag v2.2.0 --upload # Package + upload release git tag --sort=-v:refname | head -3 # Check latest version tags
./scripts/build-release.sh --tag vX.Y.Z --upload # Package + upload release
# ── Deploy ── # ── Deploy ──
curl -fsSL .../install.sh | bash # New install (release) curl -fsSL .../install.sh | bash # New install (release)
@ -276,13 +288,46 @@ docker compose ps # Container status
--- ---
## Gitea API Tokens
There are **two separate Gitea tokens** with different purposes. Using the wrong one is a common mistake:
| Variable | Target | Used by | Create at |
|----------|--------|---------|-----------|
| `GITEA_REGISTRY_API_TOKEN` | Remote registry (`gitea.bnkops.com`) | `build-release.sh --upload`, release API calls | `https://gitea.bnkops.com/user/settings/applications` |
| `GITEA_API_TOKEN` | Local Gitea instance | Docs comments, user provisioning, SSO | `http://localhost:3030/user/settings/applications` |
**Key:** Release uploads and the Gitea Releases API require `GITEA_REGISTRY_API_TOKEN`. If you get `"user does not exist"` from the API, you're using the wrong token.
---
## Checklist: Cutting a New Release ## Checklist: Cutting a New Release
1. [ ] All code changes committed and pushed to `v2` branch 1. [ ] All code changes committed and pushed to `main` branch
2. [ ] `docker compose up -d` works locally (smoke test) 2. [ ] `docker compose up -d` works locally (smoke test)
3. [ ] `./scripts/build-and-push.sh` — builds and pushes 4 production images 3. [ ] **Determine version tag:**
4. [ ] `./scripts/mirror-images.sh` — only if third-party versions changed ```bash
5. [ ] `./scripts/build-release.sh --tag vX.Y.Z --upload` — packages and uploads tarball # Check the latest existing tag to pick the next version
6. [ ] Test clean install: `tar xzf ... && cd changemaker-lite && bash config.sh && docker compose up -d` git tag --sort=-v:refname | head -5
7. [ ] Test upgrade: `./scripts/upgrade.sh` on an existing installation # Check commits since the last tag
8. [ ] Verify: `curl http://localhost:4000/api/health` returns `{"status":"ok"}` git log $(git tag --sort=-v:refname | head -1)..HEAD --oneline
```
4. [ ] `./scripts/build-and-push.sh` — builds and pushes 5 production images
5. [ ] `./scripts/mirror-images.sh` — only if third-party versions changed
6. [ ] `./scripts/build-release.sh --tag vX.Y.Z --upload` — packages and uploads tarball
7. [ ] **Add release notes** (via Gitea web UI or API):
```bash
# Update release body via API (use GITEA_REGISTRY_API_TOKEN, not GITEA_API_TOKEN)
GITEA_TOKEN=$(grep -oP 'GITEA_REGISTRY_API_TOKEN=\K.*' .env)
# Find release ID
curl -s "https://gitea.bnkops.com/api/v1/repos/admin/changemaker.lite/releases?limit=1" \
-H "Authorization: token $GITEA_TOKEN" | python3 -c "import sys,json; r=json.load(sys.stdin)[0]; print(f'ID: {r[\"id\"]}, Tag: {r[\"tag_name\"]}')"
# Update with release notes (write JSON body to /tmp/release-notes.json first)
curl -s -X PATCH "https://gitea.bnkops.com/api/v1/repos/admin/changemaker.lite/releases/RELEASE_ID" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d @/tmp/release-notes.json
```
8. [ ] Test clean install: `tar xzf ... && cd changemaker-lite && bash config.sh && docker compose up -d`
9. [ ] Test upgrade: `./scripts/upgrade.sh` on an existing installation
10. [ ] Verify: `curl http://localhost:4000/api/health` returns `{"status":"ok"}`

View File

@ -1,257 +0,0 @@
# Phase 16: Federation — Instance-to-Instance Campaign Network
## Context
Changemaker Lite instances are currently isolated islands. This feature introduces a **federated discovery network** where any instance can act as a **hub** (accepting registrations, serving a directory) and/or a **spoke** (registering with hubs, sharing campaigns). The goal is organic, admin-to-admin networking with public campaign discoverability as a secondary benefit.
**Design principles:**
- Any instance can be a hub, spoke, or both — no central authority
- Medium-depth campaign sharing: enough metadata for discovery, click-through to source
- Per-campaign federation toggle — admins choose what's shared
- Strict privacy boundary: **never** share emails, participant data, queue data, addresses, volunteer/canvass data, or credentials
- Hub admins curate their own directories — organic > control
---
## Prisma Schema Changes
**File:** `api/prisma/schema.prisma`
### New enums
```
FederationPeerStatus: PENDING | ACTIVE | REJECTED | SUSPENDED | OFFLINE
FederationRole: HUB | SPOKE
```
### New models
**FederationIdentity** (singleton — this instance's federation profile)
- `enabled`, `hubEnabled`, `hubAutoApprove`
- Instance profile: `instanceName`, `instanceDescription`, `instanceUrl`, `instanceRegion`, `instanceTags` (Json), `instanceLogoUrl`
- Ed25519 keypair: `publicKey`, `privateKey` (encrypted at rest)
- Hub description, sync interval, last sync timestamp/error
**FederationPeer** (one record per connection, in either direction)
- `role` (HUB or SPOKE), `remoteUrl` (unique per role+url)
- Remote instance profile fields (name, description, region, tags, logo, publicKey)
- Auth: `apiKey` (ours for them), `remoteApiKey` (theirs for us) — both encrypted
- Status tracking: `status`, `statusMessage`, `lastSeenAt`, `lastSyncAt`, `failureCount`
- Stats: `campaignsShared`, `responsesShared`
- Relation to `FederatedCampaign[]`
**FederatedCampaign** (cached campaign metadata from peers)
- `peerId` → FederationPeer
- Remote identifiers: `remoteCampaignId`, `remoteCampaignSlug`
- Safe metadata: title, description, emailSubject (NOT body), callToAction, coverPhoto, status, targetGovernmentLevels, featureFlags (Json), createdByName
- Aggregate stats: `emailCount`, `responseCount`
- Source instance info (denormalized): `sourceInstanceName`, `sourceInstanceUrl`, `sourceInstanceRegion`
- Staleness tracking: `lastSyncedAt`, `isStale`
- Future adoption: `adoptedAsCampaignId` (nullable FK to local Campaign)
- Unique constraint: `[peerId, remoteCampaignId]`
### Modifications to existing models
**Campaign** — add `federated Boolean @default(false)` field
**SiteSettings** — add `enableFederation Boolean @default(false)` feature toggle
---
## API Module Structure
**New directory:** `api/src/modules/federation/`
| File | Purpose |
|------|---------|
| `federation.schemas.ts` | Zod schemas: identity update, peer registration, campaign sync, directory query, list filters |
| `federation.service.ts` | Core business logic: identity CRUD, peer management, `buildSafeCampaignPayload()`, campaign sync, directory serving |
| `federation-admin.routes.ts` | SUPER_ADMIN routes: identity management, peer approve/reject/suspend, manual sync trigger |
| `federation-peer.routes.ts` | Inter-instance routes: inbound registration, campaign sync, directory, heartbeat (API-key auth) |
| `federation-public.routes.ts` | Public browsing: federated campaigns list, instance directory (no auth) |
| `federation-crypto.service.ts` | Ed25519 keypair generation, request signing/verification |
**New file:** `api/src/services/federation-sync-queue.service.ts` — BullMQ repeatable job for periodic sync
### Route table
**Admin routes** (`/api/federation/...`, SUPER_ADMIN + JWT auth):
| Method | Path | Description |
|--------|------|-------------|
| GET | `/identity` | Get federation config |
| PUT | `/identity` | Update config/profile |
| POST | `/identity/generate-keypair` | Generate Ed25519 keypair |
| GET | `/peers` | List all peers |
| POST | `/peers/register` | Register with a remote hub |
| POST | `/peers/:id/approve` | Approve incoming spoke |
| POST | `/peers/:id/reject` | Reject incoming spoke |
| POST | `/peers/:id/suspend` | Suspend peer |
| DELETE | `/peers/:id` | Remove peer |
| POST | `/sync` | Trigger manual sync |
| GET | `/sync/status` | Sync status + history |
**Peer routes** (`/api/federation/peer/...`, API-key auth via `X-Federation-Key` header):
| Method | Path | Description |
|--------|------|-------------|
| POST | `/register` | Inbound spoke registration |
| POST | `/sync` | Inbound campaign metadata push |
| GET | `/directory` | Serve campaign directory |
| GET | `/profile` | Return instance profile |
| POST | `/heartbeat` | Liveness check |
**Public routes** (`/api/federation/...`, no auth):
| Method | Path | Description |
|--------|------|-------------|
| GET | `/campaigns` | Browse federated campaigns (paginated, searchable) |
| GET | `/campaigns/:id` | Single federated campaign detail |
| GET | `/instances` | List known network instances |
### Mounting in server.ts
```
app.use('/api/federation', federationPublicRouter); // No auth — first
app.use('/api/federation', federationPeerRouter); // API-key auth
app.use('/api/federation', federationAdminRouter); // SUPER_ADMIN JWT
```
---
## Federation Protocol
### Registration handshake
1. Spoke admin enters hub URL, clicks "Register"
2. Spoke sends `POST /api/federation/peer/register` to hub with instance profile + generated API key
3. Hub creates peer record (PENDING or ACTIVE if `hubAutoApprove`)
4. Hub responds with its own API key + peer ID
5. If approved (now or later), hub calls back to spoke's `/peer/register` to complete mutual registration
6. Both instances now have each other as peers (Spoke→HUB role, Hub→SPOKE role)
### Campaign sync
- Spokes push federated campaigns to hubs on schedule (BullMQ repeatable job)
- Payload: array of safe campaign metadata + array of un-federated campaign IDs (for removal)
- Hub stores/updates `FederatedCampaign` records
- Sync includes heartbeat (updates `lastSeenAt`)
### Privacy boundary enforcement
`buildSafeCampaignPayload()` in the service layer filters campaigns to only safe fields. **Never included:** emailBody, any email addresses, user IDs, participant data, moderation internals, custom recipients, calls data.
### Offline handling
- Increment `failureCount` on sync failure; after 5 consecutive failures → status `OFFLINE`
- Mark federated campaigns as `isStale` after 24h offline
- Keep checking with exponential backoff (max 24h)
- Auto-recover when heartbeat succeeds
---
## Security
- **API-key auth:** `crypto.randomBytes(32).toString('hex')`, encrypted at rest with existing `encrypt()`/`decrypt()` utility
- **Custom middleware:** `authenticatePeer` checks `X-Federation-Key` header, verifies peer exists + is ACTIVE
- **Request signing (optional):** Ed25519 signatures on `X-Federation-Signature` header for non-repudiation (configurable, not enforced in MVP)
- **Rate limiting:** 30 req/min for peer routes, 60 req/min for public routes (separate Redis prefixes)
- **CORS:** Peer routes need permissive CORS (cross-domain by nature)
- **Input validation:** All incoming peer data Zod-validated + HTML-escaped before storage
---
## Environment Variables
Add to `api/src/config/env.ts`:
```
ENABLE_FEDERATION: z.string().default('false')
FEDERATION_SYNC_INTERVAL_MINUTES: z.coerce.number().default(60)
FEDERATION_MAX_CAMPAIGNS_PER_SYNC: z.coerce.number().default(500)
FEDERATION_PEER_TIMEOUT_MS: z.coerce.number().default(15000)
FEDERATION_MAX_PEERS: z.coerce.number().default(50)
```
---
## Admin UI
### FederationPage (`admin/src/pages/FederationPage.tsx`)
4-tab page following PangolinPage pattern:
**Tab 1 — Identity & Settings:** Toggle federation, instance profile form, keypair management, hub/spoke settings
**Tab 2 — Connected Peers:** Table of peers (name, URL, role tag, status tag, campaigns shared, last sync, actions). "Register with Hub" button opens modal. Pending incoming registrations highlighted.
**Tab 3 — Federated Campaigns:** Card grid/table of federated campaigns with search + filter (region, tags, government level). Click-through links to source instances.
**Tab 4 — Sync Status:** Last/next sync, per-peer status, manual sync button, sync history.
### Sidebar
Add to `buildMenuItems()` in `AppLayout.tsx`, gated on `settings?.enableFederation`:
```typescript
{ key: '/app/federation', icon: <GlobalOutlined />, label: 'Federation' }
```
(Using `<GlobalOutlined />` since `<GlobalOutlined />` is already imported but used for Web submenu — may use `<ClusterOutlined />` or `<DeploymentUnitOutlined />` instead)
### Route in App.tsx
```tsx
<Route path="federation" element={<ProtectedRoute requiredRoles={['SUPER_ADMIN']}><FederationPage /></ProtectedRoute>} />
```
### Campaign form integration
Add `federated` checkbox to campaign create/edit form in CampaignsPage, visible only when `settings.enableFederation` is true.
### TypeScript types
Add `FederationIdentity`, `FederationPeer`, `FederatedCampaign`, `FederationSyncStatus` interfaces to `admin/src/types/api.ts`.
### Public network page (stretch goal in MVP)
`admin/src/pages/public/FederatedCampaignsPage.tsx` at `/network` route — card grid of federated campaigns with PublicLayout dark theme.
---
## Prometheus Metrics
Add to `api/src/utils/metrics.ts`:
- `cm_federation_peers_active` (Gauge)
- `cm_federation_campaigns_shared` (Gauge)
- `cm_federation_sync_duration_seconds` (Histogram)
- `cm_federation_sync_errors_total` (Counter with `peer_id` label)
---
## Implementation Order
| Step | Description | Files Created/Modified | Depends On |
|------|-------------|----------------------|------------|
| 1 | **Prisma schema** — Add enums, 3 new models, Campaign.federated, SiteSettings.enableFederation | `api/prisma/schema.prisma` | — |
| 2 | **Migration**`npx prisma migrate dev --name add-federation` | `api/prisma/migrations/` | Step 1 |
| 3 | **Env vars** — Add federation config to env.ts + .env.example | `api/src/config/env.ts`, `.env.example` | — |
| 4 | **Crypto service** — Ed25519 keypair, sign/verify | `api/src/modules/federation/federation-crypto.service.ts` | — |
| 5 | **Schemas** — Zod validation for all federation endpoints | `api/src/modules/federation/federation.schemas.ts` | Step 1 |
| 6 | **Core service** — Identity CRUD, peer management, buildSafeCampaignPayload, campaign sync logic | `api/src/modules/federation/federation.service.ts` | Steps 2, 4, 5 |
| 7 | **Admin routes** — SUPER_ADMIN federation management | `api/src/modules/federation/federation-admin.routes.ts` | Step 6 |
| 8 | **Peer routes** — Inter-instance API with authenticatePeer middleware | `api/src/modules/federation/federation-peer.routes.ts` | Step 6 |
| 9 | **Public routes** — Browsing federated campaigns | `api/src/modules/federation/federation-public.routes.ts` | Step 6 |
| 10 | **Rate limiting** — Add federation rate limiters | `api/src/middleware/rate-limit.ts` | — |
| 11 | **Server mounting** — Import + mount routers, start sync queue | `api/src/server.ts` | Steps 7-10 |
| 12 | **Sync queue** — BullMQ repeatable job for periodic sync | `api/src/services/federation-sync-queue.service.ts` | Step 6 |
| 13 | **Metrics** — Prometheus counters/gauges | `api/src/utils/metrics.ts` | — |
| 14 | **Campaign form** — Add `federated` to schemas + service + CampaignsPage checkbox | `api/src/modules/influence/campaigns/campaigns.schemas.ts`, `campaigns.service.ts`, `admin/src/pages/CampaignsPage.tsx` | Step 2 |
| 15 | **Frontend types** — Federation TypeScript interfaces | `admin/src/types/api.ts` | — |
| 16 | **FederationPage** — 4-tab admin page | `admin/src/pages/FederationPage.tsx` | Steps 7, 15 |
| 17 | **Sidebar + routing** — Menu item + route in AppLayout/App.tsx | `admin/src/components/AppLayout.tsx`, `admin/src/App.tsx` | Step 16 |
| 18 | **Public network page** (stretch) — Federated campaigns browse | `admin/src/pages/public/FederatedCampaignsPage.tsx` | Steps 9, 15 |
---
## Future Extensions (not in MVP, but models accommodate)
- **Campaign adoption** — "Fork" a federated campaign locally (`FederatedCampaign.adoptedAsCampaignId`)
- **Cross-instance response sharing** — New `FederatedResponse` model synced alongside campaigns
- **Named networks/coalitions**`FederationNetwork` + `FederationNetworkMember` models for named alliances
- **Hub-of-hubs discovery** — Hubs share known-hub lists for transitive discovery (gossip protocol)
---
## Verification
1. **Two-instance test:** Run two API instances on different ports, enable federation on both, register one with the other
2. **Campaign sync:** Create a federated campaign on spoke, verify it appears in hub's directory
3. **Privacy boundary:** Inspect sync payloads — verify no emails, user IDs, or email bodies leak
4. **Offline handling:** Stop one instance, verify the other marks it OFFLINE after 5 failed syncs, then recovers on restart
5. **Rate limiting:** Hit peer endpoints rapidly, verify 429 responses after threshold
6. **Feature gate:** Disable federation in settings, verify all routes return 403/hidden
7. **UI:** Verify sidebar item appears/hides with feature toggle, all 4 tabs functional

View File

@ -103,15 +103,22 @@ Send SMS campaigns via an Android bridge, sync subscribers to Listmonk for newsl
## Quick Start ## Quick Start
### Production (pre-built images)
```bash ```bash
# One-command install (downloads pre-built images, runs config wizard) # 1. One-command install: checks host ports, downloads tarball, runs config wizard
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
cd ~/changemaker.lite # 2. Start services (first pull ~3 min + ~90s stabilization)
docker compose up -d cd ~/changemaker.lite && docker compose up -d
# 3. Verify the install
bash scripts/test-deployment.sh --wait 60
``` ```
Or clone and build from source: The installer checks your host's port availability before extracting — no more half-started stacks from cockpit on `:9090` or other surprises. The generated admin password is printed to stdout **and** saved to `data/admin-credentials.txt` (mode 0600). See [Prerequisites](https://cmlite.org/docs/getting-started/prerequisites/) for what you need lined up first.
### Development (source)
```bash ```bash
git clone <repo-url> changemaker.lite git clone <repo-url> changemaker.lite
@ -127,6 +134,15 @@ docker compose exec api npx prisma db seed
Then open **http://localhost:3000** and log in with the admin credentials from your `.env`. Then open **http://localhost:3000** and log in with the admin credentials from your `.env`.
### Useful tools
```bash
bash scripts/validate-env.sh # re-check .env + host ports
bash scripts/test-deployment.sh # full deployment health sweep
bash scripts/pangolin-teardown.sh # wipe tunnel org before reinstall (dry-run by default)
bash scripts/ccp-deregister.sh # deregister from Changemaker Control Panel (dry-run by default)
```
## Documentation ## Documentation
**Full documentation is available at [cmlite.org/docs/getting-started](https://cmlite.org/docs/getting-started/).** **Full documentation is available at [cmlite.org/docs/getting-started](https://cmlite.org/docs/getting-started/).**

View File

@ -1,161 +0,0 @@
# Service Integrations — EventBus Architecture
Tracking document for the platform-wide EventBus and service integration work.
**Started:** 2026-03-30
**Branch:** v2
---
## Architecture Overview
Changemaker Lite has 30+ services but most operate as isolated tools. The EventBus provides a centralized, typed, in-process pub/sub system that decouples event producers from consumers.
```
Service Handler (shift created, donation completed, etc.)
|
v
eventBus.publish('shift.created', payload)
|
+-- ListmonkListener (newsletter sync)
+-- RocketChatListener (team notifications)
+-- CrmActivityListener (contact timeline)
+-- CalendarSyncListener (unified calendar)
+-- N8nWebhookListener (external automation)
+-- GancioSyncListener (public event calendar)
```
### Why In-Process EventEmitter (not Redis PubSub)
- Single Express process — no distributed coordination needed
- Zero serialization overhead (pass JS objects directly)
- Data already persisted in DB — events are ephemeral notifications
- Matches the existing fire-and-forget pattern used by Listmonk/RC services
- Can be swapped to Redis PubSub later if we go multi-process
### Key Files
| File | Purpose |
|------|---------|
| `api/src/types/events.ts` | Typed event catalog (all event names + payloads) |
| `api/src/services/event-bus.service.ts` | Core EventBus (publish/subscribe/stats) |
| `api/src/services/event-listeners/listmonk.listener.ts` | Listmonk newsletter sync |
| `api/src/services/event-listeners/rocketchat.listener.ts` | Rocket.Chat notifications |
| `api/src/services/event-listeners/crm-activity.listener.ts` | CRM ContactActivity writer |
| `api/src/services/event-listeners/calendar-sync.listener.ts` | Calendar unification |
| `api/src/services/event-listeners/n8n-webhook.listener.ts` | n8n automation bridge |
| `api/src/services/event-listeners/gancio.listener.ts` | Gancio event sync (shifts + ticketed events) |
| `api/src/services/event-listeners/engagement-scoring.listener.ts` | Contact engagement scores (Redis ZSET) |
| `api/src/services/event-listeners/homepage-stats.listener.ts` | Homepage real-time counters + cache invalidation |
---
## Progress Tracker
### Phase 1: Core Infrastructure
- [x] Explore existing event patterns (Listmonk, RC, Gancio, provisioning)
- [x] Design EventBus architecture
- [x] Implement EventBus service (`api/src/services/event-bus.service.ts`)
- [x] Define typed event catalog (`api/src/types/events.ts` — 46 events across 14 modules)
- [x] Register EventBus in server.ts startup
- [x] Add EventBus stats endpoint (`GET /api/observability/event-bus`)
### Phase 2: Migrate Existing Integrations
- [x] Listmonk event sync → EventBus listener (9 event subscriptions)
- [x] Rocket.Chat webhook service → EventBus listener (4 event subscriptions)
- [x] Gancio shift/event sync → EventBus listener (3 event subscriptions)
### Phase 3: New Listeners
- [x] CRM Activity auto-generation listener (11 event subscriptions)
- [x] Calendar sync listener (8 event subscriptions)
- [x] n8n webhook emitter listener (wildcard subscription, forwards all events)
- [x] Listmonk webhook receiver (inbound: open, click, bounce, unsubscribe → EventBus)
### Phase 4: Wire Up Publishers (migrated from inline calls)
- [x] Shift CRUD + signup (shift.created/updated/deleted, shift.signup.created/cancelled)
- [x] Canvass session complete + visits (canvass.session.completed, contact.address.updated)
- [x] Response submit (response.submitted)
- [x] Campaign email sent (campaign.email.sent)
- [x] Payment/donation/subscription events (3 event types)
- [x] Contact tag changes (contact.tags.changed — 3 call sites)
- [x] Reengagement sent (reengagement.sent)
- [x] Campaign CRUD + publish + moderation (campaign.created/updated/deleted/published/status.changed)
- [x] User create/update/delete/approve (user.created/updated/deleted/approved)
- [x] SMS campaign start/complete + message send/receive (4 event types)
- [x] Media video publish/unpublish/view (3 event types)
- [x] Ticketed event publish/cancel (EventBus publishes alongside existing Gancio calls)
- [x] Impact story publish (social.impact-story.published)
- [x] Meeting create/delete (jitsi.routes.ts — meeting.created, meeting.deleted)
### Phase 4b: Extended Listeners (2026-03-31)
- [x] RC listener: +7 subscriptions (campaign.published, donations, subscriptions, SMS escalation, user.approved, video.published, ticketed-event.published)
- [x] CRM listener: +2 subscriptions (subscription activated, email bounced)
- [x] RC webhook service: +7 new formatter methods
- [x] Prisma migration: SHIFT, MEETING, TICKETED_EVENT added to CalendarItemSource enum
- [x] Calendar sync listener: uses proper source types (SHIFT, MEETING, TICKETED_EVENT)
### Phase 4c: New Data Listeners (2026-03-31)
- [x] Engagement scoring listener (11 subscriptions, Redis ZSET leaderboard)
- [x] Homepage stats listener (12 subscriptions, Redis counters + recent activity)
- [x] GET /api/homepage/live-stats endpoint (public, real-time counters + recent)
- [x] GET /api/observability/engagement-leaderboard endpoint (admin, top contacts)
### Phase 5: Future
- [ ] Migrate meeting-planner Gancio calls to EventBus (blocked: synchronous return value needed)
- [ ] Homepage service: swap COUNT queries for Redis counters in getStats()
- [ ] Engagement score materialization: periodic job to denormalize scores to Contact model
---
## Event Catalog
### Currently Wired (11 event points, 3 consumers)
| Event | Listmonk | Rocket.Chat | Gancio |
|-------|----------|-------------|--------|
| shift.signup | yes | yes | - |
| shift.signup.cancelled | - | yes | - |
| shift.created | - | - | yes |
| shift.updated | - | - | yes |
| shift.deleted | - | - | yes |
| canvass.session.completed | yes | yes | - |
| canvass.address.updated | yes | - | - |
| campaign.email.sent | yes | - | - |
| response.submitted | - | yes | - |
| subscription.activated | yes | - | - |
| donation.completed | yes | - | - |
| product.purchased | yes | - | - |
| contact.tags.changed | yes | - | - |
| reengagement.sent | yes | - | - |
### New Events (49+ handlers need publishers)
| Event | CRM Activity | Calendar | RC | n8n |
|-------|-------------|----------|-----|-----|
| campaign.created | - | - | - | yes |
| campaign.published | - | - | yes | yes |
| campaign.status.changed | - | - | yes | yes |
| user.approved | - | - | yes | yes |
| user.created | - | - | - | yes |
| video.published | - | - | yes | yes |
| video.viewed | yes | - | - | - |
| sms.message.received | yes | - | yes* | yes |
| sms.campaign.completed | - | - | yes | yes |
| ticketed-event.published | - | yes | - | yes |
| meeting.created | - | yes | - | - |
| impact-story.published | - | - | yes | yes |
| shift.created | - | yes | - | yes |
| donation.completed | yes | - | yes | yes |
| subscription.activated | yes | - | - | yes |
*SMS escalations (QUESTION/NEGATIVE sentiment) to relevant RC channel
---
## Design Decisions
1. **Listeners self-guard**: Each listener checks its own feature flag (ENABLE_CHAT, LISTMONK_SYNC_ENABLED, etc.) — the EventBus doesn't filter
2. **Error isolation**: Each listener wraps its handler in try-catch; one listener failing doesn't affect others
3. **No persistence**: Events are ephemeral — if the server restarts mid-event, it's lost (data is already in DB)
4. **Stats tracking**: EventBus tracks per-event emission counts + per-listener execution counts for observability
5. **Wildcard subscriptions**: Listeners can subscribe to `shift.*` to catch all shift events

View File

@ -0,0 +1,8 @@
{
"hash": "46070c3d",
"configHash": "70922fab",
"lockfileHash": "ee36a2d0",
"browserHash": "5aa32ba6",
"optimized": {},
"chunks": {}
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@ -33,9 +33,11 @@
"grapesjs-tabs": "^1.0.6", "grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1", "grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1", "grapesjs-typed": "^2.0.1",
"hls.js": "^1.6.16",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"minisearch": "^7.2.0", "minisearch": "^7.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -2633,6 +2635,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/html-entities": { "node_modules/html-entities": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
@ -2722,6 +2730,11 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/leaflet.heat": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
"integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
},
"node_modules/leaflet.markercluster": { "node_modules/leaflet.markercluster": {
"version": "1.5.3", "version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",

View File

@ -34,9 +34,11 @@
"grapesjs-tabs": "^1.0.6", "grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1", "grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1", "grapesjs-typed": "^2.0.1",
"hls.js": "^1.6.16",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"minisearch": "^7.2.0", "minisearch": "^7.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@ -43,8 +43,13 @@ import JitsiMeetPage from '@/pages/JitsiMeetPage';
import SettingsPage from '@/pages/SettingsPage'; import SettingsPage from '@/pages/SettingsPage';
import NavigationSettingsPage from '@/pages/NavigationSettingsPage'; import NavigationSettingsPage from '@/pages/NavigationSettingsPage';
import PangolinPage from '@/pages/PangolinPage'; import PangolinPage from '@/pages/PangolinPage';
import ControlPanelPage from '@/pages/ControlPanelPage';
import ObservabilityPage from '@/pages/ObservabilityPage'; import ObservabilityPage from '@/pages/ObservabilityPage';
import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage'; import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage';
import AnalyticsOverviewPage from '@/pages/analytics/AnalyticsOverviewPage';
import GeoAnalyticsPage from '@/pages/analytics/GeoAnalyticsPage';
import ContentAnalyticsPage from '@/pages/analytics/ContentAnalyticsPage';
import UserAnalyticsPage from '@/pages/analytics/UserAnalyticsPage';
import DocsCommentsPage from '@/pages/DocsCommentsPage'; import DocsCommentsPage from '@/pages/DocsCommentsPage';
import DocsMetadataPage from '@/pages/DocsMetadataPage'; import DocsMetadataPage from '@/pages/DocsMetadataPage';
import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage'; import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
@ -62,6 +67,11 @@ import GalleryAdsPage from '@/pages/media/GalleryAdsPage';
import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage'; import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage';
import CampaignModerationPage from '@/pages/influence/CampaignModerationPage'; import CampaignModerationPage from '@/pages/influence/CampaignModerationPage';
import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage'; import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage';
import PetitionsPage from '@/pages/influence/PetitionsPage';
import PetitionSignaturesPage from '@/pages/influence/PetitionSignaturesPage';
import PetitionModerationPage from '@/pages/influence/PetitionModerationPage';
import PetitionsListPage from '@/pages/public/PetitionsListPage';
import PetitionPage from '@/pages/public/PetitionPage';
import PublicLandingPage from '@/pages/public/LandingPage'; import PublicLandingPage from '@/pages/public/LandingPage';
import PagesIndexPage from '@/pages/public/PagesIndexPage'; import PagesIndexPage from '@/pages/public/PagesIndexPage';
import EventsPage from '@/pages/public/EventsPage'; import EventsPage from '@/pages/public/EventsPage';
@ -100,6 +110,7 @@ import SocialFeedPage from '@/pages/volunteer/SocialFeedPage';
import DiscoverPage from '@/pages/volunteer/DiscoverPage'; import DiscoverPage from '@/pages/volunteer/DiscoverPage';
import GroupDetailPage from '@/pages/volunteer/GroupDetailPage'; import GroupDetailPage from '@/pages/volunteer/GroupDetailPage';
import AchievementsPage from '@/pages/volunteer/AchievementsPage'; import AchievementsPage from '@/pages/volunteer/AchievementsPage';
import MyAnalyticsPage from '@/pages/volunteer/MyAnalyticsPage';
import { import {
ADMIN_ROLES, ADMIN_ROLES,
INFLUENCE_ROLES, INFLUENCE_ROLES,
@ -113,6 +124,7 @@ import {
SOCIAL_ROLES, SOCIAL_ROLES,
SYSTEM_ROLES, SYSTEM_ROLES,
POLLS_ROLES, POLLS_ROLES,
ANALYTICS_ROLES,
} from '@/types/api'; } from '@/types/api';
import { isAdmin } from '@/utils/roles'; import { isAdmin } from '@/utils/roles';
import QuickJoinPage from '@/pages/public/QuickJoinPage'; import QuickJoinPage from '@/pages/public/QuickJoinPage';
@ -134,6 +146,9 @@ import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage'; import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage'; import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
import StrawPollsPage from '@/pages/influence/StrawPollsPage'; import StrawPollsPage from '@/pages/influence/StrawPollsPage';
import ActionCampaignsPage from '@/pages/influence/ActionCampaignsPage';
import ActionCampaignEditorPage from '@/pages/influence/ActionCampaignEditorPage';
import VolunteerDashboardPage from '@/pages/volunteer/VolunteerDashboardPage';
import ReferralsPage from '@/pages/volunteer/ReferralsPage'; import ReferralsPage from '@/pages/volunteer/ReferralsPage';
import ChallengesPage from '@/pages/volunteer/ChallengesPage'; import ChallengesPage from '@/pages/volunteer/ChallengesPage';
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage'; import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
@ -172,7 +187,7 @@ function RoleAwareRedirect() {
function NavigateToCutMap() { function NavigateToCutMap() {
const { cutId } = useParams<{ cutId: string }>(); const { cutId } = useParams<{ cutId: string }>();
return <Navigate to={`/volunteer?cutId=${cutId}`} replace />; return <Navigate to={`/volunteer/map?cutId=${cutId}`} replace />;
} }
export default function App() { export default function App() {
@ -240,6 +255,12 @@ export default function App() {
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}> <Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route index element={<CampaignsListPage />} /> <Route index element={<CampaignsListPage />} />
</Route> </Route>
<Route path="/petitions" element={<FeatureGate feature="enablePetitions"><PublicLayout /></FeatureGate>}>
<Route index element={<PetitionsListPage />} />
</Route>
<Route path="/petition/:slug" element={<FeatureGate feature="enablePetitions"><PublicLayout /></FeatureGate>}>
<Route index element={<PetitionPage />} />
</Route>
<Route path="/campaigns/create" element={ <Route path="/campaigns/create" element={
<FeatureGate feature="enableInfluence"> <FeatureGate feature="enableInfluence">
<ProtectedRoute> <ProtectedRoute>
@ -352,9 +373,9 @@ export default function App() {
{/* Email link alias for video viewer */} {/* Email link alias for video viewer */}
<Route path="/media/:id" element={<MediaViewerPage />} /> <Route path="/media/:id" element={<MediaViewerPage />} />
{/* Volunteer map — full-screen, default landing page */} {/* Volunteer map — full-screen (moved from /volunteer to /volunteer/map) */}
<Route <Route
path="/volunteer" path="/volunteer/map"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<VolunteerMapPage /> <VolunteerMapPage />
@ -380,6 +401,7 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
> >
<Route path="/volunteer" element={<VolunteerDashboardPage />} />
<Route path="/volunteer/activity" element={<MyActivityPage />} /> <Route path="/volunteer/activity" element={<MyActivityPage />} />
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} /> <Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
<Route path="/volunteer/routes" element={<MyRoutesPage />} /> <Route path="/volunteer/routes" element={<MyRoutesPage />} />
@ -399,6 +421,7 @@ export default function App() {
<Route path="/volunteer/calendar/shared" element={<FeatureGate feature="enableSocialCalendar"><SharedCalendarsPage /></FeatureGate>} /> <Route path="/volunteer/calendar/shared" element={<FeatureGate feature="enableSocialCalendar"><SharedCalendarsPage /></FeatureGate>} />
<Route path="/volunteer/calendar/friend/:userId" element={<FeatureGate feature="enableSocialCalendar"><FriendCalendarPage /></FeatureGate>} /> <Route path="/volunteer/calendar/friend/:userId" element={<FeatureGate feature="enableSocialCalendar"><FriendCalendarPage /></FeatureGate>} />
<Route path="/volunteer/calendar" element={<FeatureGate feature="enableSocialCalendar"><MyCalendarPage /></FeatureGate>} /> <Route path="/volunteer/calendar" element={<FeatureGate feature="enableSocialCalendar"><MyCalendarPage /></FeatureGate>} />
<Route path="/volunteer/my-analytics" element={<FeatureGate feature="enableAnalytics"><MyAnalyticsPage /></FeatureGate>} />
<Route path="/volunteer/*" element={<NotFoundPage />} /> <Route path="/volunteer/*" element={<NotFoundPage />} />
</Route> </Route>
@ -574,6 +597,30 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="influence/petitions"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<PetitionsPage />
</ProtectedRoute>
}
/>
<Route
path="influence/petitions/:id/signatures"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<PetitionSignaturesPage />
</ProtectedRoute>
}
/>
<Route
path="influence/petitions/moderation"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<PetitionModerationPage />
</ProtectedRoute>
}
/>
<Route <Route
path="influence/straw-polls" path="influence/straw-polls"
element={ element={
@ -582,6 +629,30 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="influence/action-campaigns"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ActionCampaignsPage />
</ProtectedRoute>
}
/>
<Route
path="influence/action-campaigns/new"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ActionCampaignEditorPage />
</ProtectedRoute>
}
/>
<Route
path="influence/action-campaigns/:id"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ActionCampaignEditorPage />
</ProtectedRoute>
}
/>
<Route <Route
path="listmonk" path="listmonk"
element={ element={
@ -807,6 +878,14 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="control-panel"
element={
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<ControlPanelPage />
</ProtectedRoute>
}
/>
<Route <Route
path="observability" path="observability"
element={ element={
@ -815,6 +894,46 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="analytics"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<AnalyticsOverviewPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/geo"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<GeoAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/content"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<ContentAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/users"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<UserAnalyticsPage />
</ProtectedRoute>
}
/>
<Route
path="analytics/users/:userId"
element={
<ProtectedRoute requiredRoles={ANALYTICS_ROLES}>
<UserAnalyticsPage />
</ProtectedRoute>
}
/>
<Route <Route
path="map" path="map"
element={ element={

View File

@ -72,6 +72,7 @@ import {
PAYMENTS_ROLES, PAYMENTS_ROLES,
SOCIAL_ROLES, SOCIAL_ROLES,
POLLS_ROLES, POLLS_ROLES,
ANALYTICS_ROLES,
} from '@/types/api'; } from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url'; import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api'; import type { NavItem } from '@/types/api';
@ -186,8 +187,13 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' }, { key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: badges?.pendingEmails ? <Badge count={badges.pendingEmails} size="small" offset={[8, 0]}>Outgoing Emails</Badge> : 'Outgoing Emails' }, { key: '/app/email-queue', icon: <MailOutlined />, label: badges?.pendingEmails ? <Badge count={badges.pendingEmails} size="small" offset={[8, 0]}>Outgoing Emails</Badge> : 'Outgoing Emails' },
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' }, { key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
{ key: '/app/influence/action-campaigns', icon: <TrophyOutlined />, label: 'Action Campaigns' },
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' }, { key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' }, { key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
...(settings?.enablePetitions !== false ? [
{ key: '/app/influence/petitions', icon: <FileTextOutlined />, label: 'Petitions' },
{ key: '/app/influence/petitions/moderation', icon: <FileTextOutlined />, label: 'Petition Review' },
] : []),
...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: <BarChartOutlined />, label: 'Straw Polls' }] : []), ...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: <BarChartOutlined />, label: 'Straw Polls' }] : []),
], ],
}); });
@ -326,6 +332,20 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
} }
if (isSuperAdmin) { if (isSuperAdmin) {
if (settings?.enableAnalytics !== false && can(ANALYTICS_ROLES)) {
items.push({
key: 'analytics-submenu',
icon: <BarChartOutlined />,
label: 'Analytics',
children: [
{ key: '/app/analytics', icon: <DashboardOutlined />, label: 'Overview' },
{ key: '/app/analytics/geo', icon: <GlobalOutlined />, label: 'Geography' },
{ key: '/app/analytics/content', icon: <FileTextOutlined />, label: 'Content' },
{ key: '/app/analytics/users', icon: <TeamOutlined />, label: 'Users' },
],
});
}
items.push({ items.push({
key: 'services-submenu', key: 'services-submenu',
icon: <CloudServerOutlined />, icon: <CloudServerOutlined />,
@ -333,6 +353,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
children: [ children: [
{ type: 'group', label: 'Infrastructure', children: [ { type: 'group', label: 'Infrastructure', children: [
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' }, { key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
{ key: '/app/control-panel', icon: <ApiOutlined />, label: 'Control Panel' },
{ key: '/app/observability', icon: <LineChartOutlined />, label: 'Monitoring' }, { key: '/app/observability', icon: <LineChartOutlined />, label: 'Monitoring' },
{ key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' }, { key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' },
{ key: '/app/services/vaultwarden', icon: <LockOutlined />, label: 'Vault' }, { key: '/app/services/vaultwarden', icon: <LockOutlined />, label: 'Vault' },
@ -638,7 +659,7 @@ export default function AppLayout() {
/> />
</Tooltip> </Tooltip>
{pageHeader?.actions} {pageHeader?.actions}
{(() => { {!isMobile && (() => {
const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS); const merged = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS);
const withOverrides = applyAdminOverrides(merged); const withOverrides = applyAdminOverrides(merged);
const flags = buildFeatureFlags(settings); const flags = buildFeatureFlags(settings);
@ -647,11 +668,14 @@ export default function AppLayout() {
const getIcon = (iconName: string) => ADMIN_ICON_OVERRIDES[iconName] ?? ICON_MAP[iconName] ?? <GlobalOutlined />; const getIcon = (iconName: string) => ADMIN_ICON_OVERRIDES[iconName] ?? ICON_MAP[iconName] ?? <GlobalOutlined />;
const handleItemClick = (item: NavItem) => { const handleItemClick = (item: NavItem) => {
if (item.path.startsWith('$')) { 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') { } else if (item.external && item.id === 'home') {
window.open(buildHomeUrl(), '_blank'); window.open(buildHomeUrl(), '_blank', 'noopener,noreferrer');
} else if (item.external) { } 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 { } else {
navigate(item.path); navigate(item.path);
} }
@ -673,7 +697,7 @@ export default function AppLayout() {
placement="bottomRight" placement="bottomRight"
> >
<Button type="text" size="small" icon={getIcon(item.icon)}> <Button type="text" size="small" icon={getIcon(item.icon)}>
{!isMobile && !collapsed && item.label} {!collapsed && item.label}
</Button> </Button>
</Dropdown> </Dropdown>
); );
@ -686,23 +710,25 @@ export default function AppLayout() {
icon={getIcon(item.icon)} icon={getIcon(item.icon)}
onClick={() => handleItemClick(item)} onClick={() => handleItemClick(item)}
> >
{!isMobile && !collapsed && item.label} {!collapsed && item.label}
</Button> </Button>
</Tooltip> </Tooltip>
); );
}); });
})()} })()}
{/* Volunteer Portal button — always visible for quick switching */} {/* Volunteer Portal button — always visible for quick switching */}
<Tooltip title="Switch to Volunteer Portal"> {!isMobile && (
<Button <Tooltip title="Switch to Volunteer Portal">
type="text" <Button
size="small" type="text"
icon={<TeamOutlined />} size="small"
onClick={() => navigate('/volunteer')} icon={<TeamOutlined />}
> onClick={() => navigate('/volunteer')}
{!isMobile && !collapsed && 'Volunteer'} >
</Button> {!collapsed && 'Volunteer'}
</Tooltip> </Button>
</Tooltip>
)}
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />} data-tour="user-menu"> <Button type="text" icon={<UserOutlined />} data-tour="user-menu">
{!isMobile && !collapsed && ( {!isMobile && !collapsed && (

View File

@ -22,11 +22,13 @@ const FEATURE_LABELS: Record<string, string> = {
enableMeetingPlanner: 'Meeting Planner', enableMeetingPlanner: 'Meeting Planner',
enableTicketedEvents: 'Ticketed Events', enableTicketedEvents: 'Ticketed Events',
enableSocialCalendar: 'Social Calendar', enableSocialCalendar: 'Social Calendar',
enablePetitions: 'Petitions',
enablePolls: 'Straw Polls', enablePolls: 'Straw Polls',
enableAnalytics: 'Analytics Dashboard',
}; };
interface FeatureGateProps { interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePolls'>; feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePetitions' | 'enablePolls' | 'enableAnalytics'>;
children: ReactNode; children: ReactNode;
} }

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

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { theme } from 'antd'; import { theme } from 'antd';
import { import {
HomeOutlined,
EnvironmentOutlined, EnvironmentOutlined,
ScheduleOutlined, ScheduleOutlined,
HistoryOutlined, HistoryOutlined,
@ -15,7 +16,8 @@ import {
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
const BASE_NAV_ITEMS = [ const BASE_NAV_ITEMS = [
{ key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' }, { key: '/volunteer', icon: HomeOutlined, label: 'Home' },
{ key: '/volunteer/map', icon: EnvironmentOutlined, label: 'Map' },
{ key: '/volunteer/shifts', icon: ScheduleOutlined, label: 'Shifts' }, { key: '/volunteer/shifts', icon: ScheduleOutlined, label: 'Shifts' },
{ key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' }, { key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' },
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' }, { key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },

View File

@ -6,6 +6,7 @@ import {
UserOutlined, UserOutlined,
GlobalOutlined, GlobalOutlined,
AppstoreOutlined, AppstoreOutlined,
HomeOutlined,
EnvironmentOutlined, EnvironmentOutlined,
ScheduleOutlined, ScheduleOutlined,
HistoryOutlined, HistoryOutlined,
@ -14,6 +15,7 @@ import {
TagOutlined, TagOutlined,
TeamOutlined, TeamOutlined,
MessageOutlined, MessageOutlined,
BarChartOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -48,7 +50,8 @@ export default function VolunteerLayout() {
// Build nav items list (mirrors VolunteerFooterNav logic) // Build nav items list (mirrors VolunteerFooterNav logic)
const navItems = useMemo(() => { const navItems = useMemo(() => {
const items: { key: string; icon: React.ReactNode; label: string }[] = [ const items: { key: string; icon: React.ReactNode; label: string }[] = [
{ key: '/volunteer', icon: <EnvironmentOutlined />, label: 'Map' }, { key: '/volunteer', icon: <HomeOutlined />, label: 'Home' },
{ key: '/volunteer/map', icon: <EnvironmentOutlined />, label: 'Map' },
{ key: '/volunteer/shifts', icon: <ScheduleOutlined />, label: 'Shifts' }, { key: '/volunteer/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
{ key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' }, { key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' },
{ key: '/volunteer/routes', icon: <NodeIndexOutlined />, label: 'Routes' }, { key: '/volunteer/routes', icon: <NodeIndexOutlined />, label: 'Routes' },
@ -65,6 +68,9 @@ export default function VolunteerLayout() {
if (settings?.enableChat) { if (settings?.enableChat) {
items.push({ key: '/volunteer/chat', icon: <MessageOutlined />, label: 'Chat' }); items.push({ key: '/volunteer/chat', icon: <MessageOutlined />, label: 'Chat' });
} }
if (settings?.enableAnalytics) {
items.push({ key: '/volunteer/my-analytics', icon: <BarChartOutlined />, label: 'My Stats' });
}
return items; return items;
}, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]); }, [settings?.enableSocialCalendar, settings?.enableTicketedEvents, settings?.enableSocial, settings?.enableChat]);
@ -97,7 +103,7 @@ export default function VolunteerLayout() {
<Content <Content
style={{ style={{
maxWidth: 800, maxWidth: location.pathname === '/volunteer' ? 1280 : 800,
width: '100%', width: '100%',
margin: '0 auto', margin: '0 auto',
padding: '16px 12px max(60px, calc(44px + 16px + env(safe-area-inset-bottom))) 12px', padding: '16px 12px max(60px, calc(44px + 16px + env(safe-area-inset-bottom))) 12px',

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { import {
Modal, Drawer,
Form, Form,
Input, Input,
DatePicker, DatePicker,
@ -169,13 +169,20 @@ export default function CalendarItemModal({
}; };
return ( return (
<Modal <Drawer
open={open} open={open}
onCancel={onCancel} onClose={onCancel}
title={isEditing ? 'Edit Calendar Item' : 'New Calendar Item'} title={isEditing ? 'Edit Calendar Item' : 'New Calendar Item'}
footer={null}
width={520} width={520}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose destroyOnClose
extra={
<Button type="primary" onClick={() => form.submit()} loading={loading}>
{isEditing ? 'Save Changes' : 'Create'}
</Button>
}
> >
<Form <Form
form={form} form={form}
@ -454,26 +461,18 @@ export default function CalendarItemModal({
)} )}
{/* Actions */} {/* Actions */}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 8 }}> {isEditing && onDelete && (
<div> <div style={{ marginTop: 8 }}>
{isEditing && onDelete && ( <Button
<Button danger
danger icon={<DeleteOutlined />}
icon={<DeleteOutlined />} onClick={onDelete}
onClick={onDelete} >
> Delete
Delete
</Button>
)}
</div>
<Space>
<Button onClick={onCancel}>Cancel</Button>
<Button type="primary" htmlType="submit" loading={loading}>
{isEditing ? 'Save Changes' : 'Create'}
</Button> </Button>
</Space> </div>
</div> )}
</Form> </Form>
</Modal> </Drawer>
); );
} }

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Modal, Form, Select, Checkbox, Slider, DatePicker, Switch, Drawer, Form, Select, Checkbox, Slider, DatePicker, Switch,
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App, Grid, Button, Statistic, Row, Col, Descriptions, Alert, Spin, App, Grid, Space,
} from 'antd'; } from 'antd';
import { ExportOutlined, EyeOutlined } from '@ant-design/icons'; import { ExportOutlined, EyeOutlined } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@ -152,32 +152,34 @@ export default function ExportContactsModal({
}; };
return ( return (
<Modal <Drawer
title="Export Canvass Contacts to Campaign" title="Export Canvass Contacts to Campaign"
open={open} open={open}
onCancel={onClose} onClose={onClose}
width={isMobile ? '95vw' : 640} width={isMobile ? '95vw' : 640}
footer={[ placement="right"
<Button key="cancel" onClick={onClose}>Cancel</Button>, mask={false}
<Button rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
key="preview" extra={
icon={<EyeOutlined />} <Space>
onClick={handlePreview} <Button
loading={previewing} icon={<EyeOutlined />}
> onClick={handlePreview}
Preview loading={previewing}
</Button>, >
<Button Preview
key="export" </Button>
type="primary" <Button
icon={<ExportOutlined />} type="primary"
onClick={handleExport} icon={<ExportOutlined />}
loading={exporting} onClick={handleExport}
disabled={!preview || preview.contactsWithEmail === 0} loading={exporting}
> disabled={!preview || preview.contactsWithEmail === 0}
Export >
</Button>, Export
]} </Button>
</Space>
}
> >
<Form form={form} layout="vertical" size="small"> <Form form={form} layout="vertical" size="small">
<Form.Item <Form.Item
@ -294,6 +296,6 @@ export default function ExportContactsModal({
)} )}
</div> </div>
)} )}
</Modal> </Drawer>
); );
} }

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import {
Modal, Drawer,
Button, Button,
Input, Input,
Space, Space,
@ -150,7 +150,7 @@ export function AuthorsManagementModal({
const authorEntries = Object.entries(localAuthors); const authorEntries = Object.entries(localAuthors);
return ( return (
<Modal <Drawer
title={ title={
<span> <span>
<UserOutlined style={{ marginRight: 8 }} /> <UserOutlined style={{ marginRight: 8 }} />
@ -158,23 +158,23 @@ export function AuthorsManagementModal({
</span> </span>
} }
open={open} open={open}
onCancel={onClose} onClose={onClose}
footer={
<Space>
<Button onClick={onClose}>Close</Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAll}
loading={saving}
disabled={!dirty}
>
Save
</Button>
</Space>
}
destroyOnHidden
width={560} width={560}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSaveAll}
loading={saving}
disabled={!dirty}
>
Save
</Button>
}
> >
{contextHolder} {contextHolder}
@ -236,7 +236,7 @@ export function AuthorsManagementModal({
</Button> </Button>
)} )}
</div> </div>
</Modal> </Drawer>
); );
} }

View File

@ -0,0 +1,129 @@
import { useCallback } from 'react';
import { Button, Dropdown, Tooltip } from 'antd';
import {
BoldOutlined,
ItalicOutlined,
StrikethroughOutlined,
HighlightOutlined,
CodeOutlined,
FontSizeOutlined,
AlertOutlined,
PlusOutlined,
DownOutlined,
LinkOutlined,
FileMarkdownOutlined,
TableOutlined,
} from '@ant-design/icons';
import type { editor as monacoEditor } from 'monaco-editor';
import { SNIPPETS, PLATFORM_INSERT_IDS, applySnippet } from './mkdocs-snippets';
interface DocsEditorToolbarProps {
editorRef: React.RefObject<monacoEditor.IStandaloneCodeEditor | null>;
monacoRef: React.RefObject<typeof import('monaco-editor') | null>;
/** If true, show platform-specific inserts (video card, donate, etc.) */
showPlatformInserts?: boolean;
/** Custom handler for snippet IDs that need special treatment (modals, etc.) */
onCustomSnippet?: (snippetId: string) => boolean;
/** Background color — defaults to transparent */
background?: string;
/** Border color — defaults to rgba(255,255,255,0.08) */
borderColor?: string;
}
export default function DocsEditorToolbar({
editorRef,
monacoRef,
showPlatformInserts = false,
onCustomSnippet,
background = 'transparent',
borderColor = 'rgba(255,255,255,0.08)',
}: DocsEditorToolbarProps) {
const handleSnippet = useCallback((snippetId: string) => {
if (onCustomSnippet?.(snippetId)) return;
const snippet = SNIPPETS.find(s => s.id === snippetId);
if (!snippet || !editorRef.current || !monacoRef.current) return;
applySnippet(editorRef.current, snippet, monacoRef.current);
}, [editorRef, monacoRef, onCustomSnippet]);
const insertSnippets = SNIPPETS.filter(s =>
s.group === 'insert' && (showPlatformInserts || !PLATFORM_INSERT_IDS.has(s.id))
);
const getInsertIcon = (id: string) => {
if (id === 'link') return <LinkOutlined />;
if (id === 'image') return <FileMarkdownOutlined />;
if (id === 'table') return <TableOutlined />;
return <PlusOutlined />;
};
const btnStyle = { width: 26, height: 24 };
return (
<div
style={{
height: 28,
display: 'flex',
alignItems: 'center',
padding: '0 8px',
background,
borderBottom: `1px solid ${borderColor}`,
gap: 2,
flexShrink: 0,
overflow: 'hidden',
}}
>
<Tooltip title="Bold (Ctrl+B)" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<BoldOutlined />} onClick={() => handleSnippet('bold')} style={btnStyle} />
</Tooltip>
<Tooltip title="Italic (Ctrl+I)" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<ItalicOutlined />} onClick={() => handleSnippet('italic')} style={btnStyle} />
</Tooltip>
<Tooltip title="Strikethrough" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<StrikethroughOutlined />} onClick={() => handleSnippet('strikethrough')} style={btnStyle} />
</Tooltip>
<Tooltip title="Highlight" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<HighlightOutlined />} onClick={() => handleSnippet('highlight')} style={btnStyle} />
</Tooltip>
<Tooltip title="Inline Code" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<CodeOutlined />} onClick={() => handleSnippet('inline-code')} style={btnStyle} />
</Tooltip>
<Tooltip title="Keyboard Key" mouseEnterDelay={0.4}>
<Button type="text" size="small" style={{ ...btnStyle, fontSize: 11, fontWeight: 700 }} onClick={() => handleSnippet('kbd')}>K</Button>
</Tooltip>
<div style={{ width: 1, height: 16, background: borderColor, margin: '0 4px' }} />
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'heading').map(s => ({ key: s.id, label: s.label, icon: <FontSizeOutlined />, onClick: () => handleSnippet(s.id) })) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<FontSizeOutlined /> H <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
<div style={{ width: 1, height: 16, background: borderColor, margin: '0 4px' }} />
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'admonition').map(s => ({ key: s.id, label: s.label, icon: <AlertOutlined />, onClick: () => handleSnippet(s.id) })) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<AlertOutlined /> Admonitions <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'code').map(s => ({ key: s.id, label: s.label, icon: <CodeOutlined />, onClick: () => handleSnippet(s.id) })) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<CodeOutlined /> Code <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
<Dropdown menu={{ items: insertSnippets.map(s => ({
key: s.id,
label: s.label,
icon: getInsertIcon(s.id),
onClick: () => handleSnippet(s.id),
})) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
<PlusOutlined /> Insert <DownOutlined style={{ fontSize: 8 }} />
</Button>
</Dropdown>
</div>
);
}

View File

@ -30,6 +30,7 @@ import {
PlusOutlined, PlusOutlined,
CloseOutlined, CloseOutlined,
FileOutlined, FileOutlined,
FolderOpenOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor'; import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor';
import { isImageFile } from '@/hooks/useDocsEditor'; import { isImageFile } from '@/hooks/useDocsEditor';
@ -52,6 +53,7 @@ import { AdPickerModal } from '@/components/media/AdPickerModal';
import type { AdInsertResult } from '@/components/media/AdPickerModal'; import type { AdInsertResult } from '@/components/media/AdPickerModal';
import { PollInsertModal } from '@/components/scheduling/PollInsertModal'; import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal'; import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
import { MoveToModal } from '@/components/docs/MoveToModal';
import { useDocsCollaboration } from '@/hooks/useDocsCollaboration'; import { useDocsCollaboration } from '@/hooks/useDocsCollaboration';
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars'; import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
import { YTextareaBinding } from '@/lib/y-textarea'; import { YTextareaBinding } from '@/lib/y-textarea';
@ -259,6 +261,8 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
const [adPickerOpen, setAdPickerOpen] = useState(false); const [adPickerOpen, setAdPickerOpen] = useState(false);
const [pollInsertOpen, setPollInsertOpen] = useState(false); const [pollInsertOpen, setPollInsertOpen] = useState(false);
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false); const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
const [moveToModalOpen, setMoveToModalOpen] = useState(false);
const [moveSourcePath, setMoveSourcePath] = useState('');
const { const {
fileTree, fileTree,
@ -287,6 +291,7 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
onContentChange, onContentChange,
handleDelete, handleDelete,
handleModalOk, handleModalOk,
handleMoveFile,
handleNewFileRoot, handleNewFileRoot,
handleNewFolderRoot, handleNewFolderRoot,
refreshTree, refreshTree,
@ -430,6 +435,7 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
} }
items.push( items.push(
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } }, { key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
{ key: 'moveTo', icon: <FolderOpenOutlined />, label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } },
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) }, { key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
); );
return items; return items;
@ -910,6 +916,14 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
setWikiLinkPickerOpen(false); setWikiLinkPickerOpen(false);
}} }}
/> />
<MoveToModal
open={moveToModalOpen}
fileTree={fileTree}
sourcePath={moveSourcePath}
onMove={(targetDir) => { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }}
onClose={() => setMoveToModalOpen(false)}
/>
</> </>
); );
} }

View File

@ -0,0 +1,156 @@
import { useState, useMemo } from 'react';
import { Drawer, Input, List, theme, Typography } from 'antd';
import { FolderOutlined, HomeOutlined } from '@ant-design/icons';
import type { FileNode } from '@/types/api';
interface DirEntry {
path: string;
depth: number;
}
function collectDirs(nodes: FileNode[], exclude?: string, depth = 0): DirEntry[] {
const dirs: DirEntry[] = [];
for (const node of nodes) {
if (!node.isDirectory) continue;
if (exclude && (node.path === exclude || node.path.startsWith(exclude + '/'))) continue;
dirs.push({ path: node.path, depth });
if (node.children) dirs.push(...collectDirs(node.children, exclude, depth + 1));
}
return dirs;
}
interface MoveToModalProps {
open: boolean;
fileTree: FileNode[];
sourcePath: string;
onMove: (targetDir: string) => void;
onClose: () => void;
}
export function MoveToModal({ open, fileTree, sourcePath, onMove, onClose }: MoveToModalProps) {
const { token } = theme.useToken();
const [search, setSearch] = useState('');
const currentParent = useMemo(() => {
const lastSlash = sourcePath.lastIndexOf('/');
return lastSlash >= 0 ? sourcePath.substring(0, lastSlash) : '';
}, [sourcePath]);
const isSourceDir = useMemo(() => {
function find(nodes: FileNode[]): boolean {
for (const n of nodes) {
if (n.path === sourcePath) return n.isDirectory;
if (n.isDirectory && n.children && find(n.children)) return true;
}
return false;
}
return find(fileTree);
}, [fileTree, sourcePath]);
const allDirs = useMemo(
() => collectDirs(fileTree, isSourceDir ? sourcePath : undefined),
[fileTree, sourcePath, isSourceDir],
);
const filtered = useMemo(() => {
if (!search.trim()) return allDirs;
const q = search.toLowerCase();
return allDirs.filter(d => d.path.toLowerCase().includes(q));
}, [allDirs, search]);
const handleSelect = (dir: string) => {
onMove(dir);
setSearch('');
};
const handleClose = () => {
onClose();
setSearch('');
};
const fileName = sourcePath.split('/').pop() || sourcePath;
return (
<Drawer
title={`Move "${fileName}"`}
open={open}
onClose={handleClose}
width={420}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
>
<Input.Search
placeholder="Search directories..."
value={search}
onChange={e => setSearch(e.target.value)}
allowClear
autoFocus
style={{ marginBottom: 12 }}
/>
<div style={{ maxHeight: 360, overflow: 'auto' }}>
{/* Root directory option */}
{(!search.trim() || '/ (root)'.includes(search.toLowerCase())) && (
<div
style={{
padding: '8px 12px',
cursor: currentParent === '' ? 'not-allowed' : 'pointer',
opacity: currentParent === '' ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
gap: 8,
borderRadius: 4,
transition: 'background 0.15s',
}}
onClick={() => currentParent !== '' && handleSelect('')}
onMouseEnter={e => { if (currentParent !== '') (e.currentTarget.style.background = token.colorBgTextHover); }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
>
<HomeOutlined style={{ color: token.colorTextSecondary }} />
<span style={{ flex: 1 }}>/ (root)</span>
{currentParent === '' && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>current</Typography.Text>
)}
</div>
)}
<List
size="small"
dataSource={filtered}
locale={{ emptyText: 'No matching directories' }}
renderItem={item => {
const isCurrent = item.path === currentParent;
return (
<div
style={{
padding: '8px 12px',
paddingLeft: 12 + item.depth * 16,
cursor: isCurrent ? 'not-allowed' : 'pointer',
opacity: isCurrent ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
gap: 8,
borderRadius: 4,
transition: 'background 0.15s',
}}
onClick={() => !isCurrent && handleSelect(item.path)}
onMouseEnter={e => { if (!isCurrent) (e.currentTarget.style.background = token.colorBgTextHover); }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
>
<FolderOutlined style={{ color: token.colorTextSecondary, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.path}
</div>
{isCurrent && (
<Typography.Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>current</Typography.Text>
)}
</div>
);
}}
/>
</div>
</Drawer>
);
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Modal, Form, Input, DatePicker, Select, Switch, message, theme } from 'antd'; import { Drawer, Form, Input, DatePicker, Select, Switch, Button, message, theme } from 'antd';
import { FileMarkdownOutlined } from '@ant-design/icons'; import { FileMarkdownOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@ -84,7 +84,7 @@ export function NewBlogPostModal({
}; };
return ( return (
<Modal <Drawer
title={ title={
<span> <span>
<FileMarkdownOutlined style={{ marginRight: 8 }} /> <FileMarkdownOutlined style={{ marginRight: 8 }} />
@ -92,12 +92,17 @@ export function NewBlogPostModal({
</span> </span>
} }
open={open} open={open}
onCancel={handleClose} onClose={handleClose}
onOk={handleSubmit}
okText="Create"
confirmLoading={submitting}
destroyOnHidden
width={480} width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button type="primary" onClick={handleSubmit} loading={submitting}>
Create
</Button>
}
> >
{contextHolder} {contextHolder}
<Form <Form
@ -160,6 +165,6 @@ export function NewBlogPostModal({
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Drawer>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { Modal, Input, List, theme, Typography, Tag } from 'antd'; import { Drawer, Input, List, theme, Typography, Tag } from 'antd';
import { FileOutlined, PictureOutlined } from '@ant-design/icons'; import { FileOutlined, PictureOutlined } from '@ant-design/icons';
import type { FileNode } from '@/types/api'; import type { FileNode } from '@/types/api';
@ -62,13 +62,15 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
}; };
return ( return (
<Modal <Drawer
title="Insert Wiki Link" title="Insert Wiki Link"
open={open} open={open}
onCancel={() => { onClose(); setSearch(''); }} onClose={() => { onClose(); setSearch(''); }}
footer={null}
destroyOnHidden
width={420} width={420}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
> >
<Input.Search <Input.Search
placeholder="Search files..." placeholder="Search files..."
@ -148,6 +150,6 @@ export function WikiLinkPickerModal({ open, fileTree, onSelect, onClose }: WikiL
</div> </div>
)} )}
</div> </div>
</Modal> </Drawer>
); );
} }

View File

@ -0,0 +1,117 @@
import type { editor as monacoEditor } from 'monaco-editor';
export interface MkDocsSnippet {
id: string;
label: string;
group: 'formatting' | 'heading' | 'admonition' | 'code' | 'insert';
type: 'wrap' | 'block' | 'insert';
prefix?: string;
suffix?: string;
template?: string;
keybinding?: 'ctrl+b' | 'ctrl+i';
}
export const SNIPPETS: MkDocsSnippet[] = [
// Formatting
{ id: 'bold', label: 'Bold', group: 'formatting', type: 'wrap', prefix: '**', suffix: '**', keybinding: 'ctrl+b' },
{ id: 'italic', label: 'Italic', group: 'formatting', type: 'wrap', prefix: '*', suffix: '*', keybinding: 'ctrl+i' },
{ id: 'strikethrough', label: 'Strikethrough', group: 'formatting', type: 'wrap', prefix: '~~', suffix: '~~' },
{ id: 'highlight', label: 'Highlight', group: 'formatting', type: 'wrap', prefix: '==', suffix: '==' },
{ id: 'inline-code', label: 'Inline Code', group: 'formatting', type: 'wrap', prefix: '`', suffix: '`' },
{ id: 'kbd', label: 'Keyboard Key', group: 'formatting', type: 'wrap', prefix: '++', suffix: '++' },
// Headings
{ id: 'h1', label: 'Heading 1', group: 'heading', type: 'block', template: '# $CURSOR' },
{ id: 'h2', label: 'Heading 2', group: 'heading', type: 'block', template: '## $CURSOR' },
{ id: 'h3', label: 'Heading 3', group: 'heading', type: 'block', template: '### $CURSOR' },
{ id: 'h4', label: 'Heading 4', group: 'heading', type: 'block', template: '#### $CURSOR' },
// Admonitions
...(['note', 'warning', 'tip', 'danger', 'info', 'success', 'question', 'abstract', 'example', 'bug', 'quote'] as const).map((t) => ({
id: `admonition-${t}`,
label: `${t.charAt(0).toUpperCase() + t.slice(1)}`,
group: 'admonition' as const,
type: 'block' as const,
template: `!!! ${t} "Title"\n Content here`,
})),
{ id: 'admonition-collapsible-open', label: 'Collapsible (open)', group: 'admonition', type: 'block', template: '???+ note "Title"\n Content here' },
{ id: 'admonition-collapsible-closed', label: 'Collapsible (closed)', group: 'admonition', type: 'block', template: '??? note "Title"\n Content here' },
// Code
{ id: 'code-block', label: 'Code Block', group: 'code', type: 'block', template: '```python\n$CURSOR\n```' },
{ id: 'code-annotated', label: 'Annotated Code', group: 'code', type: 'block', template: '```python\ncode # (1)!\n```\n\n1. Annotation' },
{ id: 'mermaid', label: 'Mermaid Diagram', group: 'code', type: 'block', template: '```mermaid\ngraph LR\n A --> B\n```' },
// Inserts (standard markdown — no auth required)
{ id: 'link', label: 'Link', group: 'insert', type: 'wrap', prefix: '[', suffix: '](url)' },
{ id: 'image', label: 'Image', group: 'insert', type: 'insert', template: '![Alt text](image.png)' },
{ id: 'button', label: 'Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button }' },
{ id: 'button-primary', label: 'Primary Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button .md-button--primary }' },
{ id: 'icon', label: 'Material Icon', group: 'insert', type: 'insert', template: ':material-icon-name:' },
{ id: 'table', label: 'Table', group: 'insert', type: 'insert', template: '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |' },
{ id: 'tasklist', label: 'Task List', group: 'insert', type: 'insert', template: '- [ ] Task 1\n- [ ] Task 2\n- [x] Done' },
{ id: 'tabs', label: 'Tabs', group: 'insert', type: 'insert', template: '=== "Tab 1"\n\n Content\n\n=== "Tab 2"\n\n Content' },
{ id: 'math-block', label: 'Math Block', group: 'insert', type: 'block', template: '$$\n$CURSOR\n$$' },
{ id: 'footnote', label: 'Footnote', group: 'insert', type: 'insert', template: '[^1]\n\n[^1]: Text' },
{ id: 'def-list', label: 'Definition List', group: 'insert', type: 'insert', template: 'Term\n: Definition' },
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
// Platform-specific inserts (require auth — handled by DocsPage modals)
{ id: 'video-card', label: 'Video Card', group: 'insert', type: 'insert', template: '' },
{ id: 'photo-insert', label: 'Photo', group: 'insert', type: 'insert', template: '' },
{ id: 'donate-button', label: 'Donate Button', group: 'insert', type: 'insert', template: '' },
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
{ id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' },
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'insert', type: 'insert', template: '' },
{ id: 'wiki-link', label: 'Wiki Link [[]]', group: 'insert', type: 'insert', template: '' },
];
/** IDs of insert snippets that require authenticated API access (modal-based) */
export const PLATFORM_INSERT_IDS = new Set([
'video-card', 'photo-insert', 'donate-button', 'pricing-table',
'product-card', 'ad-insert', 'scheduling-poll', 'wiki-link',
]);
export function applySnippet(
ed: monacoEditor.IStandaloneCodeEditor,
snippet: MkDocsSnippet,
monaco: typeof import('monaco-editor'),
) {
const sel = ed.getSelection();
const model = ed.getModel();
if (!sel || !model) return;
const selectedText = model.getValueInRange(sel);
if (snippet.type === 'wrap' && snippet.prefix != null && snippet.suffix != null) {
if (selectedText) {
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.prefix + selectedText + snippet.suffix,
}]);
} else {
const placeholder = 'text';
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.prefix + placeholder + snippet.suffix,
}]);
const pos = sel.getStartPosition();
const startCol = pos.column + snippet.prefix.length;
ed.setSelection(new monaco.Selection(pos.lineNumber, startCol, pos.lineNumber, startCol + placeholder.length));
}
} else if (snippet.type === 'block' && snippet.template) {
const pos = sel.getStartPosition();
let text = snippet.template.replace('$CURSOR', selectedText);
const lineContent = model.getLineContent(pos.lineNumber);
if (pos.column > 1 && lineContent.substring(0, pos.column - 1).trim().length > 0) {
text = '\n' + text;
}
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text,
}]);
} else if (snippet.type === 'insert' && snippet.template) {
ed.executeEdits('mkdocs-snippet', [{
range: sel,
text: snippet.template,
}]);
}
ed.focus();
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography, Grid } from 'antd'; import { Drawer, Form, Input, Button, Tabs, Space, message, Table, Tag, Typography, Grid } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
@ -118,19 +118,19 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
]; ];
return ( return (
<Modal <Drawer
title={`Send Test Email: ${template.name}`} title={`Send Test Email: ${template.name}`}
open={open} open={open}
onCancel={onClose} onClose={onClose}
width={isMobile ? '95vw' : 900} width={isMobile ? '95vw' : 900}
footer={[ placement="right"
<Button key="cancel" onClick={onClose}> mask={false}
Cancel rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
</Button>, extra={
<Button key="send" type="primary" loading={sending} onClick={handleSend}> <Button type="primary" loading={sending} onClick={handleSend}>
Send Test Email Send Test Email
</Button>, </Button>
]} }
> >
<Space direction="vertical" style={{ width: '100%' }} size="large"> <Space direction="vertical" style={{ width: '100%' }} size="large">
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
@ -244,6 +244,6 @@ export default function TestEmailModal({ open, template, onClose, onSuccess }: T
]} ]}
/> />
</Space> </Space>
</Modal> </Drawer>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd'; import { Drawer, Checkbox, Button, Input, Space, Typography, Spin, Divider, message, theme } from 'antd';
import { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { mediaPublicApi } from '@/lib/media-public-api'; import { mediaPublicApi } from '@/lib/media-public-api';
@ -152,13 +152,19 @@ export default function AddToPlaylistModal({
}; };
return ( return (
<Modal <Drawer
title="Add to Playlist" title="Add to Playlist"
open={open} open={open}
onOk={handleSave} onClose={onClose}
onCancel={onClose} width={480}
confirmLoading={saving} placement="right"
okText="Save" mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSave} loading={saving}>
Save
</Button>
}
> >
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: 32 }}> <div style={{ textAlign: 'center', padding: 32 }}>
@ -238,6 +244,6 @@ export default function AddToPlaylistModal({
)} )}
</> </>
)} )}
</Modal> </Drawer>
); );
} }

View File

@ -1,17 +1,8 @@
import { Card, Tag, Badge } from 'antd'; import { Card, Tag, Badge } from 'antd';
import { FolderOpenOutlined, GlobalOutlined, PictureOutlined } from '@ant-design/icons'; import { FolderOpenOutlined, GlobalOutlined, PictureOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api'; import { useSignedMediaUrl } from '@/lib/media-url';
import type { PhotoAlbum } from '@/types/media'; import type { PhotoAlbum } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface AlbumCardProps { interface AlbumCardProps {
album: PhotoAlbum; album: PhotoAlbum;
onClick?: (album: PhotoAlbum) => void; onClick?: (album: PhotoAlbum) => void;
@ -19,6 +10,7 @@ interface AlbumCardProps {
export default function AlbumCard({ album, onClick }: AlbumCardProps) { export default function AlbumCard({ album, onClick }: AlbumCardProps) {
const coverUrl = album.coverThumbnailUrl; const coverUrl = album.coverThumbnailUrl;
const signedCoverUrl = useSignedMediaUrl(coverUrl);
return ( return (
<Card <Card
@ -35,9 +27,9 @@ export default function AlbumCard({ album, onClick }: AlbumCardProps) {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{coverUrl ? ( {coverUrl && signedCoverUrl ? (
<img <img
src={getAuthenticatedUrl(coverUrl)} src={signedCoverUrl}
alt={album.title} alt={album.title}
style={{ style={{
position: 'absolute', position: 'absolute',

View File

@ -7,16 +7,26 @@ import {
GlobalOutlined, GlobalOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { getAuthCallbacks } from '@/lib/api'; import { useSignedMediaUrl } from '@/lib/media-url';
import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media'; import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */ function PhotoThumbnail({ url, alt }: { url: string; alt: string }) {
function getAuthenticatedUrl(url: string): string { const signed = useSignedMediaUrl(url);
const { getAccessToken } = getAuthCallbacks(); if (!signed) {
const accessToken = getAccessToken(); return (
if (!accessToken) return url; <div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4 }} aria-label={alt} />
const separator = url.includes('?') ? '&' : '?'; );
return `${url}${separator}token=${accessToken}`; }
return (
<Image
src={signed}
width={60}
height={45}
style={{ objectFit: 'cover', borderRadius: 4 }}
preview={false}
alt={alt}
/>
);
} }
interface AlbumDetailDrawerProps { interface AlbumDetailDrawerProps {
@ -200,13 +210,7 @@ export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }:
<List.Item.Meta <List.Item.Meta
avatar={ avatar={
photo.thumbnailUrl ? ( photo.thumbnailUrl ? (
<Image <PhotoThumbnail url={photo.thumbnailUrl} alt={photo.title || photo.originalFilename || ''} />
src={getAuthenticatedUrl(photo.thumbnailUrl)}
width={60}
height={45}
style={{ objectFit: 'cover', borderRadius: 4 }}
preview={false}
/>
) : ( ) : (
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PictureOutlined style={{ color: '#555' }} /> <PictureOutlined style={{ color: '#555' }} />

View File

@ -1,4 +1,4 @@
import { Modal, Select, message } from 'antd'; import { Drawer, Select, Button, message } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage'; import { getErrorMessage } from '@/utils/getErrorMessage';
@ -37,13 +37,19 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
}; };
return ( return (
<Modal <Drawer
title={`Set Access Level (${videoIds.length} video${videoIds.length !== 1 ? 's' : ''})`} title={`Set Access Level (${videoIds.length} video${videoIds.length !== 1 ? 's' : ''})`}
open={open} open={open}
onOk={handleOk} onClose={onClose}
onCancel={onClose} width={480}
confirmLoading={loading} placement="right"
okText="Apply" mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleOk} loading={loading}>
Apply
</Button>
}
> >
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Select <Select
@ -54,6 +60,6 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
size="large" size="large"
/> />
</div> </div>
</Modal> </Drawer>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd'; import { Drawer, Select, Button, Input, Space, Typography, Spin, Divider, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import type { PlaylistSummary } from '@/types/media'; import type { PlaylistSummary } from '@/types/media';
@ -113,14 +113,19 @@ export default function BulkAddToPlaylistModal({
const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId); const selectedPlaylist = playlists.find((p) => p.id === selectedPlaylistId);
return ( return (
<Modal <Drawer
title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`} title={`Add ${videoIds.length} video${videoIds.length > 1 ? 's' : ''} to playlist`}
open={open} open={open}
onOk={handleAdd} onClose={onClose}
onCancel={onClose} width={480}
confirmLoading={saving} placement="right"
okText="Add" mask={false}
okButtonProps={{ disabled: !selectedPlaylistId }} rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleAdd} loading={saving} disabled={!selectedPlaylistId}>
Add
</Button>
}
> >
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: 32 }}> <div style={{ textAlign: 'center', padding: 32 }}>
@ -184,6 +189,6 @@ export default function BulkAddToPlaylistModal({
)} )}
</> </>
)} )}
</Modal> </Drawer>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Modal, Form, Input, message } from 'antd'; import { Drawer, Form, Input, Button, message } from 'antd';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import axios from 'axios'; import axios from 'axios';
@ -41,13 +41,19 @@ export default function CreateAlbumModal({
}; };
return ( return (
<Modal <Drawer
title="Create Album" title="Create Album"
open={open} open={open}
onOk={handleCreate} onClose={onClose}
onCancel={onClose} width={480}
confirmLoading={loading} placement="right"
okText="Create" mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleCreate} loading={loading}>
Create
</Button>
}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}> <Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
@ -62,6 +68,6 @@ export default function CreateAlbumModal({
{selectedPhotoIds.length} photo{selectedPhotoIds.length > 1 ? 's' : ''} will be added to this album {selectedPhotoIds.length} photo{selectedPhotoIds.length > 1 ? 's' : ''} will be added to this album
</div> </div>
)} )}
</Modal> </Drawer>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Drawer, Form, Input, Switch, Button, Space, message } from 'antd'; import { Drawer, Form, Input, Switch, Button, message } from 'antd';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import axios from 'axios'; import axios from 'axios';
@ -53,17 +53,12 @@ export default function CreatePlaylistModal({
}} }}
placement="right" placement="right"
width={420} width={420}
style={{ top: 64 }} mask={false}
styles={{ body: { paddingTop: 24 } }} rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={ extra={
<Space> <Button type="primary" onClick={handleSubmit} loading={loading}>
<Button onClick={() => { form.resetFields(); onClose(); }}> Create
Cancel </Button>
</Button>
<Button type="primary" onClick={handleSubmit} loading={loading}>
Create
</Button>
</Space>
} }
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">

View File

@ -130,7 +130,8 @@ export default function EditPlaylistModal({
}} }}
placement="right" placement="right"
width={isMobile ? '100%' : 520} width={isMobile ? '100%' : 520}
style={{ top: 64 }} mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
loading={loading} loading={loading}
> >
<Tabs <Tabs

View File

@ -148,15 +148,9 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
const connectSSE = async () => { const connectSSE = async () => {
try { try {
// Get auth token from localStorage // Get auth token from in-memory store (not localStorage)
const stored = localStorage.getItem('auth-storage'); const { useAuthStore } = await import('@/stores/auth.store');
let token = ''; const token = useAuthStore.getState().accessToken || '';
if (stored) {
try {
const parsed = JSON.parse(stored);
token = parsed?.state?.accessToken || '';
} catch {}
}
const response = await fetch(baseUrl, { const response = await fetch(baseUrl, {
headers: { headers: {

View File

@ -8,18 +8,9 @@ import {
FolderOutlined, FolderOutlined,
PictureOutlined, PictureOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api'; import { useSignedMediaUrl } from '@/lib/media-url';
import type { Photo } from '@/types/media'; import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface PhotoCardProps { interface PhotoCardProps {
photo: Photo; photo: Photo;
selected?: boolean; selected?: boolean;
@ -50,6 +41,7 @@ export default function PhotoCard({
onTogglePublish, onTogglePublish,
}: PhotoCardProps) { }: PhotoCardProps) {
const thumbnailUrl = photo.thumbnailUrl; const thumbnailUrl = photo.thumbnailUrl;
const signedThumbnailUrl = useSignedMediaUrl(thumbnailUrl);
const hoverActions = ( const hoverActions = (
<div <div
@ -112,9 +104,9 @@ export default function PhotoCard({
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{thumbnailUrl ? ( {thumbnailUrl && signedThumbnailUrl ? (
<img <img
src={getAuthenticatedUrl(thumbnailUrl)} src={signedThumbnailUrl}
alt={photo.title || photo.filename} alt={photo.title || photo.filename}
style={{ style={{
position: 'absolute', position: 'absolute',

View File

@ -1,17 +1,8 @@
import { Modal, Descriptions, Tag, Grid } from 'antd'; import { Modal, Descriptions, Tag, Grid } from 'antd';
import { CameraOutlined } from '@ant-design/icons'; import { CameraOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api'; import { useSignedMediaUrl } from '@/lib/media-url';
import type { Photo } from '@/types/media'; import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface PhotoViewerModalProps { interface PhotoViewerModalProps {
photo: Photo | null; photo: Photo | null;
open: boolean; open: boolean;
@ -22,9 +13,10 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.md; const isMobile = !screens.md;
if (!photo) return null; const adminImageUrl = photo ? `/media/photos/${photo.id}/image?size=large` : null;
const signedImageUrl = useSignedMediaUrl(adminImageUrl);
const adminImageUrl = `/media/photos/${photo.id}/image?size=large`; if (!photo) return null;
return ( return (
<Modal <Modal
@ -48,7 +40,7 @@ export default function PhotoViewerModal({ photo, open, onClose }: PhotoViewerMo
}} }}
> >
<img <img
src={getAuthenticatedUrl(adminImageUrl)} src={signedImageUrl}
alt={photo.title || photo.filename} alt={photo.title || photo.filename}
style={{ style={{
maxWidth: '100%', maxWidth: '100%',

View File

@ -1,4 +1,4 @@
import { Modal, DatePicker, Select, Space, Alert, Switch, message, Grid } from 'antd'; import { Drawer, DatePicker, Select, Space, Alert, Switch, Button, message, Grid } from 'antd';
import { ClockCircleOutlined } from '@ant-design/icons'; import { ClockCircleOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
@ -152,7 +152,7 @@ export default function SchedulePublishModal({
const serverTime = publishAt?.utc().format('YYYY-MM-DD HH:mm:ss UTC'); const serverTime = publishAt?.utc().format('YYYY-MM-DD HH:mm:ss UTC');
return ( return (
<Modal <Drawer
title={ title={
<Space> <Space>
<ClockCircleOutlined /> <ClockCircleOutlined />
@ -160,15 +160,16 @@ export default function SchedulePublishModal({
</Space> </Space>
} }
open={open} open={open}
onCancel={onClose} onClose={onClose}
onOk={handleSchedule}
okText={publishNow ? 'Publish Now' : 'Schedule'}
confirmLoading={loading}
width={isMobile ? '95vw' : 600} width={isMobile ? '95vw' : 600}
style={{ top: 20 }} placement="right"
styles={{ mask={false}
body: { maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' } rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
}} extra={
<Button type="primary" onClick={handleSchedule} loading={loading}>
{publishNow ? 'Publish Now' : 'Schedule'}
</Button>
}
aria-label="Schedule video publishing" aria-label="Schedule video publishing"
> >
{video && ( {video && (
@ -302,6 +303,6 @@ export default function SchedulePublishModal({
)} )}
</div> </div>
)} )}
</Modal> </Drawer>
); );
} }

View File

@ -2,19 +2,10 @@ import { Card, Checkbox, Tag, Spin } from 'antd';
import { ClockCircleOutlined, CheckCircleOutlined, ThunderboltFilled, LockOutlined, CrownOutlined } from '@ant-design/icons'; import { ClockCircleOutlined, CheckCircleOutlined, ThunderboltFilled, LockOutlined, CrownOutlined } from '@ant-design/icons';
import { useState } from 'react'; import { useState } from 'react';
import type { Video } from '@/types/media'; import type { Video } from '@/types/media';
import { getAuthCallbacks } from '@/lib/api'; import { useSignedMediaUrl } from '@/lib/media-url';
import VideoActions from './VideoActions'; import VideoActions from './VideoActions';
import ScheduleBadge from './ScheduleBadge'; import ScheduleBadge from './ScheduleBadge';
/** Append JWT access token as query param for <img>/<video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface VideoCardProps { interface VideoCardProps {
video: Video; video: Video;
selected: boolean; selected: boolean;
@ -48,6 +39,7 @@ export default function VideoCard({
}: VideoCardProps) { }: VideoCardProps) {
const [thumbnailLoading, setThumbnailLoading] = useState(true); const [thumbnailLoading, setThumbnailLoading] = useState(true);
const [thumbnailError, setThumbnailError] = useState(false); const [thumbnailError, setThumbnailError] = useState(false);
const signedThumbnailUrl = useSignedMediaUrl(video.thumbnailUrl);
const formatDuration = (seconds: number) => { const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
@ -76,10 +68,10 @@ export default function VideoCard({
}} }}
> >
{/* Thumbnail image or fallback */} {/* Thumbnail image or fallback */}
{video.thumbnailUrl && !thumbnailError ? ( {video.thumbnailUrl && !thumbnailError && signedThumbnailUrl ? (
<> <>
<img <img
src={getAuthenticatedUrl(video.thumbnailUrl)} src={signedThumbnailUrl}
alt={video.title} alt={video.title}
style={{ style={{
position: 'absolute', position: 'absolute',

View File

@ -2,6 +2,8 @@ import { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 're
import { Alert, Spin } from 'antd'; import { Alert, Spin } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons'; import { PlayCircleOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api'; import { getAuthCallbacks } from '@/lib/api';
import { signedMediaUrl } from '@/lib/media-url';
import { useHls } from '@/lib/use-hls';
export interface VideoMetadata { export interface VideoMetadata {
id: number; id: number;
@ -14,6 +16,8 @@ export interface VideoMetadata {
quality: string | null; quality: string | null;
streamUrl: string; streamUrl: string;
thumbnailUrl: string | null; thumbnailUrl: string | null;
hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null;
hlsManifestUrl?: string | null;
createdAt: string; createdAt: string;
} }
@ -67,6 +71,13 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Attach HLS when manifest is ready. Must be called unconditionally on
// every render (rules of hooks) — even before the loading/error early
// returns. The hook is a no-op when manifestUrl is null.
const hlsManifestUrl = metadata?.hlsStatus === 'READY' ? metadata.hlsManifestUrl ?? null : null;
const { error: hlsError } = useHls(videoRef, hlsManifestUrl);
const useMp4Src = !hlsManifestUrl || !!hlsError;
// Expose control methods via ref // Expose control methods via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
play: () => { play: () => {
@ -122,15 +133,6 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
fetchMetadata(); fetchMetadata();
}, [videoId]); }, [videoId]);
const appendToken = (url: string): string => {
if (!isAdmin) return url;
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}token=${accessToken}`;
};
const fetchMetadata = async () => { const fetchMetadata = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -157,10 +159,13 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const data = await response.json(); const data = await response.json();
// For admin, append token to stream/thumbnail URLs so <video>/<img> can access them // For admin previews of unpublished media, sign stream/thumbnail URLs
// (the legacy ?token=<JWT> path was removed 2026-04-12). The HLS
// manifest URL is already signed server-side by the metadata route, so
// we leave it untouched.
if (isAdmin) { if (isAdmin) {
if (data.streamUrl) data.streamUrl = appendToken(data.streamUrl); if (data.streamUrl) data.streamUrl = await signedMediaUrl(data.streamUrl);
if (data.thumbnailUrl) data.thumbnailUrl = appendToken(data.thumbnailUrl); if (data.thumbnailUrl) data.thumbnailUrl = await signedMediaUrl(data.thumbnailUrl);
} }
setMetadata(data); setMetadata(data);
@ -219,6 +224,10 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
? (metadata.height / metadata.width) * 100 ? (metadata.height / metadata.width) * 100
: 56.25; // Default to 16:9 : 56.25; // Default to 16:9
// (HLS attachment + MP4 fallback flag are computed at the top of the
// component, before the loading/error early returns, to satisfy the rules
// of hooks. See useMp4Src above.)
return ( return (
<div <div
style={{ style={{
@ -231,7 +240,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
> >
<video <video
ref={videoRef} ref={videoRef}
src={metadata.streamUrl} src={useMp4Src ? metadata.streamUrl : undefined}
poster={poster || metadata.thumbnailUrl || undefined} poster={poster || metadata.thumbnailUrl || undefined}
autoPlay={autoplay} autoPlay={autoplay}
controls={controls} controls={controls}

View File

@ -2,16 +2,8 @@ import { Modal } from 'antd';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { Video } from '@/types/media'; import type { Video } from '@/types/media';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { getAuthCallbacks } from '@/lib/api'; import { useSignedMediaUrl } from '@/lib/media-url';
import { useHls } from '@/lib/use-hls';
/** Append JWT access token as query param for <video> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface VideoViewerModalProps { interface VideoViewerModalProps {
video: Video | null; video: Video | null;
@ -24,6 +16,17 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
const [viewId, setViewId] = useState<number | null>(null); const [viewId, setViewId] = useState<number | null>(null);
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null); const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const lastWatchTime = useRef<number>(0); const lastWatchTime = useRef<number>(0);
const streamUrl = useSignedMediaUrl(video ? `/media/videos/${video.id}/stream` : null);
// Sign the HLS manifest URL too so admin previews of unpublished videos
// can play HLS. The hook is a no-op for nulls.
const hlsManifestUrl = useSignedMediaUrl(
video && video.hlsStatus === 'READY'
? `/media/videos/${video.id}/hls/master.m3u8`
: null,
);
const { error: hlsError } = useHls(videoRef, hlsManifestUrl ?? null);
// Fall back to MP4 src when HLS isn't ready or hls.js fatal-errored.
const useMp4Src = !hlsManifestUrl || !!hlsError;
useEffect(() => { useEffect(() => {
if (open && video) { if (open && video) {
@ -175,7 +178,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
> >
<video <video
ref={videoRef} ref={videoRef}
src={getAuthenticatedUrl(`/media/videos/${video.id}/stream`)} src={useMp4Src ? streamUrl : undefined}
controls controls
autoPlay autoPlay
style={{ style={{

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal, Radio, InputNumber, Spin, Typography, Space, theme, Grid } from 'antd'; import { Drawer, Radio, InputNumber, Spin, Typography, Space, Button, theme, Grid } from 'antd';
import { HeartOutlined, DollarOutlined, AppstoreOutlined } from '@ant-design/icons'; import { HeartOutlined, DollarOutlined, AppstoreOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
@ -80,14 +80,23 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
}); });
return ( return (
<Modal <Drawer
title="Insert Donate Block" title="Insert Donate Block"
open={open} open={open}
onCancel={onClose} onClose={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: variant === 'set-amount' && (!amount || amount <= 0) }}
width={isMobile ? '95vw' : 520} width={isMobile ? '95vw' : 520}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button
type="primary"
onClick={handleOk}
disabled={variant === 'set-amount' && (!amount || amount <= 0)}
>
Insert
</Button>
}
> >
<Paragraph type="secondary" style={{ marginBottom: 16 }}> <Paragraph type="secondary" style={{ marginBottom: 16 }}>
Choose a donation block style to insert into your document. Choose a donation block style to insert into your document.
@ -176,6 +185,6 @@ export function DonateInsertModal({ open, onClose, onInsert }: DonateInsertModal
</div> </div>
</Space> </Space>
</Radio.Group> </Radio.Group>
</Modal> </Drawer>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal, Card, Row, Col, Typography, Tag, Spin, Empty, Input, Grid } from 'antd'; import { Drawer, Card, Row, Col, Typography, Tag, Spin, Empty, Input, Button, Grid } from 'antd';
import { ShoppingCartOutlined, SearchOutlined, CheckCircleOutlined } from '@ant-design/icons'; import { ShoppingCartOutlined, SearchOutlined, CheckCircleOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
import type { Product, ProductType } from '@/types/api'; import type { Product, ProductType } from '@/types/api';
@ -35,8 +35,8 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
if (open && products.length === 0) { if (open && products.length === 0) {
setLoading(true); setLoading(true);
setError(null); setError(null);
axios.get('/api/payments/products') axios.get('/api/payments/products', { params: { limit: 50 } })
.then(({ data }) => setProducts(data)) .then(({ data }) => setProducts(data.products))
.catch(() => setError('Failed to load products')) .catch(() => setError('Failed to load products'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} }
@ -60,14 +60,19 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
}); });
return ( return (
<Modal <Drawer
title="Insert Product Card" title="Insert Product Card"
open={open} open={open}
onCancel={onClose} onClose={onClose}
onOk={handleOk}
okText="Insert"
okButtonProps={{ disabled: !selectedId }}
width={isMobile ? '95vw' : 640} width={isMobile ? '95vw' : 640}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleOk} disabled={!selectedId}>
Insert
</Button>
}
> >
<Paragraph type="secondary" style={{ marginBottom: 12 }}> <Paragraph type="secondary" style={{ marginBottom: 12 }}>
Select a product to embed as an inline purchase card. Select a product to embed as an inline purchase card.
@ -148,6 +153,6 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod
})} })}
</Row> </Row>
</div> </div>
</Modal> </Drawer>
); );
} }

View File

@ -21,9 +21,9 @@ export function ProductWidget({ productSlug, buttonText = 'Buy Now' }: ProductWi
return; return;
} }
axios.get('/api/payments/products') axios.get('/api/payments/products', { params: { limit: 50 } })
.then(({ data }) => { .then(({ data }) => {
const found = (data as Product[]).find(p => p.slug === productSlug); const found = (data.products as Product[]).find(p => p.slug === productSlug);
if (found) { if (found) {
setProduct(found); setProduct(found);
} else { } else {

View File

@ -1,4 +1,4 @@
import { Modal, Form, Input, Select, Switch, Button, Typography, message, Space } from 'antd'; import { Drawer, Form, Input, Select, Switch, Button, Typography, message, Space } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import { CopyOutlined } from '@ant-design/icons'; import { CopyOutlined } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@ -76,13 +76,20 @@ export default function CreateUserFromContactModal({
}; };
return ( return (
<Modal <Drawer
title="Create User Account" title="Create User Account"
open={open} open={open}
onCancel={() => { form.resetFields(); onClose(); }} onClose={() => { form.resetFields(); onClose(); }}
footer={null}
destroyOnHidden
width={480} width={480}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose
extra={
<Button type="primary" onClick={() => form.submit()} loading={submitting}>
Create Account
</Button>
}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Contact</Typography.Text> <Typography.Text type="secondary" style={{ fontSize: 12 }}>Contact</Typography.Text>
@ -123,17 +130,12 @@ export default function CreateUserFromContactModal({
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 0 }}> <Form.Item style={{ marginBottom: 0, display: 'none' }}>
<Space> <Button type="primary" htmlType="submit">
<Button type="primary" htmlType="submit" loading={submitting}> Create Account
Create Account </Button>
</Button>
<Button onClick={() => { form.resetFields(); onClose(); }}>
Cancel
</Button>
</Space>
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Drawer>
); );
} }

View File

@ -1,6 +1,6 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import { import {
Modal, Drawer,
Select, Select,
Typography, Typography,
Radio, Radio,
@ -155,7 +155,7 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
]; ];
return ( return (
<Modal <Drawer
title={ title={
<Space> <Space>
<SwapOutlined /> <SwapOutlined />
@ -163,14 +163,13 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
</Space> </Space>
} }
open={open} open={open}
onCancel={handleClose} onClose={handleClose}
width={700} width={700}
footer={[ placement="right"
<Button key="cancel" onClick={handleClose}> mask={false}
Cancel rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
</Button>, extra={
<Button <Button
key="merge"
type="primary" type="primary"
danger danger
onClick={handleMerge} onClick={handleMerge}
@ -178,8 +177,8 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
disabled={!sourcePerson} disabled={!sourcePerson}
> >
Confirm Merge Confirm Merge
</Button>, </Button>
]} }
> >
{/* Search for source person */} {/* Search for source person */}
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
@ -301,6 +300,6 @@ export default function MergeContactModal({ open, targetContact, onClose, onMerg
</Typography.Text> </Typography.Text>
</> </>
)} )}
</Modal> </Drawer>
); );
} }

View File

@ -17,6 +17,7 @@ const roleColors: Record<UserRole, string> = {
EVENTS_ADMIN: 'cyan', EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta', SOCIAL_ADMIN: 'magenta',
POLLS_ADMIN: 'geekblue', POLLS_ADMIN: 'geekblue',
ANALYTICS_ADMIN: 'processing',
USER: 'blue', USER: 'blue',
TEMP: 'default', TEMP: 'default',
}; };

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 }>( const { data } = await api.post<{ token: string; jitsiRoom: string; domain: string }>(
`/jitsi/meetings/${meeting.slug}/token`, `/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) { } catch (err: unknown) {
message.error(getErrorMessage(err, 'Failed to get moderator token')); message.error(getErrorMessage(err, 'Failed to get moderator token'));
} finally { } finally {

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal, Tabs, Table, Button, Input, Tag, Form, Select, DatePicker, TimePicker, Space, Spin, Typography, message } from 'antd'; import { Drawer, Tabs, Table, Button, Input, Tag, Form, Select, DatePicker, TimePicker, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api'; import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
@ -130,12 +130,14 @@ export function PollInsertModal({ open, onCancel, onInsert }: PollInsertModalPro
]; ];
return ( return (
<Modal <Drawer
open={open} open={open}
onCancel={onCancel} onClose={onCancel}
title="Insert Scheduling Poll" title="Insert Scheduling Poll"
footer={null}
width={700} width={700}
placement="right"
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
destroyOnClose destroyOnClose
> >
<Tabs <Tabs
@ -232,6 +234,6 @@ export function PollInsertModal({ open, onCancel, onInsert }: PollInsertModalPro
}, },
]} ]}
/> />
</Modal> </Drawer>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Modal, Input, Select, Typography, message, Space } from 'antd'; import { Drawer, Input, Select, Typography, message, Space, Button } from 'antd';
import { VideoCameraOutlined } from '@ant-design/icons'; import { VideoCameraOutlined } from '@ant-design/icons';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
@ -100,14 +100,19 @@ export default function RecommendVideoModal({
}; };
return ( return (
<Modal <Drawer
title="Recommend a Video" title="Recommend a Video"
open={open} open={open}
onOk={handleSend} onClose={onClose}
onCancel={onClose} width={480}
okText="Send" placement="right"
confirmLoading={sending} mask={false}
okButtonProps={{ disabled: !selectedFriendId || !selectedVideoId }} rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button type="primary" onClick={handleSend} loading={sending} disabled={!selectedFriendId || !selectedVideoId}>
Send
</Button>
}
> >
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
<div> <div>
@ -165,6 +170,6 @@ export default function RecommendVideoModal({
/> />
</div> </div>
</Space> </Space>
</Modal> </Drawer>
); );
} }

View File

@ -0,0 +1,81 @@
import { Card, Progress, Typography, Tag, Alert } from 'antd';
import { TrophyOutlined } from '@ant-design/icons';
import type { DashboardActionCampaign } from './types';
interface ActionCampaignCardProps {
campaign: DashboardActionCampaign;
}
export default function ActionCampaignCard({ campaign }: ActionCampaignCardProps) {
const percent = campaign.totalSteps > 0
? Math.round((campaign.completedSteps / campaign.totalSteps) * 100)
: 0;
const nextStep = [...campaign.steps]
.sort((a, b) => a.order - b.order)
.find((s) => !s.completed);
return (
<Card
styles={{
header: { borderBottom: '1px solid rgba(255,255,255,0.06)', padding: '12px 20px', minHeight: 'auto' },
body: { padding: '16px 20px' },
}}
title={
<span style={{ fontSize: 14, fontWeight: 600 }}>
<TrophyOutlined style={{ marginRight: 8, color: '#faad14' }} />
Your Goal
</span>
}
>
<Typography.Title level={5} style={{ margin: '0 0 4px' }}>
{campaign.title}
</Typography.Title>
{campaign.description && (
<Typography.Text type="secondary" style={{ fontSize: 13, display: 'block', marginBottom: 12 }}>
{campaign.description}
</Typography.Text>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{campaign.completedSteps} of {campaign.totalSteps} complete
</Typography.Text>
{campaign.rewardEarned && <Tag color="gold" style={{ margin: 0 }}>Reward Earned!</Tag>}
</div>
<Progress
percent={percent}
status={campaign.rewardEarned ? 'success' : 'active'}
showInfo={false}
strokeWidth={10}
style={{ marginBottom: 0 }}
/>
{campaign.rewardEarned && campaign.rewardText && (
<Alert
type="success"
showIcon
message="You've earned your reward!"
description={campaign.rewardText}
style={{ marginTop: 12 }}
/>
)}
{!campaign.rewardEarned && campaign.rewardText && (
<Typography.Text style={{ fontSize: 12, display: 'block', marginTop: 10, color: 'rgba(255,255,255,0.5)' }}>
<TrophyOutlined style={{ marginRight: 4 }} />
{campaign.rewardText}
</Typography.Text>
)}
{nextStep && !campaign.rewardEarned && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid rgba(255,255,255,0.06)' }}>
<Typography.Text style={{ fontSize: 13 }}>
<strong>Next:</strong>{' '}
<Typography.Text type="secondary" style={{ fontSize: 13 }}>{nextStep.label}</Typography.Text>
</Typography.Text>
</div>
)}
</Card>
);
}

View File

@ -0,0 +1,304 @@
import { useState } from 'react';
import { Card, Button, Typography, Tag, Space, App } from 'antd';
import {
VideoCameraOutlined,
MailOutlined,
FileTextOutlined,
CalendarOutlined,
EnvironmentOutlined,
TeamOutlined,
LinkOutlined,
CheckSquareOutlined,
CheckCircleFilled,
RightOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { DashboardActionCampaign, DashboardActionStep, ActionStepKind } from './types';
interface ActionStepsListProps {
campaign: DashboardActionCampaign;
onRefresh: () => void;
}
const KIND_ICONS: Record<ActionStepKind, React.ReactNode> = {
WATCH_VIDEO: <VideoCameraOutlined />,
SUBMIT_INFLUENCE: <MailOutlined />,
SIGN_PETITION: <FileTextOutlined />,
RSVP_EVENT: <CalendarOutlined />,
SIGNUP_SHIFT: <EnvironmentOutlined />,
JOIN_CHALLENGE: <TeamOutlined />,
VISIT_LINK: <LinkOutlined />,
CUSTOM: <CheckSquareOutlined />,
};
const KIND_LABELS: Record<ActionStepKind, string> = {
WATCH_VIDEO: 'Watch',
SUBMIT_INFLUENCE: 'Email',
SIGN_PETITION: 'Sign',
RSVP_EVENT: 'RSVP',
SIGNUP_SHIFT: 'Shift',
JOIN_CHALLENGE: 'Join',
VISIT_LINK: 'Visit',
CUSTOM: 'Action',
};
function resolveStepLink(step: DashboardActionStep): { to: string; external: boolean } | null {
if (step.targetUrl) {
const external = /^https?:\/\//i.test(step.targetUrl);
return { to: step.targetUrl, external };
}
if (!step.targetId) return null;
switch (step.kind) {
case 'WATCH_VIDEO':
return { to: `/gallery/watch/${step.targetId}`, external: false };
case 'SUBMIT_INFLUENCE':
return { to: `/campaign/${step.targetId}`, external: false };
case 'SIGN_PETITION':
return { to: `/petition/${step.targetId}`, external: false };
case 'RSVP_EVENT':
return { to: `/event/${step.targetId}`, external: false };
case 'SIGNUP_SHIFT':
return { to: `/volunteer/shifts?shiftId=${step.targetId}`, external: false };
case 'JOIN_CHALLENGE':
return { to: `/volunteer/challenges/${step.targetId}`, external: false };
default:
return null;
}
}
function HighlightedStep({
step,
onNavigate,
onSelfReport,
loading,
}: {
step: DashboardActionStep;
onNavigate: (step: DashboardActionStep) => void;
onSelfReport: (step: DashboardActionStep) => void;
loading: boolean;
}) {
const isSelfReport = step.kind === 'CUSTOM' || step.kind === 'VISIT_LINK';
const canNavigate = resolveStepLink(step) !== null;
return (
<div
style={{
background: 'linear-gradient(135deg, rgba(52,152,219,0.25) 0%, rgba(41,128,185,0.15) 100%)',
border: '1px solid rgba(52,152,219,0.3)',
borderRadius: 8,
padding: '16px 20px',
margin: '0 0 2px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<ThunderboltOutlined style={{ fontSize: 12, color: '#3498db' }} />
<Typography.Text strong style={{ fontSize: 12, color: '#3498db', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Next Up
</Typography.Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'rgba(52,152,219,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
color: '#3498db',
flexShrink: 0,
}}
>
{KIND_ICONS[step.kind]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Typography.Text strong style={{ fontSize: 15, display: 'block' }}>
{step.label}
</Typography.Text>
{step.description && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 2 }}>
{step.description}
</Typography.Text>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
{isSelfReport ? (
<>
{canNavigate && (
<Button size="middle" onClick={() => onNavigate(step)} icon={<RightOutlined />}>
Open
</Button>
)}
<Button
type="primary"
size="middle"
loading={loading}
onClick={() => onSelfReport(step)}
>
Mark as done
</Button>
</>
) : (
<Button
type="primary"
size="middle"
icon={<RightOutlined />}
onClick={() => onNavigate(step)}
disabled={!canNavigate}
>
Take Action
</Button>
)}
</div>
</div>
);
}
export default function ActionStepsList({ campaign, onRefresh }: ActionStepsListProps) {
const navigate = useNavigate();
const { message } = App.useApp();
const [completingStepId, setCompletingStepId] = useState<string | null>(null);
const handleSelfReport = async (step: DashboardActionStep) => {
setCompletingStepId(step.id);
try {
await api.post(`/action-campaigns/${campaign.slug}/steps/${step.id}/complete`);
message.success('Step marked as done');
onRefresh();
} catch {
message.error('Failed to mark step as done');
} finally {
setCompletingStepId(null);
}
};
const handleNavigate = (step: DashboardActionStep) => {
const link = resolveStepLink(step);
if (!link) return;
if (link.external) {
window.open(link.to, '_blank', 'noopener,noreferrer');
} else {
navigate(link.to);
}
};
const sortedSteps = [...campaign.steps].sort((a, b) => a.order - b.order);
const highlightedStep = sortedSteps.find((s) => !s.completed);
const remainingSteps = sortedSteps.filter((s) => s.id !== highlightedStep?.id);
return (
<Card
styles={{
header: { borderBottom: '1px solid rgba(255,255,255,0.06)', padding: '12px 20px', minHeight: 'auto' },
body: { padding: 0 },
}}
title={
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
{campaign.completedSteps} of {campaign.totalSteps} Actions
</Typography.Text>
}
>
{highlightedStep && (
<div style={{ padding: '12px 12px 0' }}>
<HighlightedStep
step={highlightedStep}
onNavigate={handleNavigate}
onSelfReport={handleSelfReport}
loading={completingStepId === highlightedStep.id}
/>
</div>
)}
{remainingSteps.map((step, i) => {
const isSelfReport = step.kind === 'CUSTOM' || step.kind === 'VISIT_LINK';
const canNavigate = resolveStepLink(step) !== null;
return (
<div
key={step.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 20px',
borderTop: (highlightedStep || i > 0) ? '1px solid rgba(255,255,255,0.04)' : undefined,
opacity: step.completed ? 0.55 : 1,
gap: 12,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
<div
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: step.completed ? '#52c41a' : 'rgba(255,255,255,0.06)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
flexShrink: 0,
color: step.completed ? '#fff' : 'rgba(255,255,255,0.5)',
}}
>
{step.completed ? <CheckCircleFilled /> : KIND_ICONS[step.kind]}
</div>
<div style={{ minWidth: 0 }}>
<Typography.Text strong style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', display: 'block', lineHeight: 1 }}>
{KIND_LABELS[step.kind]}
</Typography.Text>
<Typography.Text
style={{
fontSize: 13,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textDecoration: step.completed ? 'line-through' : 'none',
}}
>
{step.label}
</Typography.Text>
</div>
</div>
<div style={{ flexShrink: 0 }}>
{step.completed ? (
<Tag color="success" style={{ margin: 0, fontSize: 11 }}>Done</Tag>
) : isSelfReport ? (
<Space size={4}>
{canNavigate && (
<Button size="small" type="text" onClick={() => handleNavigate(step)}>Open</Button>
)}
<Button
size="small"
type="primary"
loading={completingStepId === step.id}
onClick={() => handleSelfReport(step)}
>
Mark done
</Button>
</Space>
) : (
<Button
size="small"
type="link"
onClick={() => handleNavigate(step)}
disabled={!canNavigate}
style={{ fontWeight: 600 }}
>
Take Action
</Button>
)}
</div>
</div>
);
})}
</Card>
);
}

View File

@ -0,0 +1,44 @@
import { Card, Typography } from 'antd';
import { TrophyOutlined, StarFilled } from '@ant-design/icons';
import type { DashboardPoints } from './types';
interface ActivityCardProps {
points: DashboardPoints;
}
export default function ActivityCard({ points }: ActivityCardProps) {
return (
<Card
styles={{
header: { borderBottom: '1px solid rgba(255,255,255,0.06)', padding: '12px 20px', minHeight: 'auto' },
body: { padding: '20px', textAlign: 'center' },
}}
title={
<span style={{ fontSize: 14, fontWeight: 600 }}>Activity</span>
}
extra={
<Typography.Text type="warning" style={{ fontSize: 13, fontWeight: 600 }}>
{points.total} pts
</Typography.Text>
}
>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'baseline', gap: 4, marginBottom: 4 }}>
<TrophyOutlined style={{ fontSize: 24, color: '#faad14' }} />
<Typography.Title level={2} style={{ margin: 0, color: '#faad14', fontWeight: 700 }}>
{points.total}
</Typography.Title>
</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
points earned
</Typography.Text>
{points.achievementCount > 0 && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid rgba(255,255,255,0.06)' }}>
<StarFilled style={{ color: '#f5222d', marginRight: 4 }} />
<Typography.Text style={{ fontSize: 13 }}>
{points.achievementCount} achievement{points.achievementCount !== 1 ? 's' : ''}
</Typography.Text>
</div>
)}
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More