Compare commits

..

129 Commits
v2.1.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
91db29402c Add Gitea SSO, fix security audit findings, harden production defaults
Gitea SSO: cookie-based single sign-on via nginx auth_request — sets
cml_session cookie on login/refresh, validates via /api/auth/gitea-sso-validate,
injects X-WEBAUTH-USER header for reverse proxy auth. Dedicated GITEA_SSO_SECRET
and SERVICE_PASSWORD_SALT env vars isolate secret rotation.

Security fixes from March 30 audit: IDOR on ticketed events (requireEventOwnership
middleware), IDOR on action items (admin/assignee/creator check), path traversal
on photos (resolve-based validation), CSV upload size limit (5MB), shared calendar
email exposure removed.

Gitea provisioner: auto-sync docs repo collaborator access based on role
(CONTENT_ROLES get write, SUPER_ADMIN gets admin). Gitea client extended
with collaborator management API methods.

Production hardening: NODE_ENV defaults to production in docker-compose.prod.yml,
Grafana anonymous auth disabled, install.sh branch ref updated to main.

Admin UI: moved docs reset from toolbar to MkDocs Settings danger zone,
improved collab Ctrl+S to explicitly save + cache-bust preview.

MkDocs site rebuild with updated repo data, upgrade screenshots, and content.

Bunker Admin
2026-03-31 11:20:01 -06:00
9321aeb263 Move SMS phone bridge from campaign_connector submodule into main repo
Consolidates the Termux SMS server code (previously in a separate
campaign_connector git submodule) into termux-sms/ at repo root.
Updates phone clone commands to use sparse checkout so only the
termux-sms/ directory is downloaded onto the Android device.

Bunker Admin
2026-03-31 11:04:14 -06:00
5d15b4cffa Add engagement scoring and homepage stats EventBus listeners
- Engagement scoring listener: 11 event subscriptions, weighted scoring
  (donation=50, subscription=40, shift=20, canvass=15, email=10, video=3),
  Redis sorted set leaderboard, per-contact score + last-activity tracking
- Homepage stats listener: 12 subscriptions, incremental Redis counters
  (emails, signups, donations, responses, canvass, videos), capped recent
  activity lists (last 20 per type), cache invalidation on data changes
- GET /api/homepage/live-stats — public real-time counters + recent activity
- GET /api/observability/engagement-leaderboard — admin top-N contacts
- Total: 8 listeners, 70 subscriptions across all modules

Bunker Admin
2026-03-31 10:21:05 -06:00
902adce646 Add Straw Polls feature: quick opinion polling with public landers, MkDocs widgets, and social integration
Full-stack implementation across 7 sprints:
- Backend: 5 Prisma models (StrawPoll, Option, Vote, Comment, Challenge), 4 enums, POLLS_ADMIN role,
  admin CRUD routes, public voting/SSE/widget endpoints, BullMQ auto-close queue, rate limiting
- Admin: StrawPollsPage with inline drawers (campaigns pattern), PollResults bar chart, sidebar under Advocacy
- Public: dedicated poll lander with real-time SSE updates, browse page, anonymous voting with token dedup
- MkDocs: straw-poll-widget.js hydration (inline vote + card link modes), GrapesJS block types
- Social: feed activity (poll_voted), friend badge integration, challenge notifications, notification preferences
- Feature flag: enablePolls toggle in Settings, FeatureGate, Zod schema

Bunker Admin
2026-03-31 10:16:56 -06:00
68434c51a6 Extend EventBus: RC notifications, CRM activity, Gancio migration, calendar source types
- Add 7 new RC notification types: campaign published, donations, subscriptions,
  SMS escalations, user approved, video published, ticketed events
- Add CRM activity entries for subscription activated and email bounced
- Migrate ticketed-events Gancio sync from inline calls to EventBus listener
- Add meeting.created/deleted events from jitsi.routes.ts
- Add SHIFT, MEETING, TICKETED_EVENT to CalendarItemSource enum (Prisma migration)
- Update calendar-sync listener to use proper source types instead of MANUAL
- Total: 45 listener subscriptions across 6 modules, zero inline sync calls remaining

Bunker Admin
2026-03-31 10:04:44 -06:00
075a7c8c4a Redesign hero section: two-column layout, showcase cards, animations
- Two-column desktop layout (left: text/CTAs, right: feature showcase)
- Typewriter rotating words animation cycling through 8 platform capabilities
- Feature showcase with 4 auto-rotating screenshot cards (campaigns, canvassing, media, shifts)
- Staggered feature pill badges linking to corresponding sections below
- Terminal quick-deploy snippet with copy-to-clipboard
- Canvas particle drift background animation
- Count-up stats with IntersectionObserver
- Real screenshots replace mock data in showcase cards
- Light/dark theme support for all new elements
- Mobile responsive: single-column stack, overflow containment, scaled typography
- prefers-reduced-motion respected across all animations

Bunker Admin
2026-03-31 10:01:48 -06:00
0c2ffe754e Harden Stripe payment integration: 15 security fixes from audit
Addresses 11 original findings (1 critical, 3 high, 4 medium, 3 low)
plus 4 additional findings from security review:

- Mask secrets in PUT /settings response (was leaking decrypted keys)
- Add paymentCheckoutRateLimit (10/hr/IP) to all 5 checkout endpoints
- Implement durable audit logging to payment_audit_log table
- Pin Stripe API version to 2026-01-28.clover (SDK v20.3.1)
- Add charge.dispute.created/closed webhook handlers with DISPUTED status
- Restore tickets on dispute won, handle charge_refunded closure
- Guard against sentinel passthrough corrupting stored Stripe keys
- Wrap refund DB updates in try/catch with webhook reconciliation fallback
- Add $transaction for product maxPurchases race condition
- Remove dead Payment model lookup from handleChargeRefunded
- Cap donation amount at $100k in both schemas
- Add requirePaymentsEnabled middleware on all checkout routes
- Remove Stripe internal IDs from CSV exports
- Add Cache-Control: no-store on admin settings responses

Bunker Admin
2026-03-31 08:34:23 -06:00
3de1d3fca5 Rewrite README as visual explainer with screenshots and docs link
Bunker Admin
2026-03-30 11:44:25 -06:00
a436c494fd Add duplication guard in collab onChange to detect and auto-fix doubled content
Y.js CRDT merges can duplicate content when a client reconnects after
external file modifications (e.g., API PUT while collab is active).
The guard detects when content is exactly doubled and auto-trims it.

Bunker Admin
2026-03-27 13:46:35 -06:00
078bb6e313 Fix collab preview refresh: use src reassignment with cache-buster instead of contentWindow.reload
Cross-origin iframes may silently fail on contentWindow.location.reload().
Use src reassignment with cache-buster query param to force fresh load.
Increased debounce to 2.5s to give MkDocs more time to rebuild.

Bunker Admin
2026-03-27 13:41:02 -06:00
eb16815f91 Fix blog hooks: unwrap API response envelope for authors and categories
The API returns { authors: {...} } and { categories: [...] } but hooks
were expecting the unwrapped values directly. Also add defensive guards
in components for undefined props during initial render.

Bunker Admin
2026-03-27 13:33:47 -06:00
8b9ab93856 Add docs CMS: blog authoring, access policies, sharing, version history, templates, metadata, search, Gitea auto-setup
7 documentation system features:
- Blog authoring: frontmatter panel, new post wizard, authors management
- Access policies: per-file/directory edit restrictions with role/user granularity
- Public sharing: share links with collaborative editing via dual JWT auth
- Version history: Gitea auto-commit on save, diff viewer, restore
- Document templates: 8 built-in templates (blog, guide, API ref, ADR, FAQ, etc.)
- Metadata dashboard: overview of all docs with warnings (no-tags, stale, etc.)
- Content search: in-file text search with line-level matches

Gitea auto-setup: one-click configuration of API token, repos, labels, OAuth app
- Backend service + startup hook (auto-configures if GITEA_ADMIN_PASSWORD set)
- Admin GUI wizard at /app/services/gitea/setup
- config.sh now prompts for Gitea admin password

Backend: 10 new files, 5 modified (3 models, 1 enum, 2 migrations, 30+ API endpoints)
Frontend: 13 new files, 3 modified (hooks, components, pages)

Bunker Admin
2026-03-27 13:28:52 -06:00
0fc9ea80bf Fix cookie Secure flag for HTTP dev, un-track generated nginx confs
- Cookie Secure flag now uses req.secure (respects trust proxy +
  X-Forwarded-Proto) instead of NODE_ENV. Works correctly over both
  HTTP (local dev) and HTTPS (production tunnel).
- SameSite=Strict over HTTPS, SameSite=Lax over HTTP (browsers reject
  Strict cookies over plain HTTP).
- Un-track generated nginx/conf.d/api.conf and services.conf (gitignored,
  regenerated from templates at startup).
- Update CLAUDE.md: ENCRYPTION_KEY now required in all environments.

Bunker Admin
2026-03-27 10:06:38 -06:00
776aa6fbac Fix nginx templates (source of truth) + add reservedCount migration
The generated api.conf and services.conf we edited earlier were overwritten
at container startup by envsubst from *.template files. Fix the actual
templates:
- api.conf.template: X-Forwarded-For → $remote_addr, add limit_req
- services.conf.template: add frame-ancestors CSP after proxy_hide_header
- Add Prisma migration file for ticket_tiers.reserved_count

Bunker Admin
2026-03-27 09:55:27 -06:00
b215cda018 Security audit follow-up: httpOnly cookies, ticket reservations, MongoDB keyfile
Deferred findings from the March 27 security audit, plus a bug fix:

MongoDB keyfile (bug fix):
- Generate replica.key on first boot via entrypoint script
- Fixes crash from --auth + --keyFile without an existing keyfile
- Applied to docker-compose.yml, docker-compose.prod.yml, CCP template

I7 — Ticket overselling prevention (reservation pattern):
- Add reservedCount field to TicketTier schema
- Atomically increment reservedCount inside transaction on checkout
- Release reservation on checkout.session.completed (webhook)
- Release reservation on checkout.session.expired (webhook)
- Include reservedCount in availability calculations

I17 — Move refresh token to httpOnly cookie:
- Server sets httpOnly SameSite=Strict cookie on login/register/refresh
- Cookie scoped to /api/auth path, secure in production
- Refresh/logout endpoints read from cookie (with body fallback for compat)
- Frontend no longer stores refreshToken in localStorage
- Auth store simplified: removed refreshToken from state + persistence
- API interceptor uses withCredentials:true for automatic cookie sending
- Updated media-api, media-public-api, QuickJoinPage, volunteer-invite
- Renamed getTokens → getAccessToken across all media components
- Install cookie-parser middleware

L2 — FeatureGate loading state:
- Show Skeleton instead of children while settings are loading
- Prevents briefly exposing disabled feature pages

Bunker Admin
2026-03-27 09:20:26 -06:00
82a66a97d0 Add MONGO_ROOT_PASSWORD to docs, config wizard, CCP, and prod compose
Follow-up to security audit commit — propagates MongoDB auth
(--auth flag) across all deployment paths:

- mkdocs environment-variables.md: add MONGO_ROOT_PASSWORD + MONGO_ROOT_USER,
  update ENCRYPTION_KEY description (now required in all environments),
  add to secret generation and full-stack variable lists
- config.sh: generate MONGO_ROOT_PASSWORD alongside Rocket.Chat credentials
- docker-compose.prod.yml: add --auth + credentials to MongoDB, update
  Rocket.Chat MONGO_URL with auth params
- CCP env.hbs: add MONGO_ROOT_USER/PASSWORD to chat block
- CCP docker-compose.yml.hbs: same MongoDB auth + MONGO_URL changes
- CCP secret-generator.ts: add mongoRootPassword to InstanceSecrets

Bunker Admin
2026-03-27 08:57:48 -06:00
1bf19fff0e Security audit: fix 30 findings across auth, IDOR, XSS, path traversal, infrastructure
Comprehensive 6-domain security audit addressing 8 Critical, 17 Important,
and 5 Low findings. Key fixes:

Critical:
- Strip PII from unauthenticated ticket lookup (IDOR)
- Add role+permission checks to event check-in routes
- Validate tier-to-event ownership on update/delete (IDOR)
- Fix path traversal in video replace (resolve + prefix check)
- Enable MongoDB authentication for Rocket.Chat
- Disable Grafana anonymous access
- Sanitize CSV exports against formula injection (payments)
- Apply DOMPurify to richDescription on public event page (XSS)

Important:
- Require current password for self-service password changes
- Atomic password reset token consumption (race condition fix)
- Scope postMessage to specific origin (not wildcard)
- Validate redirect parameter against open redirect
- Replace weak temp passwords (5760 values → crypto.randomBytes)
- Move shift capacity check inside transaction (TOCTOU fix)
- Fix EVENTS_ADMIN privilege inversion in ticketed events
- Make ENCRYPTION_KEY required (remove optional fallback)
- Add internal Prometheus metrics endpoint for Docker scraping
- Add nginx-level rate limiting (limit_req_zone)
- Fix X-Forwarded-For to use $remote_addr (prevents spoofing)
- Replace CSP stripping with frame-ancestors in embed proxies
- Remove error.message from Fastify 500 responses
- Strip PII from volunteer canvass address data
- Wrap GrapesJS output in {% raw %} to prevent Jinja2 SSTI
- Scope SSE token query param to /sse path only
- Sanitize Listmonk email query against injection

Bunker Admin
2026-03-27 08:47:24 -06:00
39d74e7b85 Add guided tour, media enhancements, error handling, and DevOps improvements
Major additions: onboarding tour system, correlation-id middleware, media
error handler, restore script, env validation script, Dockerignore files.
Updates across 70+ admin components for improved UX and error handling.

Bunker Admin
2026-03-26 10:31:51 -06:00
0c634e100f Replace custom code-server (9GB) with upstream LinuxServer image (~1GB)
Drop the custom Dockerfile.code-server that bundled Claude Code CLI,
Python/MkDocs tooling, and build-essential on top of codercom base.
Switch to the already-mirrored linuxserver/code-server image instead.

- Both compose files: use code-server:latest, LinuxServer env vars
  (PUID/PGID/DEFAULT_WORKSPACE), port 8443, /config mount layout
- Nginx configs + templates: proxy to :8443 instead of :8080
- API env default: CODE_SERVER_URL updated to :8443
- build-and-push.sh: remove --include-code-server flag
- upgrade.sh: remove code-server conditional rebuild + registry fallback
- install.sh: add --ignore-pull-failures for optional missing images
- .env.example, CCP templates, bunker-ops template: updated

Bunker Admin
2026-03-25 20:10:36 -06:00
f2284a9cdf Fix curl|bash install: redirect stdin from /dev/tty for interactive prompts
When piped (curl | bash), stdin is the curl output, not the terminal.
All read prompts in config.sh were reading leftover pipe data or EOF,
causing infinite password validation loops and garbage domain values.

Bunker Admin
2026-03-25 19:45:29 -06:00
7287328148 Harden install pipeline: health checks, log rotation, backup timer
- install.sh: Add Docker daemon check, 10GB disk space pre-flight,
  error handling on pull/up, post-startup health polling with crash
  detection, cleanup trap on failure
- docker-compose: Fix nginx/listmonk depends_on to service_healthy,
  add x-logging anchor (10m/3 files) to all ~39 services
- config.sh: Preserve existing secrets on re-run (reconfigure mode),
  add automated daily backup timer (systemd, 02:00, 30-day retention)
- mirror-images.sh: Fix gotify source tag (2.9.0 not v2.9.0)
- build-release.sh: Ensure mkdocs/docs and mkdocs/overrides dirs exist
- .env.example: Add COMPOSE_PROFILES variable

Bunker Admin
2026-03-25 19:33:11 -06:00
3262d92065 Remove hardcoded container names for multi-instance deployment support
- Dashboard: auto-discovers containers from Docker network via socket
  proxy API instead of hardcoded 30-name list. Labels derived from
  docker compose service metadata.
- Email/Settings: mailhog host read from env.SMTP_HOST instead of
  hardcoded 'mailhog-changemaker' string
- Pangolin: grafana container derived from env.GRAFANA_URL hostname;
  newt container/service names from NEWT_CONTAINER_NAME/NEWT_COMPOSE_SERVICE
- SSRF blocklist: built dynamically from all service URL env vars
  instead of hardcoded hostname list
- New env vars: DOCKER_NETWORK_NAME, DOCKER_PROXY_URL,
  NEWT_CONTAINER_NAME, NEWT_COMPOSE_SERVICE

Bunker Admin
2026-03-25 17:35:05 -06:00
204e90dd3b Fix config.sh: read embed ports from .env for Homepage services.yaml
Rocket.Chat and Jitsi embed ports were hardcoded in the Homepage
services.yaml generation. Now reads from .env so multi-instance
deployments with custom ports get correct Homepage dashboard links.

Bunker Admin
2026-03-25 15:32:53 -06:00
81026b38db Remove stale granular admin roles migration (subsumed by baseline)
Bunker Admin
2026-03-25 15:26:39 -06:00
abdfd50cb8 Make embed proxy ports configurable via env vars for multi-instance deployments
All 13 nginx embed proxy ports (8881-8895) are now driven by environment
variables instead of being hardcoded. This prevents port conflicts when
running multiple Changemaker instances on the same host.

Chain: .env → docker-compose port mappings → nginx container env →
entrypoint.sh envsubst → services.conf.template listen directives →
API /services/config endpoint → frontend buildServiceUrl().

Existing deployments are unaffected (all vars default to current values).

Bunker Admin
2026-03-25 15:25:00 -06:00
63e05adcee Bunch more stability fixes 2026-03-23 22:12:24 -06:00
a56f8446f7 Fix Pangolin setup: root domain support + disable SSO auth on resources
- Omit subdomain field for root domain resources (Pangolin rejects empty
  string but accepts absent field)
- Set sso:false + blockAccess:false after resource creation so resources
  are publicly accessible without Pangolin auth redirects
- Make subdomain optional in CreateHttpResourcePayload type
- Applied to both /setup and /sync endpoints

Bunker Admin
2026-03-23 15:47:57 -06:00
a5a83f2d04 Whitespace: trigger upgrade test
Bunker Admin
2026-03-23 14:58:16 -06:00
c701f77237 Add :latest fallback to registry image pull in upgrade.sh
When --use-registry is set, the upgrade script tries to pull images
tagged with the current HEAD SHA. If images were built at an earlier
commit, that SHA tag won't exist. Now tries :latest before falling
back to a full source build. Also applies to nginx and code-server.

Bunker Admin
2026-03-23 14:50:16 -06:00
44931260c4 Fix build-release.sh Gitea URL for host-side uploads
GITEA_URL points to the internal Docker hostname (gitea-changemaker:3000),
which is unreachable from the host. Derive external URL from GITEA_REGISTRY
instead, which already contains the external hostname.

Bunker Admin
2026-03-23 14:07:36 -06:00
e0fd4fd7b7 Update CLAUDE.md with consolidated architecture docs
Bunker Admin
2026-03-23 13:48:18 -06:00
0090bd2f54 some random png stuff 2026-03-23 13:07:05 -06:00
68ba45a689 Documentation editorial: Material theme hardening, metadata, and content polish
- Enable navigation.instant, prefetch, progress, content.code.select, content.tabs.link
- Fix edit_uri (main→v2), copyright year (2024→2024-2026), consent banner config
- Add abbreviations glossary (47 acronyms with hover tooltips via snippets auto-append)
- Add tags to all 72 doc pages with consistent taxonomy (audience/module/type)
- Add status:new badges to 16 recent feature pages, search:boost to 7 entry pages
- Rewrite Architecture page with 5 Mermaid diagrams and full component documentation
- Rewrite Troubleshooting page from 5 to 13 sections with actionable checklists
- Fix broken links (Monitoring/Contributing pointed to blog placeholder)
- Expand Admin Guide roles table from 5 to 11 roles
- Create custom 404 page, blog with authors and inaugural v2 announcement post
- Fresh Playwright screenshots for login, dashboard, campaigns, users, settings, locations, shifts
- Remove 5 test/dev files and orphan template override
- Add planning document (DOCS_NEXT_STEPS.md) for future editorial reference

Bunker Admin
2026-03-23 12:36:10 -06:00
bb1935027d Upgrade system finished 2026-03-22 21:47:09 -06:00
a71ba20176 Update CLAUDE.md with installer and release workflow documentation
- Quick Start: add pre-built install section with curl one-liner
- Directory Structure: expand scripts/ with all deployment scripts
- Registry Operations: add build-release, install commands, two-compose note
- V2 Gotchas: document release vs source detection, api/dist gitignore,
  api/.dockerignore purpose
- Key Config Files: add docker-compose.prod.yml and config.sh

Bunker Admin
2026-03-22 20:38:36 -06:00
8e6f0996de Add pre-built image installer and release tarball system
New install method: curl one-liner downloads a lightweight release
tarball (~9 MB) and runs the config wizard. No git clone needed,
no TypeScript compilation — pulls pre-built images from Gitea registry.

- docker-compose.prod.yml: production compose without build blocks or
  source code volume mounts; IMAGE_TAG defaults to latest
- scripts/install.sh: curl-friendly installer (downloads tarball,
  extracts, runs config.sh)
- scripts/build-release.sh: creates release tarball from dev repo
  with only runtime files (configs, scripts, docs, empty data dirs)
- config.sh: release-mode detection (VERSION file + no .git dir),
  auto-sets IMAGE_TAG=latest and NODE_ENV=production
- upgrade.sh: release-mode upgrade path (downloads new tarball from
  Gitea Releases API instead of git pull, always uses registry mode)
- upgrade-check.sh: release-mode version check via Gitea API
- .gitignore: exclude releases/ and api/dist/
- Docs: updated getting-started with pre-built install instructions

Bunker Admin
2026-03-22 20:34:49 -06:00
f550423c3f Add registry module and api .dockerignore to fix production build
- Create api/src/modules/registry/ (service + routes) so server.ts
  import resolves and TypeScript compiles all 38 modules cleanly
- Add api/.dockerignore to exclude stale local dist/ from Docker build
  context, preventing old compiled output from persisting in images
- Registry routes: GET /status (Gitea packages API), POST /build-push
  and POST /mirror (write trigger files for host watcher, SUPER_ADMIN only)

Bunker Admin
2026-03-22 19:49:36 -06:00
be2fa5d80b Fix media-api restart loop and add registry build scripts
- Fix @/utils/logger path alias (tsc doesn't transform @/ in output)
- Add JWT_INVITE_SECRET to media-api compose environment block
- Fix redis-exporter depends_on to use service name not container name
- Fix upgrade.sh: restore tracked files deleted by restore_user_paths
- Add scripts/build-and-push.sh for building + pushing production images
- Add scripts/mirror-images.sh for mirroring third-party images

Bunker Admin
2026-03-22 19:17:10 -06:00
e6e324262f Add JWT_INVITE_SECRET to API container environment
docker-compose.yml explicitly enumerates each env var passed to
containers, so the new JWT_INVITE_SECRET needed to be wired through
the environment block or the API would fail Zod env validation at
startup.

Bunker Admin
2026-03-22 12:40:34 -06:00
647efffdc4 Security hardening: JWT algorithm pinning, key separation, injection fixes
- Pin HS256 algorithm on all jwt.verify() calls (9 sites) and jwt.sign()
  calls (3 sites) — prevents algorithm confusion attacks
- Add JWT_INVITE_SECRET env var; volunteer invite tokens now use a
  dedicated key separate from access/refresh secrets
- Remove req.query.secret fallback from Listmonk webhook route — secrets
  must not appear in nginx access logs
- Replace child_process.spawn in email template seed endpoint with direct
  function import; add require.main guard to seed script
- Add sanitizeCsvField() to location CSV export to prevent formula
  injection in Excel/Sheets (=, +, -, @ prefix → apostrophe prefix)
- Cap QR endpoint text input at 2000 chars to prevent DoS via large payloads
- Fix pre-existing TS errors: type participantNeeds as UpsertNeedsInput
  in meeting-planner service; add sso field to UpdateResourcePayload

Bunker Admin
2026-03-22 12:35:04 -06:00
1707 changed files with 112190 additions and 67668 deletions

View File

@ -11,6 +11,21 @@
# - NEVER commit .env to version control # - NEVER commit .env to version control
# ============================================================================== # ==============================================================================
# ==============================================================================
# MINIMUM VIABLE SETUP (required — change these before deploying)
# ==============================================================================
# 1. V2_POSTGRES_PASSWORD — database password (8+ chars)
# 2. REDIS_PASSWORD — cache password (8+ chars)
# 3. JWT_ACCESS_SECRET — openssl rand -hex 32
# 4. JWT_REFRESH_SECRET — openssl rand -hex 32 (different from above)
# 5. JWT_INVITE_SECRET — openssl rand -hex 32 (different from above)
# 6. ENCRYPTION_KEY — openssl rand -hex 32 (different from JWT secrets)
# 7. INITIAL_ADMIN_PASSWORD — 12+ chars, uppercase + lowercase + digit
# 8. DOMAIN — your deployment domain (default: cmlite.org)
#
# Everything below these 8 values works with defaults for development.
# ==============================================================================
# --- General --- # --- General ---
NODE_ENV=development NODE_ENV=development
# Root domain serves MkDocs documentation site only # Root domain serves MkDocs documentation site only
@ -29,14 +44,30 @@ V2_POSTGRES_PORT=5433
# --- JWT Auth --- # --- JWT Auth ---
JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32 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_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
# BREAKING CHANGE (2026-04-12): both GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
# are now REQUIRED (min 32 chars). The previous fallback to JWT_ACCESS_SECRET
# has been removed — a JWT leak must not compromise SSO cookies or service
# account passwords. Both values must be distinct from each other and from
# all JWT_* secrets. Generate with: openssl rand -hex 32
# 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
# Change these before running the seed script in production # Change these before running the seed script in production
@ -57,6 +88,32 @@ ADMIN_URL=http://localhost:3000
NGINX_HTTP_PORT=80 NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443 NGINX_HTTPS_PORT=443
# --- Embed Proxy Ports ---
# Dedicated nginx ports for iframe embedding without DNS/subdomain.
# Change these to avoid port conflicts when running multiple instances on one host.
NOCODB_EMBED_PORT=8881
N8N_EMBED_PORT=8882
GITEA_EMBED_PORT=8883
MAILHOG_EMBED_PORT=8884
MINI_QR_EMBED_PORT=8885
EXCALIDRAW_EMBED_PORT=8886
HOMEPAGE_EMBED_PORT=8887
VAULTWARDEN_EMBED_PORT=8890
ROCKETCHAT_EMBED_PORT=8891
GANCIO_EMBED_PORT=8892
JITSI_EMBED_PORT=8893
GRAFANA_EMBED_PORT=8894
ALERTMANAGER_EMBED_PORT=8895
# --- Docker / Container Management ---
# Docker network name (used by dashboard to auto-discover containers)
DOCKER_NETWORK_NAME=changemaker-lite
# Docker socket proxy URL (read-only container inspection)
DOCKER_PROXY_URL=http://docker-socket-proxy:2375
# Newt tunnel container (for Pangolin restart/status checks)
NEWT_CONTAINER_NAME=newt-changemaker
NEWT_COMPOSE_SERVICE=newt
# --- SMTP / Email --- # --- SMTP / Email ---
SMTP_HOST=mailhog-changemaker SMTP_HOST=mailhog-changemaker
SMTP_PORT=1025 SMTP_PORT=1025
@ -79,6 +136,8 @@ LISTMONK_WEB_ADMIN_USER=admin
LISTMONK_WEB_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS LISTMONK_WEB_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# API user (auto-created by listmonk-init container, used by V2 API for sync) # API user (auto-created by listmonk-init container, used by V2 API for sync)
# Generate token: openssl rand -hex 16 # Generate token: openssl rand -hex 16
# NOTE: LISTMONK_ADMIN_USER/PASSWORD are what the V2 API uses to connect.
# They MUST match LISTMONK_API_USER/TOKEN (same credentials, different var names).
LISTMONK_API_USER=v2-api LISTMONK_API_USER=v2-api
LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16 LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16
LISTMONK_ADMIN_USER=v2-api LISTMONK_ADMIN_USER=v2-api
@ -129,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
@ -146,11 +212,35 @@ VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true
# Preview Links (Feb 2026) # Preview Links (Feb 2026)
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24 VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
# --- Gitea --- # --- Container Registry ---
# Gitea registry for pre-built production images.
# Set IMAGE_TAG to a commit SHA (or 'latest') to pull pre-built images instead of building from source.
# Leave IMAGE_TAG blank/unset (defaults to 'local') to build locally from source.
GITEA_REGISTRY=gitea.bnkops.com/admin
IMAGE_TAG=
# Docker Compose profiles — set to 'monitoring' to include Prometheus/Grafana/Alertmanager
# in every 'docker compose up -d'. Leave blank to start monitoring separately.
COMPOSE_PROFILES=
# Credentials used by the registry status API endpoint (GET /api/registry/status)
# For docker push/pull, run: docker login gitea.bnkops.com
GITEA_REGISTRY_USER=admin
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 (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
@ -163,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=
@ -195,29 +287,25 @@ MKDOCS_DOCS_PATH=/mkdocs/docs
# --- Code Server --- # --- Code Server ---
CODE_SERVER_PORT=8888 CODE_SERVER_PORT=8888
CODE_SERVER_URL=http://code-server:8080 CODE_SERVER_URL=http://code-server-changemaker:8443
USER_NAME=coder USER_NAME=coder
# --- Homepage --- # --- Homepage ---
HOMEPAGE_PORT=3010 HOMEPAGE_PORT=3010
HOMEPAGE_EMBED_PORT=8887
HOMEPAGE_VAR_BASE_URL=http://localhost HOMEPAGE_VAR_BASE_URL=http://localhost
# --- Mini QR --- # --- Mini QR ---
MINI_QR_PORT=8089 MINI_QR_PORT=8089
MINI_QR_URL=http://mini-qr:8080 MINI_QR_URL=http://mini-qr:8080
MINI_QR_EMBED_PORT=8885
# --- Excalidraw (Collaborative Whiteboard) --- # --- Excalidraw (Collaborative Whiteboard) ---
EXCALIDRAW_PORT=8090 EXCALIDRAW_PORT=8090
EXCALIDRAW_URL=http://excalidraw-changemaker:80 EXCALIDRAW_URL=http://excalidraw-changemaker:80
EXCALIDRAW_EMBED_PORT=8886
EXCALIDRAW_WS_URL=wss://draw.cmlite.org EXCALIDRAW_WS_URL=wss://draw.cmlite.org
# --- Vaultwarden (Password Manager) --- # --- Vaultwarden (Password Manager) ---
VAULTWARDEN_PORT=8445 VAULTWARDEN_PORT=8445
VAULTWARDEN_URL=http://vaultwarden-changemaker:80 VAULTWARDEN_URL=http://vaultwarden-changemaker:80
VAULTWARDEN_EMBED_PORT=8890
# Admin panel token (access at /admin) — generate with: openssl rand -hex 32 # Admin panel token (access at /admin) — generate with: openssl rand -hex 32
VAULTWARDEN_ADMIN_TOKEN= VAULTWARDEN_ADMIN_TOKEN=
# MUST use HTTPS — Bitwarden web vault enforces HTTPS for account creation # MUST use HTTPS — Bitwarden web vault enforces HTTPS for account creation
@ -290,13 +378,14 @@ ENABLE_CHAT=false
ROCKETCHAT_ADMIN_USER=rcadmin ROCKETCHAT_ADMIN_USER=rcadmin
ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
ROCKETCHAT_URL=http://rocketchat-changemaker:3000 ROCKETCHAT_URL=http://rocketchat-changemaker:3000
ROCKETCHAT_EMBED_PORT=8891 # MongoDB credentials for Rocket.Chat (required — MongoDB runs with --auth)
MONGO_ROOT_USER=rocketchat
MONGO_ROOT_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# --- Gancio (Event Management) --- # --- Gancio (Event Management) ---
# Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh) # Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh)
GANCIO_PORT=8092 GANCIO_PORT=8092
GANCIO_URL=http://gancio-changemaker:13120 GANCIO_URL=http://gancio-changemaker:13120
GANCIO_EMBED_PORT=8892
GANCIO_BASE_URL=https://events.cmlite.org GANCIO_BASE_URL=https://events.cmlite.org
# Gancio admin credentials for shift-to-event sync (OAuth login) # Gancio admin credentials for shift-to-event sync (OAuth login)
GANCIO_ADMIN_USER=admin GANCIO_ADMIN_USER=admin
@ -316,18 +405,12 @@ JITSI_APP_SECRET=GENERATE_WITH_openssl_rand_hex_32
# Generate each with: openssl rand -hex 16 # Generate each with: openssl rand -hex 16
JITSI_JICOFO_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16 JITSI_JICOFO_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
JITSI_JVB_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16 JITSI_JVB_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16
# Embed port for admin iframe
JITSI_EMBED_PORT=8893
JITSI_URL=http://jitsi-web-changemaker:80 JITSI_URL=http://jitsi-web-changemaker:80
# JVB public IP (required for NAT traversal — set to server's public IP in production) # JVB public IP (required for NAT traversal — set to server's public IP in production)
JVB_ADVERTISE_IP= JVB_ADVERTISE_IP=
# JVB UDP port for media traffic (must be open in firewall) # JVB UDP port for media traffic (must be open in firewall)
JVB_PORT=10000 JVB_PORT=10000
# --- Monitoring Embed Ports (iframe embedding) ---
GRAFANA_EMBED_PORT=8894
ALERTMANAGER_EMBED_PORT=8895
# --- SMS Campaigns (Termux Android Bridge) --- # --- SMS Campaigns (Termux Android Bridge) ---
# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative # ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative
# URL + API key are typically managed via admin Settings page (DB overrides env) # URL + API key are typically managed via admin Settings page (DB overrides env)
@ -340,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
@ -357,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)

38
.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/
@ -51,14 +55,44 @@ docker-compose.override.yml
core.* core.*
*/core.* */core.*
# MkDocs core binary # MkDocs core binary and container-generated assets (owned by root, not stashable)
/mkdocs/core /mkdocs/core
/mkdocs/assets/
# Upgrade artifacts # Upgrade artifacts
/logs/ /logs/
/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)
/releases/
# API compiled output (generated by tsc, baked into Docker images)
/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/
# 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,194 +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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

453
CLAUDE.md
View File

@ -6,19 +6,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`. Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
**Current state:** V2 rebuild substantially complete on the `v2` branch. Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap. **Current state:** V2 rebuild substantially complete (merged to `main`). Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
**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
--- ---
@ -45,15 +49,7 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
- **RBAC:** `requireRole(...roles)`, `requireNonTemp`, `authenticate` middleware; `SUPER_ADMIN` implicitly bypasses all role checks - **RBAC:** `requireRole(...roles)`, `requireNonTemp`, `authenticate` middleware; `SUPER_ADMIN` implicitly bypasses all role checks
- **Module-specific role groups** (defined in `api/src/utils/roles.ts`): `INFLUENCE_ROLES`, `MAP_ROLES`, `BROADCAST_ROLES`, `CONTENT_ROLES`, `MEDIA_ROLES`, `PAYMENTS_ROLES`, `EVENTS_ROLES`, `SOCIAL_ROLES`, `SYSTEM_ROLES`, `SCHEDULING_ROLES` - **Module-specific role groups** (defined in `api/src/utils/roles.ts`): `INFLUENCE_ROLES`, `MAP_ROLES`, `BROADCAST_ROLES`, `CONTENT_ROLES`, `MEDIA_ROLES`, `PAYMENTS_ROLES`, `EVENTS_ROLES`, `SOCIAL_ROLES`, `SYSTEM_ROLES`, `SCHEDULING_ROLES`
- **User management:** `SUPER_ADMIN` always; other admins need `permissions.canManageUsers: true` for write operations - **User management:** `SUPER_ADMIN` always; other admins need `permissions.canManageUsers: true` for write operations
- **Security features:** - **Security:** See Security & Configuration section below + `SECURITY_AUDIT_2025-02-11.md`
- Refresh token rotation (atomic transaction)
- User enumeration prevention (401 not 404)
- Rate limiting on auth endpoints (10/min)
- Redis authentication required
- XSS/injection prevention (HTML escaping)
- Path traversal protection
- Encryption key for DB secrets (ENCRYPTION_KEY env var)
- Security audit complete (13 findings addressed, see `SECURITY_AUDIT_2025-02-11.md`)
### Email Systems ### Email Systems
@ -67,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)
@ -78,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
@ -98,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)
@ -127,70 +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
│ │ ├── DashboardPage.tsx # Admin dashboard │ │ ├── map/ # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboard
│ │ ├── UsersPage.tsx # User CRUD │ │ ├── media/ # Library, Playlists, Analytics, Gallery Ads, Comment Moderation
│ │ ├── SettingsPage.tsx # Global site settings │ │ ├── payments/ # Dashboard, Products, Plans, Donations, Subscribers, Settings
│ │ ├── influence/ │ │ ├── social/ # Dashboard, Graph, Moderation, Referrals, Spotlights, Challenges
│ │ │ ├── CampaignsPage.tsx # Campaign management │ │ ├── sms/ # Dashboard, Contacts, Campaigns, Conversations, Templates, Setup
│ │ │ ├── ResponsesPage.tsx # Response moderation │ │ ├── events/ # Ticketed Events, Event Detail, Check-in Scanner
│ │ │ ├── RepresentativesPage.tsx # Rep cache admin │ │ ├── volunteer/ # Map, Shifts, Routes, Calendar, Friends, Profile, Groups, Achievements
│ │ │ └── EmailQueuePage.tsx # Queue monitoring │ │ ├── public/ # Homepage, Campaigns, Map, Events, Media Gallery, Pricing, Donations, Meet
│ │ ├── map/ │ │ └── (root) # Dashboard, Users, Settings, Docs*, MeetingPlanner, Observability, etc.
│ │ │ ├── LocationsPage.tsx # Location CRUD + CSV + geocoding │ ├── stores/ # 9 Zustand stores (auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking)
│ │ │ ├── CutsPage.tsx # Cut table + map drawing editor │ ├── lib/ # api.ts, media-api.ts, media-public-api.ts, nav-defaults.ts, service-url.ts, y-textarea.ts
│ │ │ ├── ShiftsPage.tsx # Shift CRUD + signups drawer
│ │ │ ├── MapSettingsPage.tsx # Map settings
│ │ │ └── DataQualityDashboardPage.tsx # Geocoding quality metrics
│ │ ├── CanvassDashboardPage.tsx # Admin canvass overview
│ │ ├── WalkSheetPage.tsx # Printable walk sheet
│ │ ├── CutExportPage.tsx # Printable location report
│ │ ├── volunteer/
│ │ │ ├── VolunteerMapPage.tsx # Full-screen GPS canvass map
│ │ │ ├── VolunteerShiftsPage.tsx # Assigned shifts
│ │ │ ├── MyActivityPage.tsx # Visit history + outcomes
│ │ │ └── MyRoutesPage.tsx # Route history
│ │ ├── public/
│ │ │ ├── CampaignsListPage.tsx # Public campaign listing
│ │ │ ├── CampaignPage.tsx # Campaign detail + email form
│ │ │ ├── ResponseWallPage.tsx # Public response wall
│ │ │ ├── MapPage.tsx # Public Leaflet map
│ │ │ ├── ShiftsPage.tsx # Public shift signup
│ │ │ ├── LandingPage.tsx # Rendered landing page (/p/:slug)
│ │ │ ├── MediaGalleryPage.tsx # Public video gallery
│ │ │ └── MediaViewerPage.tsx # Video detail page
│ │ ├── media/
│ │ │ ├── LibraryPage.tsx # Video library management
│ │ │ ├── SharedMediaPage.tsx # Public gallery admin
│ │ │ └── MediaJobsPage.tsx # Job queue monitoring
│ │ ├── LandingPagesPage.tsx # Landing page manager
│ │ ├── PageEditorPage.tsx # Full-screen GrapesJS editor
│ │ ├── EmailTemplatesPage.tsx # Email template CRUD
│ │ ├── EmailTemplateEditorPage.tsx # Email template editor
│ │ ├── ListmonkPage.tsx # Newsletter sync management
│ │ ├── PangolinPage.tsx # Tunnel setup wizard
│ │ ├── DocsPage.tsx # MkDocs export management
│ │ ├── MkDocsSettingsPage.tsx # Documentation config
│ │ ├── ObservabilityPage.tsx # Monitoring dashboard
│ │ └── services/
│ │ ├── MiniQRPage.tsx # Mini QR iframe
│ │ ├── MailHogPage.tsx # Email capture UI
│ │ ├── CodeEditorPage.tsx # Code Server management
│ │ ├── N8nPage.tsx # Workflow automation
│ │ ├── GiteaPage.tsx # Git repository hosting
│ │ └── NocoDBPage.tsx # Data browser management
│ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand)
│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios)
│ ├── 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/ # backup.sh, legacy Cloudflare scripts ├── scripts/ # Deployment, backup, upgrade, registry scripts
├── docker-compose.yml # V2 orchestration (20+ services) │ ├── install.sh # Curl-friendly installer (downloads tarball + runs config.sh)
├── docker-compose.v1.yml # V1 backup (reference) │ ├── uninstall.sh # Remove containers, volumes, and install dir
│ ├── build-and-push.sh # Build production images → push to Gitea registry
│ ├── build-release.sh # Package runtime files into release tarball
│ ├── mirror-images.sh # Mirror third-party images to Gitea
│ ├── upgrade.sh # 6-phase upgrade (git or release-tarball mode)
│ ├── upgrade-check.sh # Check for updates (git or Gitea API)
│ ├── upgrade-watcher.sh # Systemd bridge for admin GUI upgrades
│ ├── update-env.sh # Merge new variables from .env.example into existing .env
│ ├── backup.sh / restore.sh # PostgreSQL + Listmonk + uploads backup/restore
│ ├── 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
``` ```
@ -199,13 +197,28 @@ changemaker.lite/
## Quick Start Guide ## Quick Start Guide
### Initial Setup (First Time) ### Pre-built Install (Production — Recommended)
1. **Clone repository and checkout v2 branch:** The fastest way to deploy. No source code, no compilation:
```bash
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
```
This downloads a ~9MB release tarball, runs the config wizard, and sets `IMAGE_TAG=latest`. Then:
```bash
cd ~/changemaker.lite && docker compose up -d
```
Pre-built images are pulled from `gitea.bnkops.com/admin` (~2 min). Database migrations and seeding run automatically via the API entrypoint. Access the admin GUI at http://localhost:3000.
### Source Install (Development)
1. **Clone repository:**
```bash ```bash
git clone <repo-url> changemaker.lite git clone <repo-url> changemaker.lite
cd changemaker.lite cd changemaker.lite
git checkout v2
``` ```
2. **Create environment file:** 2. **Create environment file:**
@ -267,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
@ -301,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
@ -324,12 +344,41 @@ 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
``` ```
### Registry & Release Operations
```bash
# Build production images and push to Gitea registry
./scripts/build-and-push.sh --services api,admin,media-api,nginx
./scripts/build-and-push.sh --no-push # Build only, no push (verify)
# Mirror third-party images to Gitea
./scripts/mirror-images.sh # Core images (postgres, redis, etc.)
./scripts/mirror-images.sh --all # Include heavy images (RC, Jitsi, n8n)
# Build release tarball (for pre-built installs — run AFTER build-and-push)
./scripts/build-release.sh --tag v2.1.0 # Creates releases/changemaker-lite-v2.1.0.tar.gz
./scripts/build-release.sh --tag v2.1.0 --upload # Also upload to Gitea Releases API
./scripts/build-release.sh --dry-run # Preview tarball contents
# Use registry images in upgrade (source installs)
./scripts/upgrade.sh --use-registry --force --skip-backup
# Install from tarball (end-user one-liner)
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
```
**Two compose files:**
- `docker-compose.yml` — Development: includes `build:` blocks and `./api:/app` source mounts
- `docker-compose.prod.yml` — Production: `image:` only, no source mounts, `IMAGE_TAG:-latest`
Release tarballs ship `docker-compose.prod.yml` as the compose file. Source installs use `docker-compose.yml`.
**Note:** gitea.bnkops.com must use Pangolin tunnel (not Cloudflare proxy) for large image layers (>100MB). See `docs/REGISTRY_GUIDE.md`.
### Testing & Backup ### Testing & Backup
```bash ```bash
# Media API tests # Media API tests
@ -441,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
@ -451,25 +504,20 @@ 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:** **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)**.
- **Video CRUD:** Upload with FFprobe metadata extraction (duration, dimensions, orientation, quality), bulk operations
- **Quick Actions** (Feb 2026): Edit, preview, analytics, schedule, duplicate, preview links (24h JWT), reset analytics **HLS adaptive bitrate streaming:**
- **Scheduled Publishing** (Feb 2026): BullMQ job queue, timezone support (11 zones), calendar view, publish/unpublish automation - 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.
- **Analytics** (Feb 2026): Views, watch time, completion rate, traffic sources, registered viewers, GDPR-compliant (IP hashing, 90-day retention) - 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).
- **Tracking:** Public endpoints for view/event recording, 10s heartbeat, navigator.sendBeacon for reliability - `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.
- **UI Features:** Keyboard shortcuts (E/P/A/S), hover overlays, skeleton loading, error handling, mobile responsive - 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`
- Public: `/gallery` (public video gallery), `/gallery/watch/:id` (video viewer), `/media/:id` (backwards compatible viewer route) - Public: `/gallery`, `/gallery/watch/:id`, `/media/:id` (legacy)
- Tracking (public): `/track/view`, `/track/event`, `/track/heartbeat` - Public gallery uses `MediaPublicLayout` (purple theme, optional auth)
**Note:** The public gallery is served at `/gallery` via the admin app using `MediaPublicLayout`. This provides a unified purple interface for both authenticated and unauthenticated users. The gallery supports optional authentication (session-based upvoting/commenting for anonymous users).
**Documentation:**
- [Media Admin Features Guide](./docs/MEDIA_ADMIN_FEATURES.md) — Complete feature documentation
- [Video Analytics Guide](./docs/VIDEO_ANALYTICS_GUIDE.md) — Analytics setup and interpretation
- [Media API README](./api/src/modules/media/README.md) — Architecture overview
### Services & Integrations ### Services & Integrations
@ -483,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:**
@ -525,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 |
@ -563,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.
@ -576,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
@ -591,59 +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:** If `db push` was used and migrations are out of sync: - **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`)
1. Drop any stray indexes/objects in DB not in schema: `DROP INDEX IF EXISTS <name>;`
2. Create a temp shadow DB: `docker compose exec -T v2-postgres createdb -U changemaker prisma_shadow_diff`
3. Generate catch-up SQL: `docker compose exec -T api npx prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --shadow-database-url "postgresql://..." --script`
4. Save to `api/prisma/migrations/<timestamp>_<name>/migration.sql`
5. Mark as applied: `docker compose exec -T api npx prisma migrate resolve --applied <migration_name>`
6. Verify: `docker compose exec -T api npx prisma migrate status` → "Database schema is up to date!"
7. Clean up: `docker compose exec -T v2-postgres dropdb -U changemaker prisma_shadow_diff`
- **Gotcha:** `--from-migrations` replays all migration files on a shadow DB. If a migration references tables created by `db push` (no migration file), it will fail. Fix: temporarily move the dependent migration aside, generate the catch-up (which includes the missing tables), then remove the old migration
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`) — it applies pending migrations without creating a shadow DB
### V2-Specific Gotchas ### Key Gotchas
- **Prisma migrations:** Never use `db push` on the v2 branch — always use `migrate dev` to keep migration history in sync. The baseline catch-up migration (`20260224100000_baseline_catchup`) covers all schema changes from Feb 1824 that were previously applied via `db push` - **Prisma migrations:** Never use `db push` — always `migrate dev` to keep history in sync
- Fastify media API on port 4100, separate from Express on 4000 (same DB, different ORM)
- Volunteer page naming: `VolunteerShiftsPage.tsx` (not "MyAssignmentsPage")
- Tracking module: `api/src/modules/map/tracking/` (volunteer + admin routes)
- Pages module: 3 route files (pages-admin, pages-public, blocks)
- Vite proxy: `VITE_API_URL`, `VITE_MKDOCS_URL` env vars (Docker sets to container hostnames)
- Nginx media API block must come BEFORE general API block - Nginx media API block must come BEFORE general API block
- MkDocs port 4003 (was 4000, conflicted with API) - `IMAGE_TAG=local` (default) never pulls from registry; set to SHA or `latest` for pre-built images
- Media upload: requires separate RW volume mount for inbox directory (`:rw` on `/media/local/inbox`), library remains read-only - **Release vs source installs:** Detected by `VERSION` file + absence of `.git/`; release uses `docker-compose.prod.yml`, source uses `docker-compose.yml`
- FFmpeg/FFprobe: installed in media-api container (Alpine `apk add --no-cache ffmpeg`), used for metadata extraction - **`api/dist/` is gitignored** — never commit; if root-owned from container builds, fix with `chown`
- **`!` 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 production) - 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:
@ -666,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
@ -696,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.
@ -723,47 +780,49 @@ Check in order:
4. **Pangolin resources configured:** All resources set to "Not Protected" 4. **Pangolin resources configured:** All resources set to "Not Protected"
5. **Nginx running:** `docker compose ps nginx` 5. **Nginx running:** `docker compose ps nginx`
### Database 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`.
**Symptom:** API logs show database connection errors. ### 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.
**Fix:** Check in order:
1. Check PostgreSQL container: `docker compose ps v2-postgres` 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:
2. Verify `DATABASE_URL` in `.env` matches container name and port ```
3. Check PostgreSQL logs: `docker compose logs v2-postgres --tail 50` docker compose exec -u 0 media-api chown -R 1000:1000 /media/local/hls
4. Test connection: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"` ```
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`.
### Redis Connection Failures To force a re-transcode of a failed video, set `hlsStatus = NULL` in the DB and run `npm run backfill:hls`.
**Symptom:** API logs show Redis connection errors, rate limiting doesn't work.
**Fix:**
1. Check Redis container: `docker compose ps redis-changemaker`
2. Verify `REDIS_PASSWORD` matches in `.env` and `REDIS_URL` format
3. Check Redis logs: `docker compose logs redis-changemaker --tail 50`
4. Test connection: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping`
--- ---
## 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` — V2 orchestration (20+ services, monitoring profile) - `docker-compose.yml` — Development orchestration (build blocks + source mounts, 40+ services)
- `docker-compose.prod.yml` — Production orchestration (image-only, no source mounts, `IMAGE_TAG:-latest`)
- `.env` / `.env.example` — Environment variables (100+ vars) - `.env` / `.env.example` — Environment variables (100+ vars)
- `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
@ -781,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

333
DEV_WORKFLOW.md Normal file
View File

@ -0,0 +1,333 @@
# Development & Release Workflow
How code changes move from development to production deployments across all installation methods.
---
## Overview
There are **three ways** Changemaker Lite gets deployed:
| Method | Who uses it | Images from | Compose file |
|--------|------------|-------------|--------------|
| **Source install** | Developers, contributors | Built locally from source | `docker-compose.yml` |
| **Release install** | Production servers, evaluators | Gitea registry (pre-built) | `docker-compose.prod.yml` (ships as `docker-compose.yml` in tarball) |
| **CCP provisioned** | Fleet operators (Control Panel) | Gitea registry (pre-built) | Rendered from `templates/docker-compose.yml.hbs` |
All three methods share the same Gitea container registry at `gitea.bnkops.com/admin`.
---
## The Pipeline
```
┌──────────────────────────────────────────────────────────────────┐
│ DEVELOPMENT (your machine) │
│ │
│ Edit code → docker compose up -d → test locally │
│ Uses: docker-compose.yml (build: blocks + ./api:/app mounts) │
└──────────────────┬───────────────────────────────────────────────┘
│ git push
┌──────────────────────────────────────────────────────────────────┐
│ BUILD & PUBLISH │
│ │
│ Step 1: ./scripts/build-and-push.sh │
│ Builds 5 production images, pushes to Gitea registry │
│ (api, admin, media-api, nginx, ccp-agent) │
│ tagged :SHA + :latest │
│ │
│ Step 2: ./scripts/mirror-images.sh (run once/rarely) │
│ Mirrors 36 third-party images to Gitea registry │
│ (postgres, redis, nocodb, jitsi, grafana, etc.) │
│ │
│ Step 3: ./scripts/build-release.sh --tag vX.Y.Z --upload │
│ Packages runtime files into ~9MB tarball, uploads to │
│ Gitea Releases │
└──────────────────┬─────────────────100.90.78.47──────────────────────────────┘
┌───────────┴───────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ RELEASE INSTALL │ │ CCP PROVISIONED │
│ │ │ │
│ curl installer │ │ Control Panel │
│ or manual tarball│ │ creates instance │
│ → config.sh │ │ via web UI │
│ → docker compose │ │ → renders config │
│ up -d │ │ → docker compose │
│ │ │ up -d │
└─────────────────┘ └──────────────────┘
│ │
└───────────┬───────────┘
All images pulled from
gitea.bnkops.com/admin
(zero external dependencies)
```
---
## Step-by-Step
### 1. Local Development
Standard Docker Compose workflow with hot-reload:
```bash
# Start core services
docker compose up -d v2-postgres redis api admin
# API logs (watch for errors)
docker compose logs -f api
# Run with media API
docker compose up -d media-api
# Run with monitoring stack
docker compose --profile monitoring up -d
```
**Key:** `docker-compose.yml` uses `build:` blocks to compile TypeScript from source and mounts `./api:/app` for live code changes. This is the only compose file that builds from source.
### 2. Build & Push Production Images
After code changes are tested locally:
```bash
# Build production images and push to Gitea registry
./scripts/build-and-push.sh
```
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 |
|---------|-----------|-----------------|
| `api` | `api/Dockerfile` | Express + Prisma (compiled JS, no TS) |
| `admin` | `admin/Dockerfile` | Nginx serving React build output |
| `media-api` | `api/Dockerfile.media` | Fastify + FFmpeg (compiled JS) |
| `nginx` | `nginx/Dockerfile` | Nginx with `envsubst` domain templating |
| `ccp-agent` | `../changemaker-control-panel/agent/Dockerfile` | Remote management agent (sibling repo) |
```bash
# Build specific services only
./scripts/build-and-push.sh --services api,admin
# Build without pushing (verify first)
./scripts/build-and-push.sh --no-push
# Include code-server (~9GB, only when Dockerfile changes)
./scripts/build-and-push.sh --include-code-server
```
### 3. Mirror Third-Party Images (Run Once / On Version Bumps)
Copies all third-party Docker images used by the platform to the Gitea registry, so deployments never depend on Docker Hub, GHCR, LSCR, or GCR:
```bash
# Mirror all 36 images (core + platform + comms + monitoring)
./scripts/mirror-images.sh
# Mirror only essential infrastructure (postgres, redis, alpine)
./scripts/mirror-images.sh --core-only
# Preview without executing
./scripts/mirror-images.sh --dry-run
```
**When to re-run:** Only when upgrading a third-party image version. The script has explicit version pins — update the version in `mirror-images.sh`, then re-run.
Images are organized into 4 groups:
| Group | Count | Examples |
|-------|-------|---------|
| Core Infrastructure | 5 | postgres:16-alpine, redis:7-alpine, alpine:3 |
| Platform Services | 16 | nocodb, listmonk, gitea, n8n, vaultwarden, nginx, code-server |
| Communication | 8 | rocket.chat, mongo, nats, gancio, jitsi (4 containers) |
| Monitoring | 7 | prometheus, grafana, alertmanager, cadvisor, exporters, gotify |
### 4. Build Release Tarball
Packages only runtime files (~9 MB) — no source code, no node_modules:
```bash
# Build tarball
./scripts/build-release.sh --tag v2.2.0
# Build and upload to Gitea Releases
./scripts/build-release.sh --tag v2.2.0 --upload
# Preview contents without creating tarball
./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:
- `docker-compose.yml` (copy of `docker-compose.prod.yml` — image-only, no build blocks)
- `.env.example`, `config.sh` (configuration wizard)
- `scripts/` (init scripts, backup, upgrade, systemd units)
- `configs/` (prometheus, grafana, alertmanager, homepage, pangolin)
- `nginx/conf.d/` (templates for reference)
- `mkdocs/` (starter documentation)
- Empty data directories
### 5. Deploying
#### New Release Install (End Users)
```bash
# One-liner
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
# Or manual
curl -LO https://gitea.bnkops.com/admin/changemaker.lite/releases/latest/download/changemaker-lite-latest.tar.gz
tar xzf changemaker-lite-latest.tar.gz
cd changemaker-lite
bash config.sh
docker compose up -d
```
All images (custom + third-party) pull from `gitea.bnkops.com/admin`. No external registry access needed.
#### New CCP Instance (Fleet Operators)
The Control Panel provisions instances via its web UI:
1. Operator fills in the Create Instance wizard (domain, features, email, tunnel)
2. CCP copies source files, renders templates (Handlebars), generates secrets
3. With `USE_REGISTRY_IMAGES=true` (default): pulls pre-built images from Gitea (~2 min)
4. With `USE_REGISTRY_IMAGES=false`: builds from source (~10+ min)
5. Starts infrastructure → runs migrations → starts all services
CCP registry settings (in `changemaker-control-panel/.env`):
```bash
GITEA_REGISTRY=gitea.bnkops.com/admin # Registry URL for all images
USE_REGISTRY_IMAGES=true # true = pull pre-built, false = build from source
IMAGE_TAG=latest # Tag for custom images (api, admin, media-api)
```
### 6. Upgrading Existing Installations
#### Source Installs
```bash
./scripts/upgrade.sh # Standard: git pull + rebuild from source
./scripts/upgrade.sh --use-registry # Fast: pull pre-built images instead of rebuilding
./scripts/upgrade.sh --dry-run # Preview changes
```
#### Release Installs
```bash
./scripts/upgrade.sh # Auto-detects release mode, downloads latest tarball
```
Release installs are detected by the presence of a `VERSION` file and absence of `.git/`. The upgrade script automatically downloads the latest tarball from Gitea instead of running `git pull`.
---
## Image Naming Conventions
All images live under `gitea.bnkops.com/admin/`:
| Type | Naming Pattern | Example |
|------|---------------|---------|
| Custom services | `changemaker-{service}:{sha\|latest}` | `changemaker-api:latest` |
| Simple names | Same as upstream | `postgres:16-alpine`, `redis:7-alpine` |
| Namespaced → short | Org removed | `nocodb/nocodb``nocodb:0.301.3` |
| Conflict resolution | Explicit short name | `gotify/server``gotify`, `vaultwarden/server``vaultwarden` |
| Jitsi suite | `jitsi-{component}` | `jitsi-web:stable-9823`, `jitsi-prosody:stable-9823` |
| LinuxServer nginx | `ls-nginx` (avoids nginx conflict) | `ls-nginx:1.28.2` |
---
## Two Compose Files
| File | Purpose | Build? | Source mounts? | Image source |
|------|---------|--------|---------------|-------------|
| `docker-compose.yml` | Development | Yes (`build:` blocks) | Yes (`./api:/app`) | Built locally |
| `docker-compose.prod.yml` | Production | No | No | `${GITEA_REGISTRY:-gitea.bnkops.com/admin}/...` |
Release tarballs ship `docker-compose.prod.yml` renamed as `docker-compose.yml`.
The CCP template (`templates/docker-compose.yml.hbs`) generates a compose file that works like `docker-compose.prod.yml` when `USE_REGISTRY_IMAGES=true`, or like `docker-compose.yml` when `false`.
---
## Quick Reference
```bash
# ── Development ──
docker compose up -d v2-postgres redis api admin # Start dev stack
docker compose logs -f api # Watch API logs
docker compose exec api npx prisma migrate dev # Create migration
# ── Build & Publish ──
./scripts/build-and-push.sh # Build + push 5 images
./scripts/mirror-images.sh # Mirror 36 third-party images
git tag --sort=-v:refname | head -3 # Check latest version tags
./scripts/build-release.sh --tag vX.Y.Z --upload # Package + upload release
# ── Deploy ──
curl -fsSL .../install.sh | bash # New install (release)
./scripts/upgrade.sh # Upgrade existing install
./scripts/upgrade.sh --use-registry # Fast upgrade (registry images)
# ── Verify ──
curl -s http://localhost:4000/api/health # API health check
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
1. [ ] All code changes committed and pushed to `main` branch
2. [ ] `docker compose up -d` works locally (smoke test)
3. [ ] **Determine version tag:**
```bash
# Check the latest existing tag to pick the next version
git tag --sort=-v:refname | head -5
# Check commits since the last tag
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

@ -2,17 +2,13 @@ FROM codercom/code-server:latest
USER root USER root
# Install Node.js 18+ and npm # Install Node.js (for npm/claude-code — code-server bundles its own node but doesn't expose it)
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ RUN apt-get update && apt-get install -y nodejs npm --no-install-recommends \
&& apt-get install -y nodejs && rm -rf /var/lib/apt/lists/*
# Install Claude Code globally as root # Install Claude Code globally
RUN npm install -g @anthropic-ai/claude-code RUN npm install -g @anthropic-ai/claude-code
# Install Ollama (needs zstd for extraction)
RUN apt-get update && apt-get install -y zstd && rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://ollama.com/install.sh | sh
# Install Python and dependencies # Install Python and dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
python3 \ python3 \

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

192
README.md
View File

@ -1,84 +1,172 @@
# Changemaker Lite <p align="center">
<img src="mkdocs/docs/assets/logo.png" alt="Changemaker Lite" width="120" />
</p>
A self-hosted political campaign platform that consolidates advocacy email campaigns, geographic mapping, volunteer canvassing, media management, and administration into a single TypeScript stack. Built for organizers who want to own their data. <h1 align="center">Changemaker Lite</h1>
## What Is This? <p align="center">
A self-hosted campaign platform for community organizers who want to own their data.
</p>
Changemaker Lite gives community organizers the tools they need to: <p align="center">
<a href="https://cmlite.org/docs/getting-started/">Documentation</a> &middot;
<a href="https://cmlite.org">Website</a> &middot;
<a href="https://opensource.org/license/apache-2-0">Apache 2.0 License</a>
</p>
- **Run advocacy campaigns** — let supporters look up their elected representatives by postal code and send emails in a few clicks ---
- **Manage canvassing** — map locations, draw canvassing areas, schedule volunteer shifts, and track door-to-door visits with GPS
- **Host media** — upload campaign videos, share them publicly, and track engagement analytics
- **Build landing pages** — drag-and-drop page builder for campaign microsites
- **Send newsletters** — integrated with Listmonk for opt-in mailing lists
- **Monitor everything** — Prometheus + Grafana observability stack included
The entire platform runs on Docker Compose with a single `.env` file for configuration. Changemaker Lite consolidates advocacy campaigns, geographic mapping, volunteer canvassing, media management, newsletters, and administration into a single Docker Compose stack. One `.env` file, one command to start, everything under your control.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/admin-dashboard.png" alt="Admin Dashboard" width="800" />
</p>
## Why Changemaker Lite?
Most campaign tools are SaaS platforms that lock you into monthly subscriptions, hold your data hostage, and disappear when funding dries up. Changemaker Lite is different:
- **Self-hosted** -- runs on any machine with Docker. Your server, your data.
- **All-in-one** -- replaces 5-10 separate tools with a single integrated platform.
- **Free and open source** -- Apache 2.0 licensed. Fork it, modify it, make it yours.
- **Privacy-first** -- no telemetry, no third-party analytics, no data leaving your server.
## What's Inside
### Advocacy Campaigns
Let supporters look up their elected representatives by postal code and send advocacy emails in a few clicks. Track responses, moderate a public response wall, and monitor email delivery.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/public-campaigns.png" alt="Public Campaign Page" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/influence-campaigns.png" alt="Campaign Management" width="800" />
</p>
### Interactive Map & Canvassing
Import thousands of addresses, draw canvassing areas, schedule volunteer shifts, and track door-to-door visits with GPS. Volunteers get a full-screen mobile map with real-time location tracking and visit recording.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/public-map.png" alt="Public Map" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/canvass-dashboard.png" alt="Canvass Dashboard" width="800" />
</p>
### Volunteer Portal
Volunteers get their own portal with shift sign-ups, canvassing assignments, activity tracking, a social calendar, and a friends system to stay connected with their team.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/volunteer-dashboard.png" alt="Volunteer Map" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/volunteer-calendar.png" alt="Volunteer Calendar" width="800" />
</p>
### Media Library & Public Gallery
Upload campaign videos, manage metadata, schedule publishing, and share them through a public gallery. Includes GDPR-compliant analytics.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/media-library.png" alt="Media Library" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/public-gallery.png" alt="Public Gallery" width="800" />
</p>
### Landing Pages & Email Templates
Build campaign microsites with a drag-and-drop GrapesJS editor. Design email templates for consistent campaign communications.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/landing-pages.png" alt="Landing Page Builder" width="800" />
</p>
### SMS Campaigns, Newsletters & More
Send SMS campaigns via an Android bridge, sync subscribers to Listmonk for newsletters, recognize volunteers on a Wall of Fame leaderboard, and monitor everything with built-in Prometheus + Grafana observability.
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/sms-dashboard.png" alt="SMS Dashboard" width="800" />
</p>
<p align="center">
<img src="mkdocs/docs/assets/images/screenshots/features/public-wall-of-fame.png" alt="Wall of Fame" width="800" />
</p>
## Quick Start ## Quick Start
### Production (pre-built images)
```bash
# 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
# 2. Start services (first pull ~3 min + ~90s stabilization)
cd ~/changemaker.lite && docker compose up -d
# 3. Verify the install
bash scripts/test-deployment.sh --wait 60
```
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
# Clone and switch to the v2 branch
git clone <repo-url> changemaker.lite git clone <repo-url> changemaker.lite
cd changemaker.lite cd changemaker.lite
git checkout v2
# Create your environment file
cp .env.example .env cp .env.example .env
# Edit .env — at minimum set: # Edit .env -- set passwords, JWT secrets, admin credentials
# V2_POSTGRES_PASSWORD, REDIS_PASSWORD,
# JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, ENCRYPTION_KEY
# INITIAL_ADMIN_EMAIL, INITIAL_ADMIN_PASSWORD
# Start core services
docker compose up -d v2-postgres redis api admin docker compose up -d v2-postgres redis api admin
# Run database migrations and seed
docker compose exec api npx prisma migrate deploy docker compose exec api npx prisma migrate deploy
docker compose exec api npx prisma db seed 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`.
## Architecture ### Useful tools
| Component | Technology | Port |
|-----------|-----------|------|
| **API** | Express.js + Prisma + PostgreSQL | 4000 |
| **Media API** | Fastify + Prisma (shared DB) | 4100 |
| **Admin GUI** | React + Vite + Ant Design + Zustand | 3000 |
| **Reverse Proxy** | Nginx (subdomain routing) | 80 |
| **Database** | PostgreSQL 16 | 5433 |
| **Cache / Queue** | Redis + BullMQ | 6379 |
| **Newsletter** | Listmonk | 9001 |
| **Monitoring** | Prometheus + Grafana + Alertmanager | 9090, 3001 |
See `CLAUDE.md` for comprehensive architecture documentation, module reference, and troubleshooting.
## Feature Flags
Enable optional modules in `.env`:
```bash ```bash
ENABLE_MEDIA_FEATURES=true # Video library + gallery bash scripts/validate-env.sh # re-check .env + host ports
LISTMONK_SYNC_ENABLED=true # Newsletter subscriber sync bash scripts/test-deployment.sh # full deployment health sweep
EMAIL_TEST_MODE=true # Route emails to MailHog (dev) 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)
``` ```
## Production Deployment
Changemaker Lite uses [Pangolin](https://github.com/fosrl/pangolin) tunnels for production access (Cloudflare alternative). See the Tunnel page in the admin panel (`/app/tunnel`) for setup instructions.
## Documentation ## Documentation
- **`CLAUDE.md`** — Full project reference (architecture, modules, ports, patterns) **Full documentation is available at [cmlite.org/docs/getting-started](https://cmlite.org/docs/getting-started/).**
- **`V2_PLAN.md`** — Development roadmap (Phases 1-14 complete)
- **`SECURITY_AUDIT_2025-02-11.md`** — Security audit findings and remediations
- **`.env.example`** — All 100+ environment variables with descriptions
## Licensing The docs site covers installation, configuration, all features, architecture details, production deployment with Pangolin tunnels, and troubleshooting. It is the authoritative and up-to-date reference for Changemaker Lite.
This project is licensed under the [Apache License 2.0](https://opensource.org/license/apache-2-0). ## Architecture at a Glance
| Layer | Technology |
|-------|-----------|
| API | Express.js + Prisma + PostgreSQL 16 |
| Media API | Fastify + Prisma (shared DB) |
| Frontend | React + Vite + Ant Design + Zustand |
| Reverse Proxy | Nginx (subdomain routing) |
| Cache & Queue | Redis + BullMQ |
| Newsletter | Listmonk |
| Monitoring | Prometheus + Grafana + Alertmanager |
| Tunneling | Pangolin (self-hosted Cloudflare alternative) |
The entire stack runs on Docker Compose. Enable optional modules (media, newsletters, SMS, monitoring) with feature flags in `.env`.
## License
[Apache License 2.0](https://opensource.org/license/apache-2-0)
## AI Disclaimer ## AI Disclaimer

View File

@ -1,569 +0,0 @@
# Social Calendar Feature Plan
**Created:** 2026-03-06
**Status:** Planning Complete — Ready for Phase A Implementation
**Branch:** v2
**Feature Flag:** `enableSocialCalendar` (new, under SiteSettings)
---
## Overview
A layered personal and social calendar system. Each user gets their own calendar with multiple layers (system-populated, user-created, external feeds). Calendars can be shared between users at the item, category (layer), or whole-calendar level. Shared views allow multiple users' events to appear on a merged, color-coded calendar. Admin shared views can auto-include users by role.
### Design Principles
- **Layers are the core abstraction** — every event belongs to a layer, layers control visibility and sharing
- **System layers are virtual** — shifts, tickets, polls are queried live from source tables, not duplicated
- **Recurrence uses materialization** — consistent with existing ShiftSeries pattern (generate DB rows, allow exceptions)
- **Social-first** — friend relationships gate sharing; admin views are separate and only expose system data
- **Privacy by default** — layers default to PRIVATE; users explicitly opt into sharing
---
## Data Model
### CalendarLayer
Each user has multiple layers. System layers are auto-created on first calendar access.
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| userId | String | FK to User |
| name | String | "Personal", "Gym", "Google Cal", etc. |
| layerType | Enum | SYSTEM, USER, EXTERNAL |
| systemType | Enum? | SHIFTS, TICKETS, POLLS, PUBLIC_EVENTS (for SYSTEM layers only) |
| color | String | Hex color (#1890ff) |
| visibility | Enum | PRIVATE, FRIENDS, PUBLIC |
| isEnabled | Boolean | User can toggle layers on/off for themselves |
| sortOrder | Int | Display ordering |
| createdAt | DateTime | |
| updatedAt | DateTime | |
**System layers (auto-created per user):**
- My Shifts — from ShiftSignup records
- My Tickets — from EventTicket records
- My Polls — from SchedulingPollVote records
- Public Events — the existing Gancio/platform feed (togglable)
System layers are **virtual** — no CalendarItem rows are created. The API queries source tables directly and maps to the CalendarItem shape at response time.
### CalendarItem
User-created events, time blocks, and cached .ics feed entries.
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| userId | String | FK to User (owner) |
| layerId | String | FK to CalendarLayer |
| title | String | |
| description | String? | Text |
| date | DateTime | Date of this occurrence |
| startTime | String | HH:MM |
| endTime | String | HH:MM |
| isAllDay | Boolean | Default false |
| itemType | Enum | EVENT, TIME_BLOCK, REMINDER |
| location | String? | |
| color | String? | Override (null = inherit layer color) |
| visibility | Enum? | PRIVATE, FRIENDS, PUBLIC (null = inherit from layer) |
| busyStatus | Enum | BUSY, TENTATIVE, FREE (default BUSY) |
| showDetailsTo | Enum | NOBODY, FRIENDS, EVERYONE (default FRIENDS) |
| recurrenceRule | Json? | See Recurrence section |
| recurrenceEnd | DateTime? | When series stops |
| seriesId | String? | Groups recurring instances |
| isException | Boolean | Edited instance that broke from pattern |
| sourceType | Enum | MANUAL, ICS_FEED |
| sourceId | String? | External reference (ics UID, etc.) |
| createdAt | DateTime | |
| updatedAt | DateTime | |
**Notes:**
- System-layer items (shifts, tickets, polls) are NOT stored as CalendarItem rows — they're virtual
- .ics feed items ARE stored as CalendarItem rows (cached from external source, read-only to user)
- MANUAL items are user-created freeform events
### CalendarFeed (.ics import)
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| userId | String | FK to User |
| name | String | "Google Calendar", "Work" |
| url | String | .ics URL |
| layerId | String | FK to auto-created CalendarLayer |
| refreshInterval | Enum | FIFTEEN_MIN, HOURLY, SIX_HOUR, DAILY |
| lastFetchedAt | DateTime? | |
| lastStatus | Enum | OK, ERROR, PENDING |
| lastError | String? | Error message if failed |
| itemCount | Int | How many items imported |
| createdAt | DateTime | |
| updatedAt | DateTime | |
### SharedCalendarView
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| name | String | "Weekend Crew", "All Shift Admins" |
| description | String? | |
| ownerId | String | FK to User (creator) |
| viewType | Enum | MANUAL, ROLE_BASED |
| autoIncludeRoles | Json? | ["MAP_ADMIN", "USER"] (for ROLE_BASED) |
| includedLayerTypes | Json | ["shifts", "tickets", "personal-public"] |
| shareScope | Enum | MEMBERS, PUBLIC |
| shareToken | String? | Unique token for public share URL |
| createdAt | DateTime | |
| updatedAt | DateTime | |
**ROLE_BASED views:**
- Auto-include users matching specified roles
- Only pull system layers (shifts, tickets, polls) — never personal layers
- No notifications sent to included users (admin operational tool)
- Created/managed by SUPER_ADMIN or MAP_ADMIN
**MANUAL views:**
- Members are explicitly invited via notification system
- Can include personal layers (with member consent)
- Members can decline/leave
### SharedCalendarMember
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| viewId | String | FK to SharedCalendarView |
| userId | String | FK to User |
| status | Enum | INVITED, ACCEPTED, DECLINED |
| color | String | Auto-assigned from palette |
| joinedAt | DateTime? | |
| @@unique | [viewId, userId] | |
**Auto-color palette:**
```
#1890ff (blue), #52c41a (green), #fa8c16 (orange), #722ed1 (purple),
#eb2f96 (pink), #13c2c2 (cyan), #faad14 (gold), #f5222d (red),
#2f54eb (geekblue), #a0d911 (lime)
```
Assigned sequentially as members join: `PALETTE[memberIndex % length]`.
Users can override their assigned color per shared view.
### SharedViewComment
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| viewId | String | FK to SharedCalendarView |
| userId | String | FK to User |
| itemDate | String | YYYY-MM-DD (which date this comment is about) |
| itemId | String? | Optional: specific CalendarItem or source item ID |
| content | String | Text |
| createdAt | DateTime | |
### SharedViewReaction
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| viewId | String | FK to SharedCalendarView |
| userId | String | FK to User |
| itemId | String | CalendarItem or source item ID (e.g., "shift-abc123") |
| emoji | String | Single emoji or shortcode |
| createdAt | DateTime | |
| @@unique | [viewId, userId, itemId, emoji] | One reaction type per user per item |
### CalendarExportToken (.ics export)
| Field | Type | Notes |
|-------|------|-------|
| id | String (cuid) | PK |
| userId | String | FK to User |
| token | String | Unique, random (for URL auth) |
| includePersonal | Boolean | Whether personal events are exported |
| includeLayers | Json? | Array of layer IDs (null = all enabled) |
| createdAt | DateTime | |
Export URL: `GET /api/calendar/feed/:userId/:token.ics`
---
## Recurrence Model
Uses **materialization** (consistent with existing ShiftSeries pattern):
1. User creates a recurring event with a recurrence rule
2. System generates CalendarItem rows for the next 3 months
3. Background job (BullMQ, daily) extends series forward by 1 month
4. Individual instances can be edited (becomes `isException: true`) or deleted
5. Editing the series template updates all non-exception future instances
### Recurrence Rule JSON
```json
{
"frequency": "DAILY | WEEKLY | BIWEEKLY | MONTHLY",
"daysOfWeek": [1, 3, 5],
"dayOfMonth": 15,
"interval": 1
}
```
- `WEEKLY` + `daysOfWeek: [1,3,5]` = every Mon/Wed/Fri
- `MONTHLY` + `dayOfMonth: 15` = 15th of every month
- `BIWEEKLY` + `daysOfWeek: [2,4]` = every other Tue/Thu
- `interval` for skip patterns (every 2 weeks, every 3 months)
### Recurrence Edit Options (UI)
When editing a recurring event instance:
- "This event only" — marks as exception, edits the single instance
- "This and future events" — updates template + regenerates future non-exception instances
- "All events in series" — updates template + all instances (including past, excluding exceptions)
When deleting:
- "This event only" — soft-delete the single instance
- "This and future events" — delete future instances, set recurrenceEnd on template
- "All events" — delete entire series
---
## Time Block Visibility (Configurable per item)
| `showDetailsTo` | Friends see | Public sees |
|-----------------|-------------|-------------|
| NOBODY | "Busy 2-4pm" | "Busy 2-4pm" |
| FRIENDS | "Dentist 2-4pm" | "Busy 2-4pm" |
| EVERYONE | "Dentist 2-4pm" | "Dentist 2-4pm" |
Combined with `busyStatus`:
- **BUSY** — solid color block
- **TENTATIVE** — dashed/lighter block
- **FREE** — no block shown (informational only, e.g., "Available for meetings")
---
## Notification Types (reusing existing system)
| Type | Message | Trigger |
|------|---------|---------|
| SHARED_VIEW_INVITE | "Alice invited you to 'Weekend Crew' calendar" | Manual shared view invite |
| SHARED_VIEW_ACCEPTED | "Bob accepted your invite to 'Weekend Crew'" | Member accepts |
| CALENDAR_EVENT_INVITE | "Alice added you to 'Planning Meeting' on Mar 10" | Phase B: event-level sharing |
| CALENDAR_REMINDER | "Reminder: Team standup in 15 minutes" | Future: optional reminders |
Role-based admin views do NOT trigger notifications (admin operational tool using only system data).
---
## Availability Finder (Phase B)
A dedicated mode within shared calendar views:
1. Toggle "Find Available Time" on a shared view
2. System overlays all members' BUSY/TENTATIVE time blocks
3. Highlights gaps where ALL members are free
4. Optional: filter by time range ("only show weekday 9am-5pm slots")
5. Click a free slot to create an event and auto-invite all members
Visual: green highlight on free slots, red/orange on conflicts, member avatars on busy blocks.
---
## API Routes
### Phase A (Personal Calendar)
```
# Layers
GET /api/calendar/layers — list user's layers
POST /api/calendar/layers — create custom layer
PATCH /api/calendar/layers/:id — update layer (name, color, visibility, enabled)
DELETE /api/calendar/layers/:id — delete custom layer (+ its items)
# Calendar Items
GET /api/calendar/items — list items in date range (all enabled layers merged)
POST /api/calendar/items — create item (event, time block, reminder)
PATCH /api/calendar/items/:id — update item
DELETE /api/calendar/items/:id — delete item
# Recurrence
POST /api/calendar/items/:id/series — edit series (this-only, this-and-future, all)
DELETE /api/calendar/items/:id/series — delete series (this-only, this-and-future, all)
# Unified personal view (merges system layers + user items)
GET /api/calendar/my — personal calendar (date range, layer filters)
```
### Phase B (Sharing + Social)
```
# Shared Views
GET /api/calendar/shared — list shared views I own or am a member of
POST /api/calendar/shared — create shared view
PATCH /api/calendar/shared/:id — update shared view
DELETE /api/calendar/shared/:id — delete shared view (owner only)
# Members
POST /api/calendar/shared/:id/invite — invite user(s) to shared view
PATCH /api/calendar/shared/:id/respond — accept/decline invite
DELETE /api/calendar/shared/:id/leave — leave a shared view
GET /api/calendar/shared/:id/members — list members + colors
# Merged calendar data
GET /api/calendar/shared/:id/items — merged items from all members
# Event-level sharing
POST /api/calendar/items/:id/share — share specific item with friend(s)
# Comments & Reactions (on shared views)
GET /api/calendar/shared/:id/comments?date=YYYY-MM-DD
POST /api/calendar/shared/:id/comments
DELETE /api/calendar/shared/:id/comments/:commentId
POST /api/calendar/shared/:id/reactions
DELETE /api/calendar/shared/:id/reactions/:reactionId
# Availability finder
GET /api/calendar/shared/:id/availability?start=&end=&dayStart=09:00&dayEnd=17:00
# Friend's public calendar
GET /api/calendar/user/:userId — view a friend's public items
```
### Phase C (.ics Integration)
```
# Feeds (import)
GET /api/calendar/feeds — list user's subscribed feeds
POST /api/calendar/feeds — subscribe to .ics URL
PATCH /api/calendar/feeds/:id — update feed settings
DELETE /api/calendar/feeds/:id — unsubscribe (deletes layer + cached items)
POST /api/calendar/feeds/:id/refresh — force refresh now
# Export
GET /api/calendar/export/token — get or create export token
DELETE /api/calendar/export/token — revoke export token
GET /api/calendar/feed/:userId/:token.ics — public .ics feed (no auth, token in URL)
```
### Phase D (Admin Shared Views)
```
# Admin role-based views (requireRole: SUPER_ADMIN, MAP_ADMIN)
POST /api/admin/calendar/shared — create role-based shared view
PATCH /api/admin/calendar/shared/:id — update
DELETE /api/admin/calendar/shared/:id — delete
GET /api/admin/calendar/shared/:id/items — merged system-layer data for matching users
```
---
## Frontend Pages & Components
### Phase A
| Component | Location | Description |
|-----------|----------|-------------|
| MyCalendarPage | `volunteer/MyCalendarPage.tsx` | Personal calendar (main view) |
| CalendarLayerPanel | `components/calendar/CalendarLayerPanel.tsx` | Sidebar: layer list with toggles, colors, visibility |
| CalendarItemModal | `components/calendar/CalendarItemModal.tsx` | Create/edit event, time block, or reminder |
| RecurrenceEditor | `components/calendar/RecurrenceEditor.tsx` | Recurrence rule builder (frequency, days, end date) |
| PersonalCalendarView | `components/calendar/PersonalCalendarView.tsx` | Month/week/day calendar with layer color-coding |
| MobileDayView | `components/calendar/MobileDayView.tsx` | Day/3-day swipeable view for mobile |
**Mobile UX:** Day or 3-day swipeable view (not full month grid). Swipe left/right to navigate days. Layer toggles in a collapsible bottom sheet.
### Phase B
| Component | Location | Description |
|-----------|----------|-------------|
| SharedCalendarsPage | `volunteer/SharedCalendarsPage.tsx` | List of shared views I'm in |
| SharedCalendarView | `components/calendar/SharedCalendarView.tsx` | Merged multi-user calendar with member colors |
| SharedViewMembersPanel | `components/calendar/SharedViewMembersPanel.tsx` | Member list, color overrides, invite button |
| AvailabilityFinder | `components/calendar/AvailabilityFinder.tsx` | Free/busy overlay with slot highlighting |
| CalendarComments | `components/calendar/CalendarComments.tsx` | Comment thread for a date in shared view |
| CalendarReactions | `components/calendar/CalendarReactions.tsx` | Emoji reactions on items |
| FriendCalendarPage | `volunteer/FriendCalendarPage.tsx` | View a friend's public calendar |
### Phase C
| Component | Location | Description |
|-----------|----------|-------------|
| CalendarFeedsPanel | `components/calendar/CalendarFeedsPanel.tsx` | Manage .ics subscriptions |
| CalendarExportPanel | `components/calendar/CalendarExportPanel.tsx` | Export token management, copy URL |
### Phase D
| Component | Location | Description |
|-----------|----------|-------------|
| AdminSharedViewsPage | `pages/AdminSharedViewsPage.tsx` | Admin: create/manage role-based views |
| AdminCalendarOverview | `components/calendar/AdminCalendarOverview.tsx` | Big shift/event overview for admins |
---
## Navigation & Routing
### Volunteer Portal
- Footer nav: add "Calendar" tab (CalendarOutlined icon)
- `/volunteer/calendar` — MyCalendarPage
- `/volunteer/calendar/shared` — SharedCalendarsPage
- `/volunteer/calendar/shared/:id` — SharedCalendarView
- `/volunteer/calendar/friend/:userId` — FriendCalendarPage
### Admin
- Sidebar under existing section: "Calendar Overview"
- `/app/calendar/shared` — AdminSharedViewsPage
---
## Phase Breakdown
### Phase A: Personal Calendar + Layers + Freeform Events
**Scope:**
- [x] Prisma models: CalendarLayer, CalendarItem, CalendarFeed, SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction, CalendarExportToken (+ 12 enums)
- [x] Auto-create system layers on first calendar access (ensureSystemLayers)
- [x] CalendarItem CRUD (create, read, update, delete)
- [x] Recurrence: create series (materialize 3 months), edit/delete with scope options (THIS_ONLY/THIS_AND_FUTURE/ALL)
- [ ] BullMQ job: extend recurring series daily (add 1 month of future instances)
- [x] Personal calendar API: GET /api/calendar/my (merge system layers + user items)
- [x] System layer queries: shifts (from ShiftSignup), tickets (from Ticket), polls (from SchedulingPollVote)
- [x] Layer CRUD: create custom layers, toggle on/off, set color
- [x] Layer visibility settings (PRIVATE/FRIENDS/PUBLIC) — stored but not enforced until Phase B
- [x] MyCalendarPage: month view (desktop), day/3-day view (mobile)
- [x] CalendarLayerPanel: sidebar with layer toggles, color pickers, inline editing, grouped by type
- [x] CalendarItemModal: create/edit form with item type, recurrence, time block settings, scope selector
- [x] RecurrenceEditor: frequency/days/interval/end-date with preview text
- [x] PersonalCalendarView: desktop month view with layer-colored items
- [x] MobileDayView: day view with time grid, current time indicator, floating add button
- [x] Volunteer footer nav: "Calendar" tab (gated behind enableSocialCalendar)
- [x] Feature flag: enableSocialCalendar in SiteSettings, Zod schema, frontend types, FeatureGate
- [x] Settings page toggle added ("Social Calendar" in People & Engagement section)
### Phase B: Sharing + Social
**Scope:**
- [ ] Prisma models: SharedCalendarView, SharedCalendarMember, SharedViewComment, SharedViewReaction
- [ ] SharedCalendarView CRUD
- [ ] Invite flow: send invite via notification system, accept/decline/leave
- [ ] Merged calendar API: query all members' items with layer type filtering
- [ ] Auto-color assignment for members
- [ ] Layer visibility enforcement (PRIVATE/FRIENDS/PUBLIC filtering based on relationship)
- [ ] Event-level sharing: share a specific item with friend(s) via notification
- [ ] Comments on shared view dates/items
- [ ] Emoji reactions on shared view items
- [ ] Availability finder: free/busy overlay, slot highlighting, time range filter
- [ ] Friend's public calendar view
- [ ] SharedCalendarsPage, SharedCalendarView components
- [ ] AvailabilityFinder component
- [ ] CalendarComments, CalendarReactions components
- [ ] Public share URL (shareToken for unauthenticated view)
### Phase C: .ics Integration
**Scope:**
- [x] Prisma models: CalendarFeed, CalendarExportToken (already existed from Phase A migration)
- [x] .ics feed parser (node-ical v0.25.5)
- [x] BullMQ job: refresh feeds every 15 minutes (calendar-feed-refresh queue)
- [x] Feed CRUD: subscribe, update, delete, force refresh
- [x] Auto-create EXTERNAL layer per feed, cache items as CalendarItem rows (sourceType: ICS_FEED)
- [x] .ics export: generate feed from user's calendar via ical-generator v10, token-authenticated URL
- [x] Export token management (create, list, revoke)
- [x] CalendarFeedsPanel, CalendarExportPanel components
- [x] MyCalendarPage settings Drawer integration (gear icon)
### Phase D: Admin Shared Views
**Scope:**
- [ ] Role-based SharedCalendarView (viewType: ROLE_BASED)
- [ ] Auto-include users by role(s) — query live, no member rows needed
- [ ] Only expose system layers (shifts, tickets, polls) — no personal data
- [ ] No notifications to included users
- [ ] Admin routes (requireRole: SUPER_ADMIN, MAP_ADMIN)
- [ ] AdminSharedViewsPage
- [ ] AdminCalendarOverview (big shift/event dashboard)
---
## Implementation Notes
### Extending UnifiedCalendar
The existing `UnifiedCalendar` component and `unified-calendar.service.ts` remain as the **public** calendar. The new personal calendar service (`calendar.service.ts`) reuses the same source queries (shifts, Gancio, polls, ticketed events) but filters to the user's own records and merges with their CalendarItem rows.
### Recurrence Background Job
```typescript
// jobs/calendar-recurrence.job.ts
// Runs daily via BullMQ repeatable job
// 1. Find all CalendarItems with recurrenceRule where latest materialized date < now + 3 months
// 2. Generate new instances up to 3 months ahead
// 3. Skip dates that already have an instance (idempotent)
```
### .ics Feed Refresh Job
```typescript
// jobs/calendar-feed-refresh.job.ts
// Runs every 15 minutes via BullMQ repeatable job
// 1. Find feeds where lastFetchedAt + refreshInterval < now
// 2. Fetch .ics URL, parse events
// 3. Upsert CalendarItem rows (match on sourceId = ics UID)
// 4. Delete items no longer in feed
// 5. Update feed status
```
### Privacy Boundaries
| Scenario | What's visible |
|----------|---------------|
| Viewing own calendar | Everything (all layers, all items) |
| Friend views your calendar | Items on FRIENDS or PUBLIC visibility layers, plus items with individual FRIENDS/PUBLIC override |
| Public profile calendar | Only PUBLIC visibility layers and PUBLIC override items |
| Admin role-based view | Only system layers (shifts, tickets, polls) for users matching role filter |
| Shared view (MANUAL) | Items from includedLayerTypes on layers with appropriate visibility for the viewer |
| Time blocks (BUSY) | Title shown per showDetailsTo setting, always shows busy bar |
### Performance Considerations
- CalendarItem table will grow with materialized recurrence — add indexes on (userId, date), (layerId, date), (seriesId)
- System layers query source tables directly — leverage existing indexes on ShiftSignup, EventTicket, etc.
- .ics feed items are cached — only re-parsed on refresh interval
- Shared view queries can be expensive (N members x M layers) — cache merged results in Redis (2min TTL, bust on member change)
- Availability finder operates on time blocks only — narrow query scope
---
## Tracking Log
### 2026-03-06 — Planning Complete
- Brainstormed feature across 3 rounds of refinement
- Decided on layer-based architecture (system, user, external layers)
- Recurrence uses materialization (consistent with ShiftSeries pattern)
- Time block visibility is configurable per item (showDetailsTo: NOBODY/FRIENDS/EVERYONE)
- Shared views support manual (invite-based) and role-based (admin, system data only)
- Availability finder included in Phase B
- Comments and reactions on shared view items included in Phase B
- .ics import and export in Phase C
- Admin role-based views in Phase D (no personal data, no notifications)
- Reuse existing notification system for invites
- Auto-color assignment for shared view members with user override option
- Mobile UX: day/3-day swipeable view instead of month grid
### 2026-03-06 — Phase A Implementation Complete
- Schema: 8 models, 12 enums, migration `20260306203326_social_calendar_layers_items` applied
- Fixed pre-existing migration ordering issue (ticketed_events create must come before alter)
- Backend: calendar.service.ts (layer mgmt, item CRUD, recurrence materialization, personal calendar merge), calendar.routes.ts (9 endpoints), calendar.schemas.ts (Zod validation)
- Frontend: 5 new components (CalendarLayerPanel, CalendarItemModal, RecurrenceEditor, PersonalCalendarView, MobileDayView), MyCalendarPage
- Navigation: VolunteerFooterNav Calendar tab, App.tsx route, SettingsPage toggle
- Smoke tested: layers auto-create, item CRUD works, recurring events materialize correctly (Weekly Mon/Wed/Fri generated 11 instances through June)
- Both API and Admin compile with zero TypeScript errors
- Remaining Phase A item: BullMQ job for extending recurring series (not critical for launch, series materializes 3 months on creation)
### 2026-03-07 — Phase C Implementation Complete
- Backend: feed.schemas.ts (3 Zod schemas), feed.service.ts (feed CRUD, ICS parsing, RRULE materialization, export generation), feed.routes.ts (1 public + 8 auth routes), calendar-feed-queue.service.ts (BullMQ 15min repeatable job)
- Dependencies: node-ical v0.25.5 (ICS parsing), ical-generator v10.0.0 (ICS output)
- Feed import: streaming body read with 5MB limit, 1000 event cap, RRULE materialization via rrule.between(), stale event cleanup, status tracking (OK/ERROR/PENDING)
- Feed export: 32-byte random token, configurable layer/personal inclusion, past 1 month + future 3 months, standard iCalendar output with Content-Type: text/calendar
- Frontend: CalendarFeedsPanel (add/edit/delete/refresh with status badges), CalendarExportPanel (create/copy/revoke tokens), settings Drawer in MyCalendarPage (gear icon)
- Types: CalendarFeed, CalendarExportToken, CalendarFeedStatus, CalendarFeedInterval added to admin/src/types/api.ts
- server.ts: feedRoutes mounted before calendarRoutes (public .ics route needs no auth), queue worker started on bootstrap, graceful shutdown
- Smoke tested: Google US Holidays feed → 317 events imported with status OK; export token → valid .ics with VEVENT entries; revoke → 404
- Docker gotcha: anonymous volume `/app/node_modules` caches old dependencies — must `docker compose rm -sf api` to clear when adding new npm packages
- Both API and Admin compile with zero TypeScript errors

View File

@ -1,156 +0,0 @@
# Social Connections System — Implementation Plan
See the full plan in the conversation transcript. This file tracks implementation progress.
## Phase Status
| Phase | Description | Status |
|-------|-------------|--------|
| 1 | Feature Flag + Social Module Skeleton | COMPLETE |
| 2 | Friendship API (Send, Accept, Decline, Cancel, Unfriend) | COMPLETE |
| 3 | Block/Unblock API + Privacy Settings API | COMPLETE |
| 4 | User Social Profile + Volunteer Portal UI Foundation | COMPLETE |
| 5 | In-App Notification System (Bell Icon + Dropdown) | COMPLETE |
| 6 | Social Activity Feed (Friends' Activities) | COMPLETE |
| 7 | CRM Bridge (Auto-Connect + Friend Suggestions) | COMPLETE |
| 8 | Poke System + Video Recommendations | COMPLETE |
| 9 | Close Friends + Friends Management Page | COMPLETE (merged into Phase 4 UI) |
| 10 | Email Digest Notifications | COMPLETE |
| 11 | Social Integration with Existing Features | COMPLETE |
| 12 | Rocket.Chat DM Integration | COMPLETE |
| 13 | Group/Team Features (Shift Teams, Campaign Teams) | COMPLETE |
| 14 | Gamification (Achievements, Streaks, Leaderboards) | COMPLETE |
| 15 | Real-Time Features (SSE for Live Notifications, Online Status) | COMPLETE |
## Files Created/Modified
### Backend (API)
- `api/prisma/schema.prisma` — added `enableSocial` to SiteSettings
- `api/prisma/migrations/20260224215259_add_enable_social/` — migration
- `api/src/modules/social/` — new module directory
- `social.routes.ts` — main router mounting sub-routers
- `social.schemas.ts` — Zod schemas (friendship, privacy, notification)
- `social.middleware.ts``checkSocialEnabled` feature gate
- `social.rate-limits.ts` — rate limiters (friend request, social action)
- `friendship.service.ts` — full friendship CRUD + notifications
- `friendship.routes.ts` — 10 friendship endpoints
- `block.service.ts` — block/unblock with auto-unfriend
- `block.routes.ts` — 3 block endpoints
- `privacy.service.ts` — privacy settings get/update (auto-create defaults)
- `privacy.routes.ts` — 2 privacy endpoints
- `notification.service.ts` — notification CRUD + preferences (respects opt-outs)
- `notification.routes.ts` — 7 notification endpoints
- `profile.routes.ts` — user profile view (own + other, privacy-filtered)
- `api/src/modules/settings/settings.schemas.ts` — added `enableSocial`
- `api/src/server.ts` — mounted socialRouter at `/api/social`
### Frontend (Admin)
- `admin/src/types/social.ts` — TypeScript interfaces
- `admin/src/stores/social.store.ts` — Zustand social store
- `admin/src/components/social/` — new directory
- `UserAvatar.tsx` — initials avatar with userId-based color
- `FriendButton.tsx` — context-aware friend action button
- `NotificationBell.tsx` — bell icon + dropdown (30s polling)
- `admin/src/pages/volunteer/` — new pages
- `SocialProfilePage.tsx` — own + other user profile
- `FriendsPage.tsx` — friends management (tabs: friends, requests, sent, blocked)
- `NotificationsPage.tsx` — full notification list + preferences
- `admin/src/components/VolunteerLayout.tsx` — added NotificationBell
- `admin/src/components/VolunteerFooterNav.tsx` — added "Friends" nav item
- `admin/src/components/FeatureGate.tsx` — added `enableSocial`
- `admin/src/types/api.ts` — added `enableSocial` to SiteSettings
- `admin/src/pages/SettingsPage.tsx` — added toggle
- `admin/src/App.tsx` — added 6 new volunteer routes
### Phase 6 — Social Activity Feed
- `api/src/modules/social/feed.service.ts` — aggregates 4 activity types + Redis cache (2-min TTL)
- `api/src/modules/social/feed.routes.ts` — GET `/` (friend feed), GET `/my` (own activity)
- `admin/src/components/social/FeedCard.tsx` — activity card with type-based icon/color
- `admin/src/pages/volunteer/SocialFeedPage.tsx` — feed page with suggestions widget
### Phase 7 — CRM Bridge + Suggestions
- `api/src/modules/social/suggestions.service.ts` — ranked suggestions (household/mutual/shifts/campaigns)
- `api/src/modules/social/suggestions.routes.ts` — GET `/`, POST `/:userId/dismiss`
- `admin/src/components/social/FriendSuggestions.tsx` — horizontal scroll suggestions widget
- `admin/src/pages/volunteer/DiscoverPage.tsx` — search + suggestions page
### Phase 8 — Poke System + Video Recommendations
- `api/src/modules/social/poke.service.ts` — poke CRUD + 24h Redis cooldown per pair
- `api/src/modules/social/poke.routes.ts` — POST `/`, GET `/`, GET `/count`, POST `/:id/read`, GET `/cooldown/:userId`
- `api/src/modules/social/recommendation.service.ts` — video recommendation CRUD + duplicate detection
- `api/src/modules/social/recommendation.routes.ts` — POST `/`, GET `/`, GET `/sent`, GET `/count`, POST `/:id/read`
- `admin/src/components/social/PokeButton.tsx` — poke button with cooldown indicator
- `admin/src/components/social/RecommendVideoModal.tsx` — friend + video picker modal
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added PokeButton
### Phase 10 — Email Digest Notifications
- `api/prisma/migrations/20260224232546_add_digest_frequency/` — adds digestFrequency + lastDigestSentAt
- `api/src/services/social-digest.service.ts` — daily scan, generates digest emails
- `api/src/templates/email/social-digest.html` + `.txt` — digest email templates
- `api/src/server.ts` — added daily social digest scan interval
- `admin/src/pages/volunteer/NotificationsPage.tsx` — added digest frequency selector
### Phase 11 — Social Integration with Existing Features
- `api/src/modules/social/integration.service.ts` — friends on shifts, campaigns, and active map sessions (privacy-filtered)
- `api/src/modules/social/integration.routes.ts` — 3 endpoints: shifts/:id/friends, campaigns/:id/friends, map/friends
- `admin/src/components/social/FriendsAttendingBadge.tsx` — "N friends attending" badge with stacked avatars
- `admin/src/components/social/FriendsCampaignBadge.tsx` — "N friends participated" badge with stacked avatars
- `admin/src/components/social/FriendsOnMap.tsx` — floating panel showing friends currently canvassing (60s poll)
- `admin/src/pages/public/ShiftsPage.tsx` — added FriendsAttendingBadge per shift card
- `admin/src/pages/public/CampaignPage.tsx` — added FriendsCampaignBadge in hero section
- `admin/src/pages/volunteer/VolunteerShiftsPage.tsx` — added FriendsAttendingBadge per shift card
- `admin/src/pages/volunteer/VolunteerMapPage.tsx` — added FriendsOnMap overlay
- `admin/src/types/social.ts` — added FriendOnShift, FriendOnCampaign, FriendOnMap types
### Phase 12 — Rocket.Chat DM Integration
- `api/src/modules/social/messaging.service.ts` — openDM: provisions both users, creates DM room, returns roomId
- `api/src/modules/social/profile.routes.ts` — added POST `/:userId/dm` endpoint
- `api/src/services/rocketchat.client.ts` — added `createDM(usernames)` method
- `admin/src/components/social/MessageButton.tsx` — DM button (opens chat widget, RC-gated)
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added MessageButton for accepted friends
### Phase 13 — Group/Team Features
- `api/prisma/schema.prisma` — added SocialGroup, SocialGroupMember models + SocialGroupType enum + User.socialGroupMemberships relation
- `api/prisma/migrations/20260225000017_add_social_groups/` — migration creating social_groups + social_group_members tables
- `api/src/modules/social/group.service.ts` — getOrCreate, syncShiftTeam, syncCampaignTeam, listMyGroups, getGroupDetail
- `api/src/modules/social/group.routes.ts` — GET `/` (my groups), GET `/:id` (group detail)
- `api/src/modules/social/social.routes.ts` — mounted groupRouter at `/groups`
- `api/src/modules/map/shifts/shifts.service.ts` — added fire-and-forget groupService.syncShiftTeam() on all signup/cancel events
- `api/src/modules/influence/campaign-emails/campaign-emails.service.ts` — added fire-and-forget groupService.syncCampaignTeam() on email creation
- `admin/src/types/social.ts` — added SocialGroupSummary, SocialGroupDetail interfaces
- `admin/src/components/social/GroupCard.tsx` — group card with type-based icon/color
- `admin/src/pages/volunteer/GroupDetailPage.tsx` — group detail with member list + FriendButton per member
- `admin/src/pages/volunteer/FriendsPage.tsx` — added "Groups" tab
- `admin/src/App.tsx` — added `/volunteer/groups/:id` route
### Phase 14 — Gamification (Achievements, Streaks, Leaderboards)
- `api/src/modules/social/achievements.service.ts` — 11 achievements (4 shift, 4 canvass, 2 campaign, 2 social), checkAndUnlock, getLeaderboard (raw SQL), getVolunteerStats (on-the-fly computed)
- `api/src/modules/social/achievements.routes.ts` — 6 endpoints: achievements, definitions, stats, stats/:userId, user/:userId, leaderboard
- `api/src/modules/social/social.routes.ts` — mounted achievementsRouter at `/achievements`
- `api/src/modules/map/canvass/canvass.service.ts` — added achievements.checkAndUnlock after recordVisit
- `api/src/modules/map/shifts/shifts.service.ts` — added achievements.checkAndUnlock after signup events (admin, public, volunteer)
- `api/src/modules/influence/campaign-emails/campaign-emails.service.ts` — added achievements.checkAndUnlock after email creation
- `api/src/modules/social/friendship.service.ts` — added achievements.checkAndUnlock on friend accept (both users)
- `admin/src/types/social.ts` — added AchievementDef, AchievementWithProgress, VolunteerStats, LeaderboardEntry interfaces
- `admin/src/pages/volunteer/AchievementsPage.tsx` — badge gallery (locked/unlocked + progress bars), volunteer stats summary, leaderboard (canvass/shifts/campaigns tabs)
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added achievement badges section to own + other user profiles
- `admin/src/pages/volunteer/SocialFeedPage.tsx` — added top canvassers leaderboard widget
- `admin/src/App.tsx` — added `/volunteer/achievements` route
### Phase 15 — Real-Time Features (SSE for Live Notifications, Online Status)
- `api/src/modules/social/sse.service.ts` — in-memory SSE connection manager (addClient, removeClient, sendToUser, sendToUsers, heartbeat, closeAll)
- `api/src/modules/social/presence.service.ts` — online/offline tracking with privacy filtering, broadcastPresence to friends, stale cleanup (5min timeout), markAllOffline (startup)
- `api/src/modules/social/sse.routes.ts` — GET `/` (SSE stream), GET `/online-friends`, GET `/status`
- `api/src/modules/social/social.routes.ts` — mounted sseRouter at `/sse`, added query-param token injection for EventSource auth
- `api/src/modules/social/notification.service.ts` — SSE push after notification creation (real-time delivery)
- `api/src/modules/social/friendship.service.ts` — SSE push on friend_request + friend_accepted events
- `api/src/modules/social/poke.service.ts` — SSE push on poke events
- `api/src/server.ts` — SSE heartbeat start, presenceService.markAllOffline on startup, 1-min stale cleanup interval, sseService.closeAll on graceful shutdown
- `admin/src/hooks/useSSE.ts` — EventSource hook with auto-reconnect (exponential backoff), handles notification/presence/friend_request/friend_accepted/poke events
- `admin/src/components/social/OnlineIndicator.tsx` — green dot showing online status for friends
- `admin/src/components/social/UserAvatar.tsx` — added showOnline prop with OnlineIndicator overlay
- `admin/src/stores/social.store.ts` — added onlineFriends state + fetchOnlineFriends action
- `admin/src/components/VolunteerLayout.tsx` — initialized useSSE() on mount
- `admin/src/components/social/NotificationBell.tsx` — reduced polling to 2-min fallback (SSE handles real-time)
- `admin/src/pages/volunteer/FriendsPage.tsx` — enabled showOnline on friend list avatars
- `admin/src/pages/volunteer/SocialProfilePage.tsx` — enabled showOnline on other user profile avatars

6
admin/.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.git
*.log
.env
.env.*

View File

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

View File

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

238
admin/package-lock.json generated
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",
@ -1154,9 +1156,9 @@
"dev": true "dev": true
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1167,9 +1169,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1180,9 +1182,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1193,9 +1195,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1206,9 +1208,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1219,9 +1221,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1232,9 +1234,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1245,9 +1247,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1258,9 +1260,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1271,9 +1273,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1284,9 +1286,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -1297,9 +1299,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -1310,9 +1312,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1323,9 +1325,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1336,9 +1338,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1349,9 +1351,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1362,9 +1364,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1375,9 +1377,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1388,9 +1390,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1401,9 +1403,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1414,9 +1416,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1427,9 +1429,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1440,9 +1442,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1453,9 +1455,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1466,9 +1468,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2261,9 +2263,9 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.3.1", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
@ -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",
@ -2860,9 +2873,9 @@
"dev": true "dev": true
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -3651,9 +3664,9 @@
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
@ -3666,31 +3679,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -3993,10 +4006,9 @@
"dev": true "dev": true
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },

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

@ -32,6 +32,7 @@ import CodeEditorPage from '@/pages/CodeEditorPage';
import NocoDBPage from '@/pages/NocoDBPage'; import NocoDBPage from '@/pages/NocoDBPage';
import N8nPage from '@/pages/N8nPage'; import N8nPage from '@/pages/N8nPage';
import GiteaPage from '@/pages/GiteaPage'; import GiteaPage from '@/pages/GiteaPage';
import GiteaSetupPage from '@/pages/GiteaSetupPage';
import MailHogPage from '@/pages/MailHogPage'; import MailHogPage from '@/pages/MailHogPage';
import MiniQRPage from '@/pages/MiniQRPage'; import MiniQRPage from '@/pages/MiniQRPage';
import ExcalidrawPage from '@/pages/ExcalidrawPage'; import ExcalidrawPage from '@/pages/ExcalidrawPage';
@ -42,9 +43,15 @@ 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 PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage'; import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage';
import SubscribersPage from '@/pages/payments/SubscribersPage'; import SubscribersPage from '@/pages/payments/SubscribersPage';
import PaymentProductsPage from '@/pages/payments/ProductsPage'; import PaymentProductsPage from '@/pages/payments/ProductsPage';
@ -60,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';
@ -98,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,
@ -110,6 +123,8 @@ import {
EVENTS_ROLES, EVENTS_ROLES,
SOCIAL_ROLES, SOCIAL_ROLES,
SYSTEM_ROLES, SYSTEM_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';
@ -130,6 +145,10 @@ import ReferralAdminPage from '@/pages/social/ReferralAdminPage';
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage'; 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 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';
@ -140,6 +159,8 @@ import MeetingAgendaPage from '@/pages/MeetingAgendaPage';
import ActionItemsPage from '@/pages/ActionItemsPage'; import ActionItemsPage from '@/pages/ActionItemsPage';
import SchedulingPollPage from '@/pages/public/SchedulingPollPage'; import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
import PollsListPage from '@/pages/public/PollsListPage'; import PollsListPage from '@/pages/public/PollsListPage';
import StrawPollPage from '@/pages/public/StrawPollPage';
import StrawPollsListPage from '@/pages/public/StrawPollsListPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage'; import JitsiAuthPage from '@/pages/JitsiAuthPage';
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage'; import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage'; import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
@ -153,6 +174,7 @@ import MyCalendarPage from '@/pages/volunteer/MyCalendarPage';
import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage'; import SharedCalendarsPage from '@/pages/volunteer/SharedCalendarsPage';
import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage'; import SharedCalendarViewPage from '@/pages/volunteer/SharedCalendarViewPage';
import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage'; import FriendCalendarPage from '@/pages/volunteer/FriendCalendarPage';
import SharedDocEditorPage from '@/pages/public/SharedDocEditorPage';
import NotFoundPage from '@/pages/NotFoundPage'; import NotFoundPage from '@/pages/NotFoundPage';
import CommandPalette from '@/components/command-palette/CommandPalette'; import CommandPalette from '@/components/command-palette/CommandPalette';
@ -165,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() {
@ -233,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>
@ -273,6 +301,14 @@ export default function App() {
<Route index element={<SchedulingPollPage />} /> <Route index element={<SchedulingPollPage />} />
</Route> </Route>
{/* Straw polls — feature-gated */}
<Route path="/straw-polls" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
<Route index element={<StrawPollsListPage />} />
</Route>
<Route path="/straw-poll/:slug" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
<Route index element={<StrawPollPage />} />
</Route>
{/* Public ticketed event pages — feature-gated */} {/* Public ticketed event pages — feature-gated */}
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}> <Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<TicketedEventDetailPage />} /> <Route index element={<TicketedEventDetailPage />} />
@ -313,6 +349,9 @@ export default function App() {
<Route index element={<ContactProfilePage />} /> <Route index element={<ContactProfilePage />} />
</Route> </Route>
{/* Shared doc editor (no auth, token-based access) */}
<Route path="/docs/share/:shareToken" element={<SharedDocEditorPage />} />
{/* Public Media Gallery (purple theme) — feature-gated */} {/* Public Media Gallery (purple theme) — feature-gated */}
<Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}> <Route path="/gallery" element={<FeatureGate feature="enableMediaFeatures"><MediaPublicLayout /></FeatureGate>}>
<Route index element={<MediaGalleryPage />} /> <Route index element={<MediaGalleryPage />} />
@ -334,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 />
@ -362,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 />} />
@ -381,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>
@ -556,6 +597,62 @@ 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
path="influence/straw-polls"
element={
<ProtectedRoute requiredRoles={POLLS_ROLES}>
<StrawPollsPage />
</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={
@ -604,6 +701,14 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="docs/metadata"
element={
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<DocsMetadataPage />
</ProtectedRoute>
}
/>
<Route <Route
path="navigation" path="navigation"
element={ element={
@ -644,6 +749,14 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="services/gitea/setup"
element={
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<GiteaSetupPage />
</ProtectedRoute>
}
/>
<Route <Route
path="services/mailhog" path="services/mailhog"
element={ element={
@ -765,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={
@ -773,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

@ -54,6 +54,7 @@ import {
TrophyOutlined, TrophyOutlined,
FlagOutlined, FlagOutlined,
UserAddOutlined, UserAddOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@ -70,6 +71,8 @@ import {
MEDIA_ROLES, MEDIA_ROLES,
PAYMENTS_ROLES, PAYMENTS_ROLES,
SOCIAL_ROLES, SOCIAL_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';
@ -83,6 +86,10 @@ import {
} from '@/lib/nav-defaults'; } from '@/lib/nav-defaults';
import { useCommandPaletteStore } from '@/stores/command-palette.store'; import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useFavoritesStore } from '@/stores/favorites.store'; import { useFavoritesStore } from '@/stores/favorites.store';
import { useTourStore } from '@/stores/tour.store';
import { AdminTour } from './tour/AdminTour';
import { TourHub } from './tour/TourHub';
import { TourTriggerButton } from './tour/TourTriggerButton';
import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items'; import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items';
import RocketChatWidget from './chat/RocketChatWidget'; import RocketChatWidget from './chat/RocketChatWidget';
@ -180,8 +187,14 @@ 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' }] : []),
], ],
}); });
} }
@ -226,6 +239,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' }); webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' }); webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' }); webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
webChildren.push({ key: '/app/docs/metadata', icon: <DatabaseOutlined />, label: 'Metadata' });
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' }); webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' }); webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
items.push({ items.push({
@ -318,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 />,
@ -325,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' },
@ -333,6 +362,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ type: 'group', label: 'Tools', children: [ { type: 'group', label: 'Tools', children: [
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' }, { key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' }, { key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/gitea/setup', icon: <SettingOutlined />, label: 'Gitea Setup' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' }, { key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' }, { key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
]}, ]},
@ -431,6 +461,14 @@ export default function AppLayout() {
}; };
const userMenuItems: MenuProps['items'] = [ const userMenuItems: MenuProps['items'] = [
{
key: 'tour',
icon: <QuestionCircleOutlined />,
label: 'Learning Tours',
onClick: () => {
useTourStore.getState().openHub();
},
},
{ {
key: 'logout', key: 'logout',
icon: <LogoutOutlined />, icon: <LogoutOutlined />,
@ -578,6 +616,7 @@ export default function AppLayout() {
trigger={null} trigger={null}
collapsible collapsible
collapsed={collapsed} collapsed={collapsed}
data-tour="sidebar"
style={{ overflow: 'auto', height: '100vh', position: 'sticky', top: 0, left: 0 }} style={{ overflow: 'auto', height: '100vh', position: 'sticky', top: 0, left: 0 }}
> >
{sidebarMenu} {sidebarMenu}
@ -615,11 +654,12 @@ export default function AppLayout() {
<Button <Button
type="text" type="text"
icon={<SearchOutlined />} icon={<SearchOutlined />}
data-tour="search-button"
onClick={() => useCommandPaletteStore.getState().open()} onClick={() => useCommandPaletteStore.getState().open()}
/> />
</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);
@ -628,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);
} }
@ -654,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>
); );
@ -667,25 +710,27 @@ 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 />}> <Button type="text" icon={<UserOutlined />} data-tour="user-menu">
{!isMobile && !collapsed && ( {!isMobile && !collapsed && (
<Text style={{ marginLeft: 8 }}> <Text style={{ marginLeft: 8 }}>
{user?.name || user?.email || 'User'} {user?.name || user?.email || 'User'}
@ -695,6 +740,7 @@ export default function AppLayout() {
</Dropdown> </Dropdown>
</Header> </Header>
<Content <Content
id="app-content-area"
style={{ style={{
margin: fullBleed ? 0 : (isMobile ? 12 : 24), margin: fullBleed ? 0 : (isMobile ? 12 : 24),
padding: fullBleed ? 0 : (isMobile ? 16 : 24), padding: fullBleed ? 0 : (isMobile ? 16 : 24),
@ -702,12 +748,16 @@ export default function AppLayout() {
borderRadius: fullBleed ? 0 : token.borderRadiusLG, borderRadius: fullBleed ? 0 : token.borderRadiusLG,
minHeight: 280, minHeight: 280,
overflow: fullBleed ? 'hidden' : undefined, overflow: fullBleed ? 'hidden' : undefined,
position: 'relative',
}} }}
> >
<Outlet context={{ setPageHeader } satisfies AppOutletContext} /> <Outlet context={{ setPageHeader } satisfies AppOutletContext} />
</Content> </Content>
</Layout> </Layout>
</Layout> </Layout>
<AdminTour />
<TourHub />
<TourTriggerButton />
<RocketChatWidget /> <RocketChatWidget />
</> </>
); );

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Result, Button } from 'antd'; import { Result, Button, Skeleton } from 'antd';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { SettingOutlined } from '@ant-design/icons'; import { SettingOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -22,10 +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',
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'>; 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;
} }
@ -36,8 +39,8 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']); const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const featureName = FEATURE_LABELS[feature] || feature; const featureName = FEATURE_LABELS[feature] || feature;
// While loading or if settings haven't arrived yet, render children (optimistic) // Show skeleton while settings are loading to prevent briefly showing disabled features
if (loading || !settings) return <>{children}</>; if (loading || !settings) return <Skeleton active style={{ padding: 24 }} />;
if (settings[feature] === false) { if (settings[feature] === false) {
return ( return (

View File

@ -571,6 +571,40 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
</div> </div>
</section>`; </section>`;
} }
case 'straw-poll-inline': {
const pollSlug = (defaults.pollSlug as string) || '';
return `
<section style="padding: 60px 40px;">
<div class="straw-poll-inline"
data-poll-slug="${pollSlug}"
data-show-results="true"
style="max-width: 500px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); border-radius: 12px; padding: 32px; text-align: center; color: #fff;">
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 1024 1024">
<path d="M160 960h128V480H160v480zm256 0h128V320H416v640zm256 0h128V160H672v800z"/>
</svg>
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">Straw Poll (Inline)</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Inline voting widget renders on published page</p>
</div>
</div>
</section>`;
}
case 'straw-poll-card': {
const pollSlug = (defaults.pollSlug as string) || '';
return `
<section style="padding: 40px;">
<div class="straw-poll-card"
data-poll-slug="${pollSlug}"
style="max-width: 400px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #722ed1 0%, #531dab 100%); border-radius: 12px; padding: 24px; text-align: center; color: #fff;">
<p style="margin: 0; font-size: 1rem; font-weight: 600;">Straw Poll (Card Link)</p>
<p style="margin: 8px 0 0; font-size: 0.85rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
<p style="margin: 8px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Preview card with vote link renders on published page</p>
</div>
</div>
</section>`;
}
default: default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`; return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
} }

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

@ -344,6 +344,7 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
justifyContent: 'space-between', justifyContent: 'space-between',
padding: '0 24px', padding: '0 24px',
height: 56, height: 56,
overflow: 'hidden',
flexShrink: 0, flexShrink: 0,
}} }}
> >
@ -374,7 +375,7 @@ export default function PublicNavBar({ activePath, showAuth = true, onSignInClic
/> />
</Space> </Space>
) : ( ) : (
<Space size={navCollapsed ? 8 : 16}> <Space size={navCollapsed ? 8 : 16} style={{ flexWrap: 'nowrap', overflow: 'hidden' }}>
{visibleNavItems.map(renderDesktopLink)} {visibleNavItems.map(renderDesktopLink)}
{overflowMenuItems.length > 0 && ( {overflowMenuItems.length > 0 && (
<Dropdown <Dropdown

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

@ -16,6 +16,7 @@ import { PlusOutlined, CheckCircleOutlined, VideoCameraOutlined, CopyOutlined }
import axios from 'axios'; import axios from 'axios';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { TextArea } = Input; const { TextArea } = Input;
const { Text } = Typography; const { Text } = Typography;
@ -79,9 +80,8 @@ export default function EventSubmissionForm({ initialDate, onSuccess, gancioUrl,
setSuccess(true); setSuccess(true);
form.resetFields(); form.resetFields();
onSuccess?.(); onSuccess?.();
} catch (err: any) { } catch (err: unknown) {
const msg = err.response?.data?.error?.message || err.response?.data?.error || 'Failed to submit event'; message.error(getErrorMessage(err, 'Failed to submit event'));
message.error(msg);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }

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

@ -48,10 +48,11 @@ export default function ChatPanel({ panel, leftOffset }: Props) {
if (!rcAuthToken || !iframeRef.current?.contentWindow) return; if (!rcAuthToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => { const sendToken = () => {
if (!iframeRef.current?.contentWindow) return; if (!iframeRef.current?.contentWindow || !rcServiceUrl) return;
const targetOrigin = new URL(rcServiceUrl).origin;
iframeRef.current.contentWindow.postMessage( iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: rcAuthToken }, { event: 'login-with-token', loginToken: rcAuthToken },
'*', targetOrigin,
); );
}; };

View File

@ -240,7 +240,12 @@ export default function CommandPalette() {
if (flatItem.type === 'command') { if (flatItem.type === 'command') {
const cmd = flatItem.item; const cmd = flatItem.item;
addRecent(cmd.id); addRecent(cmd.id);
navigate(cmd.path, { state: cmd.navigationState }); // Special handling for non-navigation actions
if (cmd.id === 'action-learning-tours') {
import('@/stores/tour.store').then(({ useTourStore }) => useTourStore.getState().openHub());
} else {
navigate(cmd.path, { state: cmd.navigationState });
}
} else { } else {
navigate(flatItem.item.path, { state: flatItem.item.navigationState }); navigate(flatItem.item.path, { state: flatItem.item.navigationState });
} }

View File

@ -790,6 +790,18 @@ export const commandRegistry: CommandItem[] = [
requiredRoles: ['SUPER_ADMIN'], requiredRoles: ['SUPER_ADMIN'],
}, },
// ── Help & Tours ────────────────────────────────────────
{
id: 'action-learning-tours',
title: 'Learning Tours',
description: 'Browse interactive tutorials for each section of the admin',
group: 'Actions',
path: '',
icon: 'QuestionCircleOutlined',
keywords: ['tour', 'help', 'learn', 'tutorial', 'guide', 'onboarding', 'walkthrough'],
category: 'action',
},
// ── Quick actions ───────────────────────────────────── // ── Quick actions ─────────────────────────────────────
{ {
id: 'action-create-campaign', id: 'action-create-campaign',

View File

@ -0,0 +1,391 @@
import { useState, useEffect } from 'react';
import {
Drawer,
Button,
Input,
Space,
Typography,
Popconfirm,
message,
theme,
Divider,
Empty,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
SaveOutlined,
CloseOutlined,
UserOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
type AuthorsMap = Record<string, AuthorEntry>;
interface AuthorsManagementModalProps {
open: boolean;
onClose: () => void;
authors: AuthorsMap;
onSaved: () => void;
}
interface AuthorFormState {
id: string;
name: string;
description: string;
avatar: string;
}
const emptyForm: AuthorFormState = { id: '', name: '', description: '', avatar: '' };
export function AuthorsManagementModal({
open,
onClose,
authors,
onSaved,
}: AuthorsManagementModalProps) {
const [messageApi, contextHolder] = message.useMessage();
const [localAuthors, setLocalAuthors] = useState<AuthorsMap>({});
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editForm, setEditForm] = useState<AuthorFormState>(emptyForm);
const [addingNew, setAddingNew] = useState(false);
const [newForm, setNewForm] = useState<AuthorFormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
// Sync from props when modal opens
useEffect(() => {
if (open) {
setLocalAuthors({ ...authors });
setEditingKey(null);
setAddingNew(false);
setNewForm(emptyForm);
setDirty(false);
}
}, [open, authors]);
const startEdit = (key: string) => {
const entry = localAuthors[key];
if (!entry) return;
setEditingKey(key);
setEditForm({
id: key,
name: entry.name,
description: entry.description ?? '',
avatar: entry.avatar ?? '',
});
};
const cancelEdit = () => {
setEditingKey(null);
setEditForm(emptyForm);
};
const saveEdit = () => {
if (!editForm.name.trim()) {
messageApi.warning('Name is required');
return;
}
const updated = { ...localAuthors };
// If key changed, remove old and add new
if (editingKey && editingKey !== editForm.id && editForm.id.trim()) {
delete updated[editingKey];
}
const key = editForm.id.trim() || editingKey!;
updated[key] = buildEntry(editForm);
setLocalAuthors(updated);
setEditingKey(null);
setEditForm(emptyForm);
setDirty(true);
};
const deleteAuthor = (key: string) => {
const updated = { ...localAuthors };
delete updated[key];
setLocalAuthors(updated);
setDirty(true);
if (editingKey === key) cancelEdit();
};
const saveNewAuthor = () => {
if (!newForm.id.trim()) {
messageApi.warning('Author ID is required');
return;
}
if (!newForm.name.trim()) {
messageApi.warning('Name is required');
return;
}
if (localAuthors[newForm.id.trim()]) {
messageApi.warning('An author with this ID already exists');
return;
}
const updated = { ...localAuthors };
updated[newForm.id.trim()] = buildEntry(newForm);
setLocalAuthors(updated);
setNewForm(emptyForm);
setAddingNew(false);
setDirty(true);
};
const handleSaveAll = async () => {
setSaving(true);
try {
await api.put('/docs/blog/authors', { authors: localAuthors });
messageApi.success('Authors saved');
setDirty(false);
onSaved();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
?.error?.message || 'Failed to save authors';
messageApi.error(msg);
} finally {
setSaving(false);
}
};
const authorEntries = Object.entries(localAuthors);
return (
<Drawer
title={
<span>
<UserOutlined style={{ marginRight: 8 }} />
Manage Authors
</span>
}
open={open}
onClose={onClose}
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}
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{authorEntries.length === 0 && !addingNew && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No authors defined yet"
style={{ margin: '24px 0' }}
/>
)}
{authorEntries.map(([key, entry]) => (
<div key={key}>
{editingKey === key ? (
<AuthorEditForm
form={editForm}
onChange={setEditForm}
onSave={saveEdit}
onCancel={cancelEdit}
showId
/>
) : (
<AuthorRow
id={key}
entry={entry}
onEdit={() => startEdit(key)}
onDelete={() => deleteAuthor(key)}
/>
)}
<Divider style={{ margin: '8px 0' }} />
</div>
))}
{/* New author form */}
{addingNew ? (
<AuthorEditForm
form={newForm}
onChange={setNewForm}
onSave={saveNewAuthor}
onCancel={() => {
setAddingNew(false);
setNewForm(emptyForm);
}}
showId
isNew
/>
) : (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => setAddingNew(true)}
block
style={{ marginTop: 8 }}
>
Add Author
</Button>
)}
</div>
</Drawer>
);
}
function buildEntry(form: AuthorFormState): AuthorEntry {
const entry: AuthorEntry = { name: form.name.trim() };
if (form.description.trim()) entry.description = form.description.trim();
if (form.avatar.trim()) entry.avatar = form.avatar.trim();
return entry;
}
/** Read-only author row */
function AuthorRow({
id,
entry,
onEdit,
onDelete,
}: {
id: string;
entry: AuthorEntry;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
padding: '8px 4px',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Typography.Text strong>{entry.name}</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 11, fontFamily: 'monospace' }}
>
{id}
</Typography.Text>
</div>
{entry.description && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, display: 'block', marginTop: 2 }}
>
{entry.description}
</Typography.Text>
)}
{entry.avatar && (
<Typography.Text
type="secondary"
style={{
fontSize: 11,
display: 'block',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
Avatar: {entry.avatar}
</Typography.Text>
)}
</div>
<Space size={4}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={onEdit} />
<Popconfirm
title="Delete this author?"
onConfirm={onDelete}
okText="Delete"
okButtonProps={{ danger: true }}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
</div>
);
}
/** Inline edit / create form for an author */
function AuthorEditForm({
form,
onChange,
onSave,
onCancel,
showId,
isNew,
}: {
form: AuthorFormState;
onChange: (f: AuthorFormState) => void;
onSave: () => void;
onCancel: () => void;
showId?: boolean;
isNew?: boolean;
}) {
const { token } = theme.useToken();
return (
<div
style={{
padding: '10px',
borderRadius: token.borderRadius,
background: token.colorFillQuaternary,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{showId && (
<Input
size="small"
placeholder="Author ID (e.g. jdoe)"
value={form.id}
onChange={(e) => onChange({ ...form, id: e.target.value })}
addonBefore="ID"
disabled={!isNew && !!form.id}
/>
)}
<Input
size="small"
placeholder="Display name"
value={form.name}
onChange={(e) => onChange({ ...form, name: e.target.value })}
addonBefore="Name"
autoFocus
/>
<Input
size="small"
placeholder="Short description (optional)"
value={form.description}
onChange={(e) => onChange({ ...form, description: e.target.value })}
addonBefore="Desc"
/>
<Input
size="small"
placeholder="Avatar URL (optional)"
value={form.avatar}
onChange={(e) => onChange({ ...form, avatar: e.target.value })}
addonBefore="Avatar"
/>
<Space size={8}>
<Button size="small" type="primary" icon={<SaveOutlined />} onClick={onSave}>
{isNew ? 'Add' : 'Update'}
</Button>
<Button size="small" icon={<CloseOutlined />} onClick={onCancel}>
Cancel
</Button>
</Space>
</div>
);
}

View File

@ -0,0 +1,265 @@
import { DatePicker, Select, Switch, Input, Typography, theme, Button, Tooltip } from 'antd';
import {
CalendarOutlined,
UserOutlined,
TagsOutlined,
FolderOutlined,
FileTextOutlined,
EyeInvisibleOutlined,
LinkOutlined,
TeamOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { BlogFrontmatter } from '@/hooks/useBlogFrontmatter';
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
interface BlogFrontmatterPanelProps {
frontmatter: BlogFrontmatter | null;
onUpdate: (field: string, value: unknown) => void;
authors: Record<string, AuthorEntry>;
categories: string[];
loading: boolean;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
onManageAuthors: () => void;
}
const PANEL_WIDTH = 250;
export function BlogFrontmatterPanel({
frontmatter,
onUpdate,
authors,
categories,
loading,
collapsed,
onCollapsedChange,
onManageAuthors,
}: BlogFrontmatterPanelProps) {
const { token } = theme.useToken();
if (!frontmatter) return null;
const authorOptions = Object.entries(authors || {}).map(([key, entry]) => ({
label: entry.name,
value: key,
}));
const categoryOptions = (categories || []).map((cat) => ({
label: cat,
value: cat,
}));
if (collapsed) {
return (
<div
style={{
width: 32,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingTop: 8,
borderLeft: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
}}
>
<Tooltip title="Show blog panel" placement="left">
<Button
type="text"
size="small"
icon={<LeftOutlined />}
onClick={() => onCollapsedChange(false)}
/>
</Tooltip>
</div>
);
}
return (
<div
style={{
width: PANEL_WIDTH,
flexShrink: 0,
borderLeft: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgContainer,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Typography.Text strong style={{ fontSize: 13 }}>
Blog
</Typography.Text>
<Tooltip title="Collapse panel">
<Button
type="text"
size="small"
icon={<RightOutlined />}
onClick={() => onCollapsedChange(true)}
/>
</Tooltip>
</div>
{/* Fields */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
{/* Date */}
<FieldGroup icon={<CalendarOutlined />} label="Date">
<DatePicker
value={frontmatter.date ? dayjs(frontmatter.date) : undefined}
onChange={(d) => onUpdate('date', d ? d.format('YYYY-MM-DD') : undefined)}
format="YYYY-MM-DD"
size="small"
style={{ width: '100%' }}
allowClear
/>
</FieldGroup>
{/* Authors */}
<FieldGroup
icon={<UserOutlined />}
label="Authors"
extra={
<Tooltip title="Manage authors">
<Button
type="link"
size="small"
icon={<TeamOutlined />}
onClick={onManageAuthors}
style={{ padding: 0, height: 'auto', fontSize: 11 }}
/>
</Tooltip>
}
>
<Select
mode="multiple"
size="small"
placeholder="Select authors"
value={frontmatter.authors ?? []}
onChange={(val) => onUpdate('authors', val)}
options={authorOptions}
loading={loading}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
</FieldGroup>
{/* Categories */}
<FieldGroup icon={<FolderOutlined />} label="Categories">
<Select
mode="tags"
size="small"
placeholder="Add categories"
value={frontmatter.categories ?? []}
onChange={(val) => onUpdate('categories', val)}
options={categoryOptions}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
</FieldGroup>
{/* Tags */}
<FieldGroup icon={<TagsOutlined />} label="Tags">
<Select
mode="tags"
size="small"
placeholder="Add tags"
value={frontmatter.tags ?? []}
onChange={(val) => onUpdate('tags', val)}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
</FieldGroup>
{/* Draft */}
<FieldGroup icon={<EyeInvisibleOutlined />} label="Draft">
<Switch
size="small"
checked={frontmatter.draft ?? false}
onChange={(checked) => onUpdate('draft', checked || undefined)}
/>
</FieldGroup>
{/* Slug */}
<FieldGroup icon={<LinkOutlined />} label="Slug">
<Input
size="small"
placeholder="auto-generated"
value={frontmatter.slug ?? ''}
onChange={(e) => onUpdate('slug', e.target.value || undefined)}
allowClear
/>
</FieldGroup>
{/* Description */}
<FieldGroup icon={<FileTextOutlined />} label="Description">
<Input.TextArea
size="small"
rows={3}
placeholder="Post description / excerpt"
value={frontmatter.description ?? ''}
onChange={(e) => onUpdate('description', e.target.value || undefined)}
/>
</FieldGroup>
</div>
</div>
);
}
/** Small labeled field wrapper */
function FieldGroup({
icon,
label,
extra,
children,
}: {
icon: React.ReactNode;
label: string;
extra?: React.ReactNode;
children: React.ReactNode;
}) {
const { token } = theme.useToken();
return (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
marginBottom: 4,
}}
>
<span style={{ color: token.colorTextSecondary, fontSize: 12 }}>{icon}</span>
<Typography.Text
style={{ fontSize: 11, fontWeight: 600, color: token.colorTextSecondary, textTransform: 'uppercase', letterSpacing: 0.3 }}
>
{label}
</Typography.Text>
{extra && <span style={{ marginLeft: 'auto' }}>{extra}</span>}
</div>
{children}
</div>
);
}

View File

@ -0,0 +1,322 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Form,
Select,
Switch,
Button,
Spin,
Tag,
Space,
Typography,
Divider,
message,
Alert,
} from 'antd';
import {
LockOutlined,
TeamOutlined,
UserOutlined,
SaveOutlined,
UndoOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
const { Text, Title } = Typography;
interface DocAccessPolicyPanelProps {
open: boolean;
onClose: () => void;
documentPath: string | null;
}
interface EffectivePolicy {
id: string | null;
documentPath: string;
isDirectory: boolean;
allowedEditors: string[];
isDefault: boolean;
}
const AVAILABLE_ROLES: { value: string; label: string }[] = [
{ value: 'role:SUPER_ADMIN', label: 'Super Admin' },
{ value: 'role:CONTENT_ADMIN', label: 'Content Admin' },
{ value: 'role:INFLUENCE_ADMIN', label: 'Influence Admin' },
{ value: 'role:MAP_ADMIN', label: 'Map Admin' },
{ value: 'role:BROADCAST_ADMIN', label: 'Broadcast Admin' },
{ value: 'role:MEDIA_ADMIN', label: 'Media Admin' },
{ value: 'role:PAYMENTS_ADMIN', label: 'Payments Admin' },
{ value: 'role:EVENTS_ADMIN', label: 'Events Admin' },
{ value: 'role:SOCIAL_ADMIN', label: 'Social Admin' },
];
function editorLabel(editor: string): string {
if (editor === 'all_content_editors') return 'All Content Editors';
if (editor.startsWith('role:')) {
const roleName = editor.substring(5);
return roleName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
if (editor.startsWith('user:')) return editor.substring(5);
return editor;
}
function editorColor(editor: string): string {
if (editor === 'all_content_editors') return 'green';
if (editor.startsWith('role:')) return 'blue';
if (editor.startsWith('user:')) return 'purple';
return 'default';
}
export function DocAccessPolicyPanel({ open, onClose, documentPath }: DocAccessPolicyPanelProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [policy, setPolicy] = useState<EffectivePolicy | null>(null);
const [allContentEditors, setAllContentEditors] = useState(true);
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
const [userEmails, setUserEmails] = useState<string[]>([]);
const [isDirectory, setIsDirectory] = useState(false);
const fetchPolicy = useCallback(async () => {
if (!documentPath) return;
setLoading(true);
try {
const { data } = await api.get<EffectivePolicy>('/docs-access/policy', {
params: { path: documentPath },
});
setPolicy(data);
// Parse allowedEditors into form state
const hasAllContentEditors = data.allowedEditors.includes('all_content_editors');
setAllContentEditors(hasAllContentEditors);
setIsDirectory(data.isDirectory);
const roles: string[] = [];
const users: string[] = [];
for (const editor of data.allowedEditors) {
if (editor === 'all_content_editors') continue;
if (editor.startsWith('role:')) roles.push(editor);
else if (editor.startsWith('user:')) users.push(editor.substring(5));
}
setSelectedRoles(roles);
setUserEmails(users);
} catch {
message.error('Failed to load access policy');
} finally {
setLoading(false);
}
}, [documentPath]);
useEffect(() => {
if (open && documentPath) {
fetchPolicy();
}
}, [open, documentPath, fetchPolicy]);
const handleSave = async () => {
if (!documentPath) return;
setSaving(true);
try {
const allowedEditors: string[] = [];
if (allContentEditors) {
allowedEditors.push('all_content_editors');
}
allowedEditors.push(...selectedRoles);
allowedEditors.push(...userEmails.map((e) => `user:${e}`));
if (allowedEditors.length === 0) {
message.warning('At least one editor must be specified');
setSaving(false);
return;
}
await api.put('/docs-access/policy', {
documentPath,
isDirectory,
allowedEditors,
});
message.success('Access policy saved');
fetchPolicy();
} catch {
message.error('Failed to save access policy');
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!documentPath) return;
setSaving(true);
try {
await api.delete('/docs-access/policy', {
params: { path: documentPath },
});
message.success('Policy reset to default');
fetchPolicy();
} catch {
message.error('Failed to reset policy');
} finally {
setSaving(false);
}
};
const fileName = documentPath?.split('/').pop() || 'Document';
return (
<Drawer
title={
<Space>
<LockOutlined />
<span>Access Policy</span>
</Space>
}
open={open}
onClose={onClose}
width={480}
destroyOnClose
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : !documentPath ? (
<Alert message="No document selected" type="info" />
) : (
<>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Text type="secondary">Document</Text>
<br />
<Text strong>{fileName}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{documentPath}
</Text>
</div>
{policy?.isDefault && !policy.id && (
<Alert
message="Default policy"
description="No custom policy is set. All content editors can edit this file."
type="info"
showIcon
/>
)}
{policy && !policy.isDefault && (
<Alert
message="Custom policy active"
description={
policy.isDirectory
? `Directory policy applied from: ${policy.documentPath}`
: 'File-specific policy'
}
type="warning"
showIcon
/>
)}
<Divider style={{ margin: '8px 0' }} />
<Title level={5} style={{ margin: 0 }}>
Current Editors
</Title>
<div>
{policy?.allowedEditors.map((editor) => (
<Tag
key={editor}
color={editorColor(editor)}
icon={
editor === 'all_content_editors' ? (
<TeamOutlined />
) : editor.startsWith('user:') ? (
<UserOutlined />
) : undefined
}
style={{ marginBottom: 4 }}
>
{editorLabel(editor)}
</Tag>
))}
</div>
<Divider style={{ margin: '8px 0' }} />
<Title level={5} style={{ margin: 0 }}>
Edit Policy
</Title>
<Form layout="vertical" style={{ width: '100%' }}>
<Form.Item label="All content editors (default)">
<Switch
checked={allContentEditors}
onChange={setAllContentEditors}
checkedChildren="On"
unCheckedChildren="Off"
/>
</Form.Item>
<Form.Item label="Additional roles">
<Select
mode="multiple"
value={selectedRoles}
onChange={setSelectedRoles}
options={AVAILABLE_ROLES}
placeholder="Select roles..."
allowClear
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Specific users (by email)">
<Select
mode="tags"
value={userEmails}
onChange={setUserEmails}
placeholder="Type email and press Enter..."
tokenSeparators={[',']}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="Apply to directory">
<Switch
checked={isDirectory}
onChange={setIsDirectory}
checkedChildren="Directory"
unCheckedChildren="File only"
/>
{isDirectory && (
<Text type="secondary" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>
This policy will apply to all files within this directory and subdirectories.
</Text>
)}
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
>
Save Policy
</Button>
<Button
icon={<UndoOutlined />}
onClick={handleReset}
loading={saving}
disabled={policy?.isDefault ?? true}
>
Reset to Default
</Button>
</Space>
</Form.Item>
</Form>
</Space>
</>
)}
</Drawer>
);
}

View File

@ -0,0 +1,377 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Timeline,
Button,
Spin,
Space,
Typography,
Popconfirm,
message,
Empty,
Tag,
Divider,
theme,
} from 'antd';
import {
HistoryOutlined,
UserOutlined,
RollbackOutlined,
FileTextOutlined,
EyeOutlined,
CloseOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
interface DocHistoryDrawerProps {
open: boolean;
onClose: () => void;
documentPath: string | null;
currentContent: string;
onRestore: (content: string) => void;
}
interface HistoryCommit {
sha: string;
commit: {
message: string;
author: { name: string; email: string; date: string };
committer: { name: string; email: string; date: string };
};
}
export function DocHistoryDrawer({
open,
onClose,
documentPath,
currentContent,
onRestore,
}: DocHistoryDrawerProps) {
const { token: themeToken } = theme.useToken();
const [loading, setLoading] = useState(false);
const [commits, setCommits] = useState<HistoryCommit[]>([]);
const [selectedSha, setSelectedSha] = useState<string | null>(null);
const [revisionContent, setRevisionContent] = useState<string | null>(null);
const [loadingRevision, setLoadingRevision] = useState(false);
const [restoring, setRestoring] = useState(false);
const fetchHistory = useCallback(async () => {
if (!documentPath) return;
setLoading(true);
try {
const { data } = await api.get<{ commits: HistoryCommit[] }>(
`/docs/history/${documentPath}`,
);
setCommits(data.commits);
} catch {
message.error('Failed to load version history');
} finally {
setLoading(false);
}
}, [documentPath]);
useEffect(() => {
if (open && documentPath) {
fetchHistory();
setSelectedSha(null);
setRevisionContent(null);
}
}, [open, documentPath, fetchHistory]);
const handleSelectCommit = async (sha: string) => {
if (!documentPath) return;
if (selectedSha === sha) {
// Toggle off
setSelectedSha(null);
setRevisionContent(null);
return;
}
setSelectedSha(sha);
setLoadingRevision(true);
try {
const { data } = await api.get<{ sha: string; path: string; content: string }>(
`/docs/revision/${sha}/${documentPath}`,
);
setRevisionContent(data.content);
} catch {
message.error('Failed to load revision');
setRevisionContent(null);
} finally {
setLoadingRevision(false);
}
};
const handleRestore = async () => {
if (!documentPath || !selectedSha) return;
setRestoring(true);
try {
const { data } = await api.post<{ success: boolean; path: string }>(
`/docs/restore/${selectedSha}/${documentPath}`,
);
if (data.success && revisionContent) {
onRestore(revisionContent);
message.success('File restored to selected version');
setSelectedSha(null);
setRevisionContent(null);
fetchHistory();
} else {
message.error('Failed to restore revision');
}
} catch {
message.error('Failed to restore revision');
} finally {
setRestoring(false);
}
};
const fileName = documentPath?.split('/').pop() || 'Document';
// Simple line-by-line diff display
const renderDiffView = () => {
if (loadingRevision) {
return (
<div style={{ textAlign: 'center', padding: 20 }}>
<Spin />
</div>
);
}
if (revisionContent === null) return null;
const currentLines = currentContent.split('\n');
const historicalLines = revisionContent.split('\n');
return (
<div style={{ marginTop: 12 }}>
<Space style={{ marginBottom: 8 }}>
<Tag color="blue">Viewing revision {selectedSha?.substring(0, 7)}</Tag>
<Popconfirm
title="Restore this version?"
description="The current file content will be replaced with this revision."
onConfirm={handleRestore}
okText="Restore"
cancelText="Cancel"
>
<Button
type="primary"
size="small"
icon={<RollbackOutlined />}
loading={restoring}
>
Restore
</Button>
</Popconfirm>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => {
setSelectedSha(null);
setRevisionContent(null);
}}
>
Close
</Button>
</Space>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 8,
}}
>
<div>
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
Historical ({selectedSha?.substring(0, 7)})
</Text>
<pre
style={{
background: themeToken.colorBgLayout,
border: `1px solid ${themeToken.colorBorderSecondary}`,
borderRadius: themeToken.borderRadius,
padding: 8,
fontSize: 11,
lineHeight: 1.5,
overflow: 'auto',
maxHeight: 400,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
}}
>
{historicalLines.map((line, i) => {
const isDiff = i < currentLines.length && line !== currentLines[i];
const isAdded = i >= currentLines.length;
return (
<div
key={i}
style={{
background: isDiff
? 'rgba(82, 196, 26, 0.1)'
: isAdded
? 'rgba(82, 196, 26, 0.15)'
: undefined,
}}
>
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
{i + 1}
</Text>
{line}
</div>
);
})}
</pre>
</div>
<div>
<Text type="secondary" strong style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>
Current
</Text>
<pre
style={{
background: themeToken.colorBgLayout,
border: `1px solid ${themeToken.colorBorderSecondary}`,
borderRadius: themeToken.borderRadius,
padding: 8,
fontSize: 11,
lineHeight: 1.5,
overflow: 'auto',
maxHeight: 400,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
}}
>
{currentLines.map((line, i) => {
const isDiff = i < historicalLines.length && line !== historicalLines[i];
const isAdded = i >= historicalLines.length;
return (
<div
key={i}
style={{
background: isDiff
? 'rgba(245, 34, 45, 0.1)'
: isAdded
? 'rgba(245, 34, 45, 0.15)'
: undefined,
}}
>
<Text type="secondary" style={{ fontSize: 10, userSelect: 'none', marginRight: 8, display: 'inline-block', width: 30, textAlign: 'right' }}>
{i + 1}
</Text>
{line}
</div>
);
})}
</pre>
</div>
</div>
</div>
);
};
return (
<Drawer
title={
<Space>
<HistoryOutlined />
<span>Version History</span>
</Space>
}
open={open}
onClose={onClose}
width={selectedSha ? 800 : 420}
destroyOnClose
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin size="large" />
</div>
) : !documentPath ? (
<Empty description="No document selected" />
) : commits.length === 0 ? (
<Empty
image={<FileTextOutlined style={{ fontSize: 48, color: themeToken.colorTextDisabled }} />}
description="No version history available"
>
<Text type="secondary">
History is recorded when files are saved with Gitea integration enabled.
</Text>
</Empty>
) : (
<Space direction="vertical" style={{ width: '100%' }} size="small">
<div>
<Text type="secondary">File: </Text>
<Text strong>{fileName}</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{commits.length} revision{commits.length !== 1 ? 's' : ''}
</Text>
<Divider style={{ margin: '8px 0' }} />
<Timeline
items={commits.map((commit) => ({
color: selectedSha === commit.sha ? themeToken.colorPrimary : themeToken.colorTextSecondary,
children: (
<div
key={commit.sha}
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: themeToken.borderRadius,
background:
selectedSha === commit.sha
? themeToken.colorPrimaryBg
: undefined,
transition: 'background 0.2s',
}}
onClick={() => handleSelectCommit(commit.sha)}
>
<div style={{ marginBottom: 2 }}>
<Text strong style={{ fontSize: 13 }}>
{commit.commit.message}
</Text>
</div>
<Space size={8}>
<Text type="secondary" style={{ fontSize: 11 }}>
<UserOutlined style={{ marginRight: 4 }} />
{commit.commit.author.name}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(commit.commit.author.date).fromNow()}
</Text>
<Tag style={{ fontSize: 10 }}>{commit.sha.substring(0, 7)}</Tag>
</Space>
{selectedSha !== commit.sha && (
<div style={{ marginTop: 4 }}>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={(e) => {
e.stopPropagation();
handleSelectCommit(commit.sha);
}}
>
View
</Button>
</div>
)}
</div>
),
}))}
/>
{renderDiffView()}
</Space>
)}
</Drawer>
);
}

View File

@ -0,0 +1,390 @@
import { useState, useEffect, useCallback } from 'react';
import {
Drawer,
Form,
Select,
Switch,
Button,
Input,
InputNumber,
Table,
Tag,
Space,
Typography,
Divider,
Tooltip,
message,
Alert,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ShareAltOutlined,
CopyOutlined,
StopOutlined,
LinkOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
dayjs.extend(relativeTime);
const { Text, Paragraph } = Typography;
interface DocSharePanelProps {
open: boolean;
onClose: () => void;
documentPath: string | null;
}
interface ShareLink {
id: string;
documentPath: string;
shareToken: string;
status: 'ACTIVE' | 'REVOKED' | 'EXPIRED';
canEdit: boolean;
expiresAt: string | null;
maxUses: number | null;
useCount: number;
guestName: string | null;
createdBy: { id: string; name: string | null; email: string };
createdAt: string;
updatedAt: string;
}
const EXPIRY_OPTIONS = [
{ value: 1, label: '1 hour' },
{ value: 24, label: '24 hours' },
{ value: 168, label: '7 days' },
{ value: 720, label: '30 days' },
{ value: 0, label: 'No expiry' },
];
function statusTag(status: string) {
switch (status) {
case 'ACTIVE':
return (
<Tag icon={<CheckCircleOutlined />} color="success">
Active
</Tag>
);
case 'REVOKED':
return (
<Tag icon={<CloseCircleOutlined />} color="error">
Revoked
</Tag>
);
case 'EXPIRED':
return (
<Tag icon={<ClockCircleOutlined />} color="default">
Expired
</Tag>
);
default:
return <Tag>{status}</Tag>;
}
}
export function DocSharePanel({ open, onClose, documentPath }: DocSharePanelProps) {
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [links, setLinks] = useState<ShareLink[]>([]);
const [generatedUrl, setGeneratedUrl] = useState<string | null>(null);
// Form state
const [canEdit, setCanEdit] = useState(true);
const [expiryHours, setExpiryHours] = useState<number>(168); // 7 days default
const [maxUses, setMaxUses] = useState<number | null>(null);
const [guestName, setGuestName] = useState('');
const fetchLinks = useCallback(async () => {
if (!documentPath) return;
setLoading(true);
try {
const { data } = await api.get<{ links: ShareLink[] }>('/docs-access/share/links', {
params: { path: documentPath },
});
setLinks(data.links);
} catch {
message.error('Failed to load share links');
} finally {
setLoading(false);
}
}, [documentPath]);
useEffect(() => {
if (open && documentPath) {
fetchLinks();
setGeneratedUrl(null);
}
}, [open, documentPath, fetchLinks]);
const handleCreate = async () => {
if (!documentPath) return;
setCreating(true);
try {
const payload: Record<string, unknown> = {
documentPath,
canEdit,
};
if (expiryHours > 0) {
payload.expiresInHours = expiryHours;
}
if (maxUses && maxUses > 0) {
payload.maxUses = maxUses;
}
if (guestName.trim()) {
payload.guestName = guestName.trim();
}
const { data } = await api.post<{ id: string; shareToken: string; documentPath: string }>(
'/docs-access/share/create',
payload,
);
const url = `${window.location.origin}/docs/share/${data.shareToken}`;
setGeneratedUrl(url);
message.success('Share link created');
fetchLinks();
} catch {
message.error('Failed to create share link');
} finally {
setCreating(false);
}
};
const handleCopyUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
message.success('Link copied to clipboard');
} catch {
message.error('Failed to copy link');
}
};
const handleRevoke = async (id: string) => {
try {
await api.patch(`/docs-access/share/${id}/revoke`);
message.success('Share link revoked');
fetchLinks();
} catch {
message.error('Failed to revoke share link');
}
};
const columns: ColumnsType<ShareLink> = [
{
title: 'Token',
dataIndex: 'shareToken',
key: 'token',
width: 100,
render: (token: string) => (
<Tooltip title={token}>
<Text code copyable={{ text: `${window.location.origin}/docs/share/${token}` }}>
{token.substring(0, 8)}...
</Text>
</Tooltip>
),
},
{
title: 'Guest',
dataIndex: 'guestName',
key: 'guest',
width: 100,
render: (name: string | null) => name || <Text type="secondary">--</Text>,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'created',
width: 110,
render: (date: string) => (
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
<span>{dayjs(date).fromNow()}</span>
</Tooltip>
),
},
{
title: 'Expiry',
dataIndex: 'expiresAt',
key: 'expiry',
width: 110,
render: (date: string | null) =>
date ? (
<Tooltip title={dayjs(date).format('YYYY-MM-DD HH:mm')}>
<span>{dayjs(date).fromNow()}</span>
</Tooltip>
) : (
<Text type="secondary">Never</Text>
),
},
{
title: 'Uses',
key: 'uses',
width: 70,
render: (_: unknown, record: ShareLink) => (
<span>
{record.useCount}
{record.maxUses ? ` / ${record.maxUses}` : ''}
</span>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 90,
render: (status: string) => statusTag(status),
},
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, record: ShareLink) =>
record.status === 'ACTIVE' ? (
<Tooltip title="Revoke">
<Button
type="text"
danger
size="small"
icon={<StopOutlined />}
onClick={() => handleRevoke(record.id)}
/>
</Tooltip>
) : null,
},
];
const fileName = documentPath?.split('/').pop() || 'Document';
return (
<Drawer
title={
<Space>
<ShareAltOutlined />
<span>Share Document</span>
</Space>
}
open={open}
onClose={onClose}
width={640}
destroyOnClose
>
{!documentPath ? (
<Alert message="No document selected" type="info" />
) : (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Text type="secondary">Document</Text>
<br />
<Text strong>{fileName}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{documentPath}
</Text>
</div>
<Divider style={{ margin: '8px 0' }}>Create Share Link</Divider>
<Form layout="vertical" style={{ width: '100%' }}>
<Form.Item label="Can edit">
<Switch
checked={canEdit}
onChange={setCanEdit}
checkedChildren="Edit"
unCheckedChildren="View only"
/>
</Form.Item>
<Form.Item label="Expiry">
<Select
value={expiryHours}
onChange={setExpiryHours}
options={EXPIRY_OPTIONS}
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item label="Max uses (optional)">
<InputNumber
min={1}
max={1000}
value={maxUses}
onChange={(v) => setMaxUses(v)}
placeholder="Unlimited"
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item label="Guest name (optional)">
<Input
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
placeholder="Name shown to collaborators"
maxLength={200}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
icon={<LinkOutlined />}
onClick={handleCreate}
loading={creating}
>
Generate Link
</Button>
</Form.Item>
</Form>
{generatedUrl && (
<Alert
message="Share link created"
description={
<Space direction="vertical" style={{ width: '100%' }}>
<Paragraph
copyable={{
text: generatedUrl,
onCopy: () => message.success('Copied'),
}}
style={{ margin: 0, wordBreak: 'break-all' }}
>
{generatedUrl}
</Paragraph>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopyUrl(generatedUrl)}
>
Copy Link
</Button>
</Space>
}
type="success"
showIcon
closable
onClose={() => setGeneratedUrl(null)}
/>
)}
<Divider style={{ margin: '8px 0' }}>Active Links</Divider>
<Table<ShareLink>
columns={columns}
dataSource={links}
rowKey="id"
loading={loading}
size="small"
pagination={false}
scroll={{ x: 600 }}
locale={{ emptyText: 'No share links for this document' }}
/>
</Space>
)}
</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

@ -0,0 +1,170 @@
import { useState } from 'react';
import { Drawer, Form, Input, DatePicker, Select, Switch, Button, message, theme } from 'antd';
import { FileMarkdownOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { AuthorEntry } from '@/hooks/useBlogAuthors';
interface NewBlogPostModalProps {
open: boolean;
onClose: () => void;
onCreated: (path: string) => void;
authors: Record<string, AuthorEntry>;
categories: string[];
}
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function NewBlogPostModal({
open,
onClose,
onCreated,
authors,
categories,
}: NewBlogPostModalProps) {
const { token } = theme.useToken();
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const titleValue = Form.useWatch('title', form) as string | undefined;
const dateValue = Form.useWatch('date', form) as dayjs.Dayjs | undefined;
const slug = titleValue ? slugify(titleValue) : '';
const dateStr = dateValue ? dateValue.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
const previewFilename = slug ? `blog/posts/${dateStr}-${slug}.md` : '';
const authorOptions = Object.entries(authors || {}).map(([key, entry]) => ({
label: entry.name,
value: key,
}));
const categoryOptions = (categories || []).map((cat) => ({
label: cat,
value: cat,
}));
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const res = await api.post<{ path: string }>('/docs/blog/posts', {
title: values.title,
date: values.date ? values.date.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
authors: values.authors ?? [],
categories: values.categories ?? [],
draft: values.draft ?? true,
});
messageApi.success('Blog post created');
form.resetFields();
onCreated(res.data.path);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data
?.error?.message || 'Failed to create blog post';
messageApi.error(msg);
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
form.resetFields();
onClose();
};
return (
<Drawer
title={
<span>
<FileMarkdownOutlined style={{ marginRight: 8 }} />
New Blog Post
</span>
}
open={open}
onClose={handleClose}
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}
<Form
form={form}
layout="vertical"
initialValues={{
date: dayjs(),
draft: true,
}}
style={{ marginTop: 16 }}
>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input placeholder="My New Blog Post" autoFocus />
</Form.Item>
{previewFilename && (
<div
style={{
marginTop: -12,
marginBottom: 16,
padding: '6px 10px',
borderRadius: token.borderRadius,
background: token.colorFillQuaternary,
fontSize: 12,
color: token.colorTextSecondary,
fontFamily: 'monospace',
}}
>
{previewFilename}
</div>
)}
<Form.Item name="date" label="Date">
<DatePicker format="YYYY-MM-DD" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="authors" label="Author(s)">
<Select
mode="multiple"
placeholder="Select authors"
options={authorOptions}
allowClear
/>
</Form.Item>
<Form.Item name="categories" label="Categories">
<Select
mode="tags"
placeholder="Add categories"
options={categoryOptions}
allowClear
/>
</Form.Item>
<Form.Item name="draft" label="Draft" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</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

@ -39,6 +39,7 @@ import type {
AreaImportProgress, AreaImportProgress,
AreaImportSourceStatus, AreaImportSourceStatus,
} from '@/types/api'; } from '@/types/api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@ -232,8 +233,8 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
try { try {
const { data } = await api.post('/map/area-import/preview', buildRequestBody()); const { data } = await api.post('/map/area-import/preview', buildRequestBody());
setPreview(data); setPreview(data);
} catch (err: any) { } catch (err: unknown) {
setPreviewError(err?.response?.data?.error?.message || err.message || 'Preview failed'); setPreviewError(getErrorMessage(err, 'Preview failed'));
} finally { } finally {
setPreviewLoading(false); setPreviewLoading(false);
} }
@ -259,8 +260,8 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP
// Ignore polling errors // Ignore polling errors
} }
}, 2000); }, 2000);
} catch (err: any) { } catch (err: unknown) {
setPreviewError(err?.response?.data?.error?.message || 'Failed to start import'); setPreviewError(getErrorMessage(err, 'Failed to start import'));
setImporting(false); setImporting(false);
} }
}; };

View File

@ -1,9 +1,10 @@
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';
import type { PlaylistSummary } from '@/types/media'; import type { PlaylistSummary } from '@/types/media';
import axios from 'axios';
const { Text } = Typography; const { Text } = Typography;
@ -139,8 +140,8 @@ export default function AddToPlaylistModal({
{ ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null }, { ...data, hasVideo: true, isOwner: true, creator: { id: '', name: '', email: '' }, videoCount: 1, totalDurationSeconds: 0, viewCount: 0, thumbnailUrl: null, isFeatured: false, featuredPosition: null },
]); ]);
setSelections((prev) => ({ ...prev, [data.id]: true })); setSelections((prev) => ({ ...prev, [data.id]: true }));
} catch (error: any) { } catch (error: unknown) {
if (error.response?.status === 409) { if (axios.isAxiosError(error) && error.response?.status === 409) {
message.error('You already have a playlist with this name'); message.error('You already have a playlist with this name');
} else { } else {
message.error('Failed to create playlist'); message.error('Failed to create playlist');
@ -151,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 }}>
@ -237,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 { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
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 { getTokens } = getAuthCallbacks(); if (!signed) {
const { accessToken } = getTokens(); 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,6 +1,7 @@
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';
interface BulkAccessLevelModalProps { interface BulkAccessLevelModalProps {
open: boolean; open: boolean;
@ -28,21 +29,27 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
}); });
message.success(`Updated access level to "${accessLevel}" for ${data.updatedCount} video(s)`); message.success(`Updated access level to "${accessLevel}" for ${data.updatedCount} video(s)`);
onSuccess(); onSuccess();
} catch (error: any) { } catch (error: unknown) {
message.error(error.response?.data?.message || 'Failed to update access levels'); message.error(getErrorMessage(error, 'Failed to update access levels'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
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
@ -53,6 +60,6 @@ export default function BulkAccessLevelModal({ open, videoIds, onClose, onSucces
size="large" size="large"
/> />
</div> </div>
</Modal> </Drawer>
); );
} }

View File

@ -1,8 +1,9 @@
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';
import axios from 'axios';
const { Text } = Typography; const { Text } = Typography;
@ -62,8 +63,8 @@ export default function BulkAddToPlaylistModal({
try { try {
await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId }); await mediaApi.post(`/playlists/${selectedPlaylistId}/videos`, { mediaId });
added++; added++;
} catch (error: any) { } catch (error: unknown) {
if (error.response?.status === 409) { if (axios.isAxiosError(error) && error.response?.status === 409) {
skipped++; skipped++;
} else { } else {
throw error; throw error;
@ -98,8 +99,8 @@ export default function BulkAddToPlaylistModal({
setNewName(''); setNewName('');
setShowCreate(false); setShowCreate(false);
message.success(`Created "${data.name}"`); message.success(`Created "${data.name}"`);
} catch (error: any) { } catch (error: unknown) {
if (error.response?.status === 409) { if (axios.isAxiosError(error) && error.response?.status === 409) {
message.error('You already have a playlist with this name'); message.error('You already have a playlist with this name');
} else { } else {
message.error('Failed to create playlist'); message.error('Failed to create playlist');
@ -112,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 }}>
@ -183,6 +189,6 @@ export default function BulkAddToPlaylistModal({
)} )}
</> </>
)} )}
</Modal> </Drawer>
); );
} }

View File

@ -16,6 +16,7 @@ import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import axios from 'axios';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@ -105,8 +106,8 @@ export default function CommentSection({ videoId }: CommentSectionProps) {
setComments((prev) => [response.data.comment, ...prev]); setComments((prev) => [response.data.comment, ...prev]);
setCommentText(''); setCommentText('');
message.success('Comment posted!'); message.success('Comment posted!');
} catch (error: any) { } catch (error: unknown) {
if (error.response?.status === 401) { if (axios.isAxiosError(error) && error.response?.status === 401) {
message.error('Please log in to comment'); message.error('Please log in to comment');
} else { } else {
message.error('Failed to post comment'); message.error('Failed to post comment');

View File

@ -1,6 +1,7 @@
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';
interface CreateAlbumModalProps { interface CreateAlbumModalProps {
open: boolean; open: boolean;
@ -30,8 +31,8 @@ export default function CreateAlbumModal({
message.success('Album created'); message.success('Album created');
form.resetFields(); form.resetFields();
onSuccess(); onSuccess();
} catch (error: any) { } catch (error: unknown) {
if (error.response?.data?.message) { if (axios.isAxiosError(error) && error.response?.data?.message) {
message.error(error.response.data.message); message.error(error.response.data.message);
} }
} finally { } finally {
@ -40,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' }]}>
@ -61,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,6 +1,7 @@
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';
interface CreatePlaylistModalProps { interface CreatePlaylistModalProps {
open: boolean; open: boolean;
@ -31,10 +32,10 @@ export default function CreatePlaylistModal({
form.resetFields(); form.resetFields();
onCreated?.(data); onCreated?.(data);
onClose(); onClose();
} catch (error: any) { } catch (error: unknown) {
if (error.response?.status === 409) { if (axios.isAxiosError(error) && error.response?.status === 409) {
message.error('You already have a playlist with this name'); message.error('You already have a playlist with this name');
} else if (!error.errorFields) { } else if (!(error instanceof Object && 'errorFields' in error)) {
message.error('Failed to create playlist'); message.error('Failed to create playlist');
} }
} finally { } finally {
@ -52,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

@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { Drawer, Form, Input, Select, Descriptions, message, Button } from 'antd'; import { Drawer, Form, Input, Select, Descriptions, message, Button } from 'antd';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import type { Photo } from '@/types/media'; import type { Photo } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface EditPhotoModalProps { interface EditPhotoModalProps {
photo: Photo | null; photo: Photo | null;
@ -34,8 +35,8 @@ export default function EditPhotoModal({ photo, open, onClose, onSuccess }: Edit
await mediaApi.patch(`/photos/${photo.id}`, values); await mediaApi.patch(`/photos/${photo.id}`, values);
message.success('Photo updated'); message.success('Photo updated');
onSuccess(); onSuccess();
} catch (error: any) { } catch (error: unknown) {
message.error(error.response?.data?.message || 'Failed to update photo'); message.error(getErrorMessage(error, 'Failed to update photo'));
} }
}; };

View File

@ -4,6 +4,7 @@ import { DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/
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';
import type { PlaylistVideoItem } from '@/types/media'; import type { PlaylistVideoItem } from '@/types/media';
import axios from 'axios';
const { Text } = Typography; const { Text } = Typography;
@ -71,10 +72,10 @@ export default function EditPlaylistModal({
message.success('Playlist updated'); message.success('Playlist updated');
onUpdated?.(); onUpdated?.();
} catch (error: any) { } catch (error: unknown) {
if (error.response?.status === 409) { if (axios.isAxiosError(error) && error.response?.status === 409) {
message.error('You already have a playlist with this name'); message.error('You already have a playlist with this name');
} else if (!error.errorFields) { } else if (!(error instanceof Object && 'errorFields' in error)) {
message.error('Failed to update playlist'); message.error('Failed to update playlist');
} }
} finally { } finally {
@ -129,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

@ -3,6 +3,7 @@ import { EditOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import type { Video } from '@/types/media'; import type { Video } from '@/types/media';
import axios from 'axios';
interface EditVideoDrawerProps { interface EditVideoDrawerProps {
video: Video | null; video: Video | null;
@ -87,8 +88,8 @@ export default function EditVideoModal({ video, open, onClose, onSuccess }: Edit
message.success('Video updated successfully'); message.success('Video updated successfully');
onSuccess?.(); onSuccess?.();
onClose(); onClose();
} catch (error: any) { } catch (error: unknown) {
if (error.response?.data?.message) { if (axios.isAxiosError(error) && error.response?.data?.message) {
message.error(error.response.data.message); message.error(error.response.data.message);
} }
// form validation errors are shown inline // form validation errors are shown inline

View File

@ -13,6 +13,7 @@ import {
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext'; import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mediaPublicApi } from '@/lib/media-public-api'; import { mediaPublicApi } from '@/lib/media-public-api';
import type { PublicAlbum } from '@/types/media'; import type { PublicAlbum } from '@/types/media';
import axios from 'axios';
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@ -103,8 +104,8 @@ export default function ExpandedAlbumCard({ album }: ExpandedAlbumCardProps) {
} }
setHasUpvoted(true); setHasUpvoted(true);
setUpvoteCount(prev => prev + 1); setUpvoteCount(prev => prev + 1);
} catch (error: any) { } catch (error: unknown) {
if (error.response?.status === 401) { if (axios.isAxiosError(error) && error.response?.status === 401) {
message.info('Please log in to upvote'); message.info('Please log in to upvote');
} }
} finally { } finally {

View File

@ -13,6 +13,7 @@ import {
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext'; import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mediaPublicApi } from '@/lib/media-public-api'; import { mediaPublicApi } from '@/lib/media-public-api';
import type { PublicPhoto } from '@/types/media'; import type { PublicPhoto } from '@/types/media';
import axios from 'axios';
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@ -80,8 +81,8 @@ export default function ExpandedPhotoCard({ photo }: ExpandedPhotoCardProps) {
await mediaPublicApi.post(`/photos/${photo.id}/upvote`); await mediaPublicApi.post(`/photos/${photo.id}/upvote`);
setHasUpvoted(true); setHasUpvoted(true);
setUpvoteCount(prev => prev + 1); setUpvoteCount(prev => prev + 1);
} catch (error: any) { } catch (error: unknown) {
if (error.response?.status === 401) { if (axios.isAxiosError(error) && error.response?.status === 401) {
message.info('Please log in to upvote'); message.info('Please log in to upvote');
} }
} finally { } finally {

View File

@ -18,6 +18,7 @@ import ReactionButtons from './ReactionButtons';
import AddToPlaylistModal from './AddToPlaylistModal'; import AddToPlaylistModal from './AddToPlaylistModal';
import { mediaPublicApi } from '@/lib/media-public-api'; import { mediaPublicApi } from '@/lib/media-public-api';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import axios from 'axios';
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@ -117,9 +118,9 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
await mediaPublicApi.post(`/public/${video.id}/upvote`); await mediaPublicApi.post(`/public/${video.id}/upvote`);
setHasUpvoted(true); setHasUpvoted(true);
setUpvoteCount(prev => prev + 1); setUpvoteCount(prev => prev + 1);
} catch (error: any) { } catch (error: unknown) {
console.error('Upvote failed:', error); console.error('Upvote failed:', error);
if (error.response?.status === 401) { if (axios.isAxiosError(error) && error.response?.status === 401) {
alert('Please log in to upvote videos'); alert('Please log in to upvote videos');
} }
} finally { } finally {

View File

@ -26,6 +26,7 @@ import {
ExpandOutlined, ExpandOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { TextArea } = Input; const { TextArea } = Input;
const { Text } = Typography; const { Text } = Typography;
@ -147,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: {
@ -260,8 +255,8 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
// Immediately expand the new job // Immediately expand the new job
setExpandedJobId(data.jobId); setExpandedJobId(data.jobId);
fetchJobs(); fetchJobs();
} catch (err: any) { } catch (err: unknown) {
message.error(err.response?.data?.message || 'Failed to submit fetch job'); message.error(getErrorMessage(err, 'Failed to submit fetch job'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -272,8 +267,8 @@ export default function FetchVideosDrawer({ open, onClose, onSuccess }: FetchVid
await mediaApi.delete(`/videos/fetch/jobs/${jobId}`); await mediaApi.delete(`/videos/fetch/jobs/${jobId}`);
message.success('Job cancelled'); message.success('Job cancelled');
fetchJobs(); fetchJobs();
} catch (err: any) { } catch (err: unknown) {
message.error(err.response?.data?.message || 'Failed to cancel job'); message.error(getErrorMessage(err, 'Failed to cancel job'));
} }
}; };

View File

@ -20,6 +20,7 @@ import {
import { useMediaAuth } from '@/contexts/MediaAuthContext'; import { useMediaAuth } from '@/contexts/MediaAuthContext';
import { mediaPublicApi } from '@/lib/media-public-api'; import { mediaPublicApi } from '@/lib/media-public-api';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import axios from 'axios';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@ -302,12 +303,12 @@ export default function LiveChat({
setCommentInput(''); setCommentInput('');
// Note: New comment will appear via SSE broadcast // Note: New comment will appear via SSE broadcast
} catch (err: any) { } catch (err: unknown) {
console.error('Failed to submit comment:', err); console.error('Failed to submit comment:', err);
if (err.response?.status === 429) { if (axios.isAxiosError(err) && err.response?.status === 429) {
alert('Rate limit exceeded. Please wait a minute before commenting again.'); alert('Rate limit exceeded. Please wait a minute before commenting again.');
} else if (err.response?.status === 401) { } else if (axios.isAxiosError(err) && err.response?.status === 401) {
alert('Please log in to comment.'); alert('Please log in to comment.');
if (onRequestLogin) { if (onRequestLogin) {
onRequestLogin(); onRequestLogin();

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 { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
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 { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
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,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Modal, Select, message } from 'antd'; import { Modal, Select, message } from 'antd';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface PublishModalProps { interface PublishModalProps {
open: boolean; open: boolean;
@ -28,8 +29,8 @@ export default function PublishModal({ open, videoIds, onSuccess, onCancel }: Pu
message.success(`Successfully published ${videoIds.length} video(s) to ${category}`); message.success(`Successfully published ${videoIds.length} video(s) to ${category}`);
onSuccess(); onSuccess();
setCategory('videos'); setCategory('videos');
} catch (error: any) { } catch (error: unknown) {
message.error(error.response?.data?.message || 'Failed to publish videos'); message.error(getErrorMessage(error, 'Failed to publish videos'));
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -9,6 +9,7 @@ import {
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import type { VideoAnalytics } from '@/types/media'; import type { VideoAnalytics } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface QuickAnalyticsModalProps { interface QuickAnalyticsModalProps {
videoId: number; videoId: number;
@ -41,9 +42,9 @@ export default function QuickAnalyticsModal({
setError(null); setError(null);
const response = await mediaApi.get(`/videos/${videoId}/analytics`); const response = await mediaApi.get(`/videos/${videoId}/analytics`);
setAnalytics(response.data); setAnalytics(response.data);
} catch (error: any) { } catch (error: unknown) {
console.error('Failed to fetch analytics:', error); console.error('Failed to fetch analytics:', error);
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.'); setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -3,6 +3,7 @@ import { Space, Button, message, theme } from 'antd';
import { mediaPublicApi } from '@/lib/media-public-api'; import { mediaPublicApi } from '@/lib/media-public-api';
import { useAuthStore } from '@/stores/auth.store'; import { useAuthStore } from '@/stores/auth.store';
import { hexToRgba } from '@/utils/color'; import { hexToRgba } from '@/utils/color';
import axios from 'axios';
interface ReactionButtonsProps { interface ReactionButtonsProps {
videoId: number; videoId: number;
@ -63,8 +64,8 @@ export default function ReactionButtons({ videoId, currentTime }: ReactionButton
}, 2000); }, 2000);
message.success(`${emoji} reaction added!`); message.success(`${emoji} reaction added!`);
} catch (error: any) { } catch (error: unknown) {
if (error.response?.status === 401) { if (axios.isAxiosError(error) && error.response?.status === 401) {
message.error('Please log in to add reactions'); message.error('Please log in to add reactions');
} else { } else {
message.error('Failed to add reaction'); message.error('Failed to add reaction');

View File

@ -4,6 +4,7 @@ import { CalendarOutlined, ClockCircleOutlined, DeleteOutlined, ReloadOutlined }
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface ScheduleEvent { interface ScheduleEvent {
jobId: string; jobId: string;
@ -55,9 +56,9 @@ export default function ScheduleCalendarDrawer({
params: { limit: 100 }, params: { limit: 100 },
}); });
setSchedules(response.data.schedules || []); setSchedules(response.data.schedules || []);
} catch (error: any) { } catch (error: unknown) {
console.error('Failed to fetch schedules:', error); console.error('Failed to fetch schedules:', error);
setError(error.response?.data?.message || 'Failed to load schedules. Please try again.'); setError(getErrorMessage(error, 'Failed to load schedules. Please try again.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -69,8 +70,8 @@ export default function ScheduleCalendarDrawer({
message.success(`${action} schedule cancelled`); message.success(`${action} schedule cancelled`);
fetchSchedules(); fetchSchedules();
onRefresh?.(); onRefresh?.();
} catch (error: any) { } catch (error: unknown) {
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`); message.error(getErrorMessage(error, `Failed to cancel ${action} schedule`));
} }
}; };

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';
@ -6,6 +6,7 @@ import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import type { Video } from '@/types/media'; import type { Video } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@ -100,8 +101,8 @@ export default function SchedulePublishModal({
onSuccess?.(); onSuccess?.();
onClose(); onClose();
} catch (error: any) { } catch (error: unknown) {
message.error(error.response?.data?.message || 'Failed to schedule video'); message.error(getErrorMessage(error, 'Failed to schedule video'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -116,8 +117,8 @@ export default function SchedulePublishModal({
message.success(`${action} schedule cancelled`); message.success(`${action} schedule cancelled`);
onSuccess?.(); onSuccess?.();
onClose(); onClose();
} catch (error: any) { } catch (error: unknown) {
message.error(error.response?.data?.message || `Failed to cancel ${action} schedule`); message.error(getErrorMessage(error, `Failed to cancel ${action} schedule`));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -151,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 />
@ -159,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 && (
@ -301,6 +303,6 @@ export default function SchedulePublishModal({
)} )}
</div> </div>
)} )}
</Modal> </Drawer>
); );
} }

View File

@ -3,6 +3,7 @@ import { Drawer, Upload, Form, Input, Select, Button, message, Progress, List, T
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import type { PhotoAlbum } from '@/types/media'; import type { PhotoAlbum } from '@/types/media';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Dragger } = Upload; const { Dragger } = Upload;
@ -75,11 +76,11 @@ export default function UploadPhotoDrawer({ open, onClose, onSuccess, albumId }:
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
uploadResults.push({ filename: file.name, success: true }); uploadResults.push({ filename: file.name, success: true });
} catch (error: any) { } catch (error: unknown) {
uploadResults.push({ uploadResults.push({
filename: file.name, filename: file.name,
success: false, success: false,
error: error.response?.data?.message || 'Upload failed', error: getErrorMessage(error, 'Upload failed'),
}); });
} }

View File

@ -16,6 +16,7 @@ import {
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface'; import type { UploadFile } from 'antd/es/upload/interface';
import { mediaApi } from '@/lib/media-api'; import { mediaApi } from '@/lib/media-api';
import { getErrorMessage } from '@/utils/getErrorMessage';
const { Dragger } = Upload; const { Dragger } = Upload;
const { Text } = Typography; const { Text } = Typography;
@ -117,11 +118,11 @@ export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVi
filename: file.name, filename: file.name,
success: true, success: true,
}); });
} catch (error: any) { } catch (error: unknown) {
uploadResults.push({ uploadResults.push({
filename: file.name, filename: file.name,
success: false, success: false,
error: error.response?.data?.message || 'Upload failed', error: getErrorMessage(error, 'Upload failed'),
}); });
} }

View File

@ -12,6 +12,7 @@ import { mediaApi } from '@/lib/media-api';
import type { VideoAnalytics } from '@/types/media'; import type { VideoAnalytics } from '@/types/media';
import AnalyticsChart from './AnalyticsChart'; import AnalyticsChart from './AnalyticsChart';
import ViewersTable from './ViewersTable'; import ViewersTable from './ViewersTable';
import { getErrorMessage } from '@/utils/getErrorMessage';
interface VideoAnalyticsModalProps { interface VideoAnalyticsModalProps {
videoId: number | null; videoId: number | null;
@ -46,9 +47,9 @@ export default function VideoAnalyticsModal({
setError(null); setError(null);
const response = await mediaApi.get(`/videos/${videoId}/analytics`); const response = await mediaApi.get(`/videos/${videoId}/analytics`);
setAnalytics(response.data); setAnalytics(response.data);
} catch (error: any) { } catch (error: unknown) {
console.error('Failed to fetch analytics:', error); console.error('Failed to fetch analytics:', error);
setError(error.response?.data?.message || 'Failed to load analytics. Please try again.'); setError(getErrorMessage(error, 'Failed to load analytics. Please try again.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }

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 { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
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 { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
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);
@ -139,8 +141,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
// Use relative URL to go through nginx proxy // Use relative URL to go through nginx proxy
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (isAdmin) { if (isAdmin) {
const { getTokens } = getAuthCallbacks(); const { getAccessToken } = getAuthCallbacks();
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (accessToken) { if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`; headers['Authorization'] = `Bearer ${accessToken}`;
} }
@ -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 { getTokens } = getAuthCallbacks();
const { accessToken } = getTokens();
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>
); );
} }

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