v0.12.0 codes for FCM and rebranded jama to RosterChirp
This commit is contained in:
4
.env
4
.env
@@ -27,4 +27,6 @@ HOST_ADMIN_KEY=VBGFHETSTTGRDDWAASJKH
|
|||||||
|
|
||||||
#** Optional
|
#** Optional
|
||||||
PORT=3144
|
PORT=3144
|
||||||
TZ=America/Toronto
|
TZ=America/Toronto
|
||||||
|
|
||||||
|
{"type": "service_account","project_id": "rosterchirp-push", "private_key_id": "577d2e29044634a9a7efba8cc3c5b67cd98f1f61", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCma+cTelvpLARK\n0B+Ok4901OJU3TEpnJHMc7N5mEg231Nn4XnXSuM4QPOSSJLGRTSHJrZ17kAJkShf\nP2V8mVWujtwhK/a2W1Jl43VkgqNnAunsqG0T12hCpPuRYkVZoDtPmJ4pYzD8TkV9\n1xmrHGP4JovSld5uDFWTQ91ZSsEqi1lS3A35Tqhl3jO81GPzRaR8CyY7/cyoplZ3\n49njHV9llh68pFeYYbfNDbZAli9tDDmP4rBQud8Q+Ix5syQ2zbmAErQFzSl8hiCO\n3FhsZYGBxj2cDXTJXfwqiLoVBL9WbTj5NMVnneDSZJ15ILjQA6zyqKvCsXYB4YrM\n3Vs1D3jnAgMBAAECggEAMh2JSwjMV8XNDxhogF94UlbvR14KuXywPTDUabgNexS6\ngaxZLBedoCmTD8iyBmn9vPtP8+iIuTjQvwoQzjpAnp3ftU+Pbm/Guu8JwXhDq7gp\naH55xoFWIMedCDVfK/PAGKKdclov/LK3Y4Ncc/ZLNoWpEoPWJS6qsHu90u9bhytN\n+TQ//K4ODvxzp8dwrVyEoalaTSubxctvyN43L2EkqJBWOfm5GOnfbfB6UENTVYii\nd98lsc/LFumepGyHWrXOGodjVqWqaW54po2KMGXUiYfdzg7vgcVpBIdG1gbKAmax\n1Ypst7l4VKosJM1cI5zykdRj8JuGlX4YM5MRLhGtLQKBgQDafa2tFU4OViWwMn4E\nowdpczZKjHCy/50NSS/UU2WyTCJo8cO6vjhkCGVINazAWABXIcLeuWHYOIhWyfRj\n9v7dShH2VA2mip7vFP1XEjiK98tFOV4FbS1Qg4m5zVvsECOSNZ21ozHEgZ9q0yEK\nDoXX3HOr3OV6az6GUhB45CzeWwKBgQDC/dtnSwJFpFyR1QAZX5lPeAYXlosO1jhO\nhCqPwKemOnH32j/UVGmE2t93ALo1sQD2YV2CsuHysQWEnH/mdC9iR9HF0g9nCrgc\nqbOD2MppnISYEp2DktcfyZMNujxMYYZ6e4rxXCrjkdk4z2xg5PsGYAJZQ+FA7XdN\nBViQmz1tZQKBgDLPsXkkEEADRsaAJ5BafZnHYmPZ30exbEuvroDZWDgrvoDbYKJo\nJGMXFL7DRMaCcKnSvyfewuNu2j4cv0oUIddCp4S6rWYCrM16+yOpqB6hW9NgcP4g\nEr67qGbeXDc81ZjmASRBrIw/fNxx9ygIkpXNvdTFDVT35dWE9jG3FrwrAoGADICO\nUr8idCinrsoDaZ0RjWDasyR54gemMJKU0AbAOQ5CRGv/77NB2LzX2x920P56W1G+\n1yR1DESBYBFQugv1Bc4pCw/+4NJ1H5FZ6zg5MjBQ6Bc5djgyBt27ygOI3jTalHvb\nWsJYFaNCVDwobMYBulTpkaOii7EuFwgit5Lci2kCgYEAimaTURYyX6MblMlLzcKd\nmtjvfldF8xFh1xKofbiQwfMOiKn0G2xuVWyIvv4U23ZV+Yzu18gr9lOT0JsAsn3G\nx91hUPyWPozoe/MHSecDGIMUUiHdPbIvOO4w3Mv/HCfKuIvruQaaMJ2gvj72zn6C\nCMJVQgxfBULCw4ByDd63pnw=\n-----END PRIVATE KEY-----\n", "client_email": "firebase-adminsdk-fbsvc@rosterchirp-push.iam.gserviceaccount.com", "client_id": "103819905443146316089", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40rosterchirp-push.iam.gserviceaccount.com", "universe_domain": "googleapis.com"}
|
||||||
|
|||||||
31
.env.example
31
.env.example
@@ -3,28 +3,41 @@ DB_PASSWORD=change_me_strong_password
|
|||||||
JWT_SECRET=change_me_super_secret_jwt_key
|
JWT_SECRET=change_me_super_secret_jwt_key
|
||||||
|
|
||||||
# ── App identity ──────────────────────────────────────────────────────────────
|
# ── App identity ──────────────────────────────────────────────────────────────
|
||||||
PROJECT_NAME=jama
|
PROJECT_NAME=rosterchirp
|
||||||
APP_NAME=jama
|
APP_NAME=rosterchirp
|
||||||
DEFCHAT_NAME=General Chat
|
DEFCHAT_NAME=General Chat
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
ADMIN_EMAIL=admin@jama.local
|
ADMIN_EMAIL=admin@rosterchirp.local
|
||||||
ADMIN_PASS=Admin@1234
|
ADMIN_PASS=Admin@1234
|
||||||
ADMPW_RESET=false
|
ADMPW_RESET=false
|
||||||
|
|
||||||
# ── Database ──────────────────────────────────────────────────────────────────
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
DB_NAME=jama
|
DB_NAME=rosterchirp
|
||||||
DB_USER=jama
|
DB_USER=rosterchirp
|
||||||
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
|
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
|
||||||
|
|
||||||
# ── Tenancy mode ──────────────────────────────────────────────────────────────
|
# ── Tenancy mode ──────────────────────────────────────────────────────────────
|
||||||
# selfhost = single tenant (JAMA-CHAT / JAMA-BRAND / JAMA-TEAM)
|
# selfhost = single tenant (RosterChirp-Chat / RosterChirp-Brand / RosterChirp-Team)
|
||||||
# host = multi-tenant (JAMA-HOST only)
|
# host = multi-tenant (RosterChirp-Host only)
|
||||||
APP_TYPE=selfhost
|
APP_TYPE=selfhost
|
||||||
|
|
||||||
# ── JAMA-HOST only (ignored in selfhost mode) ─────────────────────────────────
|
# ── RosterChirp-Host only (ignored in selfhost mode) ─────────────────────────────────
|
||||||
# HOST_DOMAIN=jamachat.com
|
# HOST_DOMAIN=rosterchirp.com
|
||||||
# HOST_ADMIN_KEY=change_me_host_admin_secret
|
# HOST_ADMIN_KEY=change_me_host_admin_secret
|
||||||
|
|
||||||
# ── Optional ──────────────────────────────────────────────────────────────────
|
# ── Optional ──────────────────────────────────────────────────────────────────
|
||||||
PORT=3000
|
PORT=3000
|
||||||
TZ=UTC
|
TZ=UTC
|
||||||
|
|
||||||
|
# ── Firebase Cloud Messaging (FCM) — Android background push ──────────────────
|
||||||
|
# Required for push notifications to work on Android when the app is backgrounded.
|
||||||
|
# Get these from: Firebase Console → Project Settings → General → Your web app
|
||||||
|
# FIREBASE_API_KEY=
|
||||||
|
# FIREBASE_PROJECT_ID=
|
||||||
|
# FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
# FIREBASE_APP_ID=
|
||||||
|
# Get VAPID key from: Firebase Console → Project Settings → Cloud Messaging → Web Push certificates
|
||||||
|
# FIREBASE_VAPID_KEY=
|
||||||
|
# Get service account JSON from: Firebase Console → Project Settings → Service accounts → Generate new private key
|
||||||
|
# Paste the entire JSON content as a single-line string:
|
||||||
|
# FIREBASE_SERVICE_ACCOUNT={"type":"service_account","project_id":"..."}
|
||||||
|
|||||||
60
CLAUDE.md
60
CLAUDE.md
@@ -1,10 +1,10 @@
|
|||||||
# JAMA — Claude Code Development Context
|
# RosterChirp — Claude Code Development Context
|
||||||
|
|
||||||
## What is JAMA?
|
## What is RosterChirp?
|
||||||
|
|
||||||
**jama** (just another messaging app) is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
|
**RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
|
||||||
|
|
||||||
**Current version:** 0.11.25
|
**Current version:** 0.11.26
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
jama/
|
rosterchirp/
|
||||||
├── CLAUDE.md ← this file
|
├── CLAUDE.md ← this file
|
||||||
├── KNOWN_LIMITATIONS.md
|
├── KNOWN_LIMITATIONS.md
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
@@ -51,7 +51,7 @@ jama/
|
|||||||
│ │ ├── users.js
|
│ │ ├── users.js
|
||||||
│ │ ├── settings.js
|
│ │ ├── settings.js
|
||||||
│ │ ├── push.js
|
│ │ ├── push.js
|
||||||
│ │ ├── host.js ← JAMA-HOST control plane only
|
│ │ ├── host.js ← RosterChirp-Host control plane only
|
||||||
│ │ ├── about.js
|
│ │ ├── about.js
|
||||||
│ │ └── help.js
|
│ │ └── help.js
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
@@ -106,7 +106,7 @@ jama/
|
|||||||
|
|
||||||
## Version Bump — Files to Update
|
## Version Bump — Files to Update
|
||||||
|
|
||||||
When bumping the version (e.g. 0.11.25 → 0.11.26), update **all three**:
|
When bumping the version (e.g. 0.11.26 → 0.11.27), update **all three**:
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/package.json "version": "X.Y.Z"
|
backend/package.json "version": "X.Y.Z"
|
||||||
@@ -116,7 +116,7 @@ build.sh VERSION="${1:-X.Y.Z}"
|
|||||||
|
|
||||||
One-liner:
|
One-liner:
|
||||||
```bash
|
```bash
|
||||||
OLD=0.11.25; NEW=0.11.26
|
OLD=0.11.26; NEW=0.11.27
|
||||||
sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json
|
sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json
|
||||||
sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh
|
sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh
|
||||||
```
|
```
|
||||||
@@ -127,14 +127,14 @@ The `.env.example` has no version field. There is no fourth location.
|
|||||||
|
|
||||||
## Output ZIP
|
## Output ZIP
|
||||||
|
|
||||||
When packaging for delivery: `jama.zip` at `/mnt/user-data/outputs/jama.zip`
|
When packaging for delivery: `rosterchirp.zip` at `/mnt/user-data/outputs/rosterchirp.zip`
|
||||||
|
|
||||||
Always exclude:
|
Always exclude:
|
||||||
```bash
|
```bash
|
||||||
zip -qr jama.zip jama \
|
zip -qr rosterchirp.zip rosterchirp \
|
||||||
--exclude "jama/README.md" \
|
--exclude "rosterchirp/README.md" \
|
||||||
--exclude "jama/data/help.md" \
|
--exclude "rosterchirp/data/help.md" \
|
||||||
--exclude "jama/backend/src/data/help.md"
|
--exclude "rosterchirp/backend/src/data/help.md"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -146,7 +146,7 @@ zip -qr jama.zip jama \
|
|||||||
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
|
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
|
||||||
| `host` | Multi-tenant — one schema per tenant. Requires `HOST_DOMAIN` and `HOST_ADMIN_KEY`. |
|
| `host` | Multi-tenant — one schema per tenant. Requires `HOST_DOMAIN` and `HOST_ADMIN_KEY`. |
|
||||||
|
|
||||||
JAMA-HOST tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control panel lives at `https://{HOST_DOMAIN}/host`.
|
RosterChirp-Host tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control panel lives at `https://{HOST_DOMAIN}/host`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ JAMA-HOST tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control pa
|
|||||||
- **Schema resolution:** `tenantMiddleware` sets `req.schema` from the HTTP `Host` header before any route runs. `assertSafeSchema()` validates all schema names against `[a-z_][a-z0-9_]*`.
|
- **Schema resolution:** `tenantMiddleware` sets `req.schema` from the HTTP `Host` header before any route runs. `assertSafeSchema()` validates all schema names against `[a-z_][a-z0-9_]*`.
|
||||||
- **Migrations:** Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
|
- **Migrations:** Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
|
||||||
- **Seeding:** `seedSettings → seedEventTypes → seedAdmin → seedUserGroups` on startup. All use `ON CONFLICT DO NOTHING`.
|
- **Seeding:** `seedSettings → seedEventTypes → seedAdmin → seedUserGroups` on startup. All use `ON CONFLICT DO NOTHING`.
|
||||||
- **Current migrations:** 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users)
|
- **Current migrations:** 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users) → 007 (FCM push) → 008 (rebrand)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -203,9 +203,9 @@ Stored in `settings` table per schema:
|
|||||||
| `feature_branding` | `'true'`/`'false'` | Brand+ |
|
| `feature_branding` | `'true'`/`'false'` | Brand+ |
|
||||||
| `feature_group_manager` | `'true'`/`'false'` | Team |
|
| `feature_group_manager` | `'true'`/`'false'` | Team |
|
||||||
| `feature_schedule_manager` | `'true'`/`'false'` | Team |
|
| `feature_schedule_manager` | `'true'`/`'false'` | Team |
|
||||||
| `app_type` | `'JAMA-Chat'`/`'JAMA-Brand'`/`'JAMA-Team'` | — |
|
| `app_type` | `'RosterChirp-Chat'`/`'RosterChirp-Brand'`/`'RosterChirp-Team'` | — |
|
||||||
|
|
||||||
JAMA-HOST always forces `JAMA-Team` on the public schema at startup.
|
RosterChirp-Host always forces `RosterChirp-Team` on the public schema at startup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -303,13 +303,7 @@ Single-user add/remove via `groups.js` (GroupInfoModal) always uses the named me
|
|||||||
## Outstanding / Deferred Work
|
## Outstanding / Deferred Work
|
||||||
|
|
||||||
### Android Background Push (KNOWN_LIMITATIONS.md)
|
### Android Background Push (KNOWN_LIMITATIONS.md)
|
||||||
**Status:** Deferred. Web Push with VAPID doesn't survive Android Doze mode.
|
**Status:** Implemented (v0.11.26+). Replaced web-push/VAPID with Firebase Cloud Messaging (FCM). Requires Firebase project setup — see .env.example for required env vars and sw.js for the SW config block.
|
||||||
**Fix plan:** Integrate Firebase Cloud Messaging (FCM).
|
|
||||||
1. Create Firebase project (free tier)
|
|
||||||
2. Add Firebase config to `.env` and `sw.js`
|
|
||||||
3. Replace `web-push` subscription flow with Firebase SDK
|
|
||||||
4. Switch backend dispatch from `web-push` to `firebase-admin`
|
|
||||||
5. WebSocket reconnect-on-focus (frontend only, no Firebase needed)
|
|
||||||
|
|
||||||
### WebSocket Reconnect on Focus
|
### WebSocket Reconnect on Focus
|
||||||
**Status:** Deferred. Socket drops when Android PWA is backgrounded.
|
**Status:** Deferred. Socket drops when Android PWA is backgrounded.
|
||||||
@@ -326,19 +320,25 @@ HOST_DOMAIN= # host mode only
|
|||||||
HOST_ADMIN_KEY= # host mode only
|
HOST_ADMIN_KEY= # host mode only
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_NAME=jama
|
DB_NAME=rosterchirp
|
||||||
DB_USER=jama
|
DB_USER=rosterchirp
|
||||||
DB_PASSWORD= # avoid ! (shell interpolation issue with docker-compose)
|
DB_PASSWORD= # avoid ! (shell interpolation issue with docker-compose)
|
||||||
ADMIN_EMAIL=
|
ADMIN_EMAIL=
|
||||||
ADMIN_NAME=
|
ADMIN_NAME=
|
||||||
ADMIN_PASS=
|
ADMIN_PASS=
|
||||||
ADMPW_RESET=true|false
|
ADMPW_RESET=true|false
|
||||||
APP_NAME=jama
|
APP_NAME=rosterchirp
|
||||||
USER_PASS= # default password for bulk-created users
|
USER_PASS= # default password for bulk-created users
|
||||||
DEFCHAT_NAME=General Chat
|
DEFCHAT_NAME=General Chat
|
||||||
JAMA_VERSION= # injected by build.sh into Docker image
|
ROSTERCHIRP_VERSION= # injected by build.sh into Docker image
|
||||||
VAPID_PUBLIC= # auto-generated on first start if not set
|
VAPID_PUBLIC= # auto-generated on first start if not set
|
||||||
VAPID_PRIVATE= # auto-generated on first start if not set
|
VAPID_PRIVATE= # auto-generated on first start if not set
|
||||||
|
FIREBASE_API_KEY= # FCM web app config
|
||||||
|
FIREBASE_PROJECT_ID= # FCM web app config
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID= # FCM web app config
|
||||||
|
FIREBASE_APP_ID= # FCM web app config
|
||||||
|
FIREBASE_VAPID_KEY= # FCM Web Push certificate public key
|
||||||
|
FIREBASE_SERVICE_ACCOUNT= # FCM service account JSON (stringified, backend only)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -347,7 +347,7 @@ VAPID_PRIVATE= # auto-generated on first start if not set
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Production: Ubuntu 22.04, Docker Compose v2
|
# Production: Ubuntu 22.04, Docker Compose v2
|
||||||
# Directory: /home/rick/jama/
|
# Directory: /home/rick/rosterchirp/
|
||||||
|
|
||||||
./build.sh # builds Docker image
|
./build.sh # builds Docker image
|
||||||
docker compose up -d # starts all services
|
docker compose up -d # starts all services
|
||||||
@@ -359,4 +359,4 @@ Build sequence: `build.sh` → Docker build → `npm run build` (Vite) → `dock
|
|||||||
|
|
||||||
## Session History
|
## Session History
|
||||||
|
|
||||||
Previous development was conducted via Claude.ai web interface (sessions summarised in this document). Development continues in Claude Code from v0.11.25.
|
Development continues in Claude Code from v0.11.26 (rebranded from jama to RosterChirp).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Caddyfile.example — JAMA-HOST reverse proxy
|
# Caddyfile.example — RosterChirp-Host reverse proxy
|
||||||
#
|
#
|
||||||
# Caddy handles SSL automatically via Let's Encrypt.
|
# Caddy handles SSL automatically via Let's Encrypt.
|
||||||
# Wildcard certs require a DNS challenge provider.
|
# Wildcard certs require a DNS challenge provider.
|
||||||
@@ -12,23 +12,23 @@
|
|||||||
# CF_API_TOKEN=your_cloudflare_token (or equivalent)
|
# CF_API_TOKEN=your_cloudflare_token (or equivalent)
|
||||||
#
|
#
|
||||||
# 3. Add a wildcard DNS record in your DNS provider:
|
# 3. Add a wildcard DNS record in your DNS provider:
|
||||||
# *.jamachat.com → your server IP
|
# *.rosterchirp.com → your server IP
|
||||||
# jamachat.com → your server IP
|
# rosterchirp.com → your server IP
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it)
|
# Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it)
|
||||||
# Reload: caddy reload
|
# Reload: caddy reload
|
||||||
|
|
||||||
# ── Wildcard subdomain ────────────────────────────────────────────────────────
|
# ── Wildcard subdomain ────────────────────────────────────────────────────────
|
||||||
# Handles team1.jamachat.com, teamB.jamachat.com, etc.
|
# Handles team1.rosterchirp.com, teamB.rosterchirp.com, etc.
|
||||||
# Replace jamachat.com with your actual HOST_DOMAIN.
|
# Replace rosterchirp.com with your actual HOST_DOMAIN.
|
||||||
|
|
||||||
*.jamachat.com {
|
*.rosterchirp.com {
|
||||||
tls {
|
tls {
|
||||||
dns cloudflare {env.CF_API_TOKEN}
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Forward all requests to the jama app container
|
# Forward all requests to the rosterchirp app container
|
||||||
reverse_proxy localhost:3000
|
reverse_proxy localhost:3000
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
@@ -42,13 +42,13 @@
|
|||||||
|
|
||||||
# Logs (optional)
|
# Logs (optional)
|
||||||
log {
|
log {
|
||||||
output file /var/log/caddy/jama-access.log
|
output file /var/log/caddy/rosterchirp-access.log
|
||||||
format json
|
format json
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Base domain (host admin panel) ───────────────────────────────────────────
|
# ── Base domain (host admin panel) ───────────────────────────────────────────
|
||||||
jamachat.com {
|
rosterchirp.com {
|
||||||
reverse_proxy localhost:3000
|
reverse_proxy localhost:3000
|
||||||
header {
|
header {
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
@@ -60,7 +60,7 @@ jamachat.com {
|
|||||||
# ── Custom tenant domains ─────────────────────────────────────────────────────
|
# ── Custom tenant domains ─────────────────────────────────────────────────────
|
||||||
# When a tenant sets up a custom domain (e.g. chat.theircompany.com):
|
# When a tenant sets up a custom domain (e.g. chat.theircompany.com):
|
||||||
#
|
#
|
||||||
# 1. They add a DNS CNAME: chat.theircompany.com → jamachat.com
|
# 1. They add a DNS CNAME: chat.theircompany.com → rosterchirp.com
|
||||||
#
|
#
|
||||||
# 2. You add a block here and reload Caddy.
|
# 2. You add a block here and reload Caddy.
|
||||||
# Caddy will automatically obtain and renew the SSL cert.
|
# Caddy will automatically obtain and renew the SSL cert.
|
||||||
@@ -80,7 +80,7 @@ jamachat.com {
|
|||||||
# }
|
# }
|
||||||
# }
|
# }
|
||||||
#
|
#
|
||||||
# *.jamachat.com, jamachat.com {
|
# *.rosterchirp.com, rosterchirp.com {
|
||||||
# tls { on_demand }
|
# tls { on_demand }
|
||||||
# reverse_proxy localhost:3000
|
# reverse_proxy localhost:3000
|
||||||
# }
|
# }
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ FROM node:20-alpine
|
|||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG BUILD_DATE=unknown
|
ARG BUILD_DATE=unknown
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="jama" \
|
LABEL org.opencontainers.image.title="rosterchirp" \
|
||||||
org.opencontainers.image.description="Self-hosted team chat PWA" \
|
org.opencontainers.image.description="Self-hosted team chat PWA" \
|
||||||
org.opencontainers.image.version="${VERSION}" \
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
org.opencontainers.image.created="${BUILD_DATE}"
|
org.opencontainers.image.created="${BUILD_DATE}"
|
||||||
|
|
||||||
ENV JAMA_VERSION=${VERSION}
|
ENV ROSTERCHIRP_VERSION=${VERSION}
|
||||||
|
|
||||||
# No native build tools needed — pg uses pure JS by default
|
# No native build tools needed — pg uses pure JS by default
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
31
FUTURE_FEATURES.md
Normal file
31
FUTURE_FEATURES.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# RosterChirp — Future Feature Requests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Markdown Message Rendering
|
||||||
|
|
||||||
|
**Request:** Render markdown-formatted text in the message window.
|
||||||
|
|
||||||
|
**Scope recommendation:** Start with inline-only markdown (bold, italic, inline code, strikethrough, links). Full block-level markdown (headers, lists, tables, code blocks) is a follow-on.
|
||||||
|
|
||||||
|
### Why inline-only first
|
||||||
|
- Zero risk of misformatting existing plain-text messages
|
||||||
|
- Full markdown risks rendering existing content oddly (e.g. a message starting with `1.` or `#` rendering as a list/header)
|
||||||
|
|
||||||
|
### Implementation notes
|
||||||
|
- Integration point is `formatMsgContent()` in `Message.jsx` — already returns HTML for `dangerouslySetInnerHTML`; a markdown parser slots straight in
|
||||||
|
- Add `marked` npm package (~13KB) for parsing
|
||||||
|
- Add `DOMPurify` for XSS sanitization — **required**, markdown allows raw HTML passthrough and content comes from other users
|
||||||
|
- `marked` config: `{ breaks: true }` to preserve single-newline-as-`<br>` behaviour, `{ mangle: false, headerIds: false }` to suppress heading anchors
|
||||||
|
- Apply markdown parse first, then @mention substitution (so `**@[Name]**` renders correctly)
|
||||||
|
- Remove the existing URL regex linkifier in `formatMsgContent` — markdown handles links natively
|
||||||
|
- Strip markdown from reply previews (currently shows raw `**text**`)
|
||||||
|
- `handleCopy` copies `msg.content` (raw markdown source) — correct behaviour, no change needed
|
||||||
|
- Emoji-only detection runs on raw content before rendering — no change needed
|
||||||
|
- Compose box stays plain textarea for v1; no preview toolbar required
|
||||||
|
|
||||||
|
### Effort estimate
|
||||||
|
| Scope | Estimate |
|
||||||
|
|---|---|
|
||||||
|
| Inline-only (bold, italic, code, strikethrough) | ~1.5 hours |
|
||||||
|
| Full markdown (+ block elements, CSS for bubbles) | ~4–5 hours |
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# JAMA — Known Limitations
|
# RosterChirp — Known Limitations
|
||||||
|
|
||||||
## Android Background Push Notifications
|
## Android Background Push Notifications
|
||||||
|
|
||||||
@@ -7,13 +7,13 @@
|
|||||||
**Does not affect:** Desktop browsers, iOS PWA (iOS 16.4+)
|
**Does not affect:** Desktop browsers, iOS PWA (iOS 16.4+)
|
||||||
|
|
||||||
### Symptom
|
### Symptom
|
||||||
Push notifications are not delivered when the jama PWA or Chrome browser loses focus on Android. The app also disconnects from the WebSocket (real-time chat) when backgrounded. Notifications only arrive while the app is open and in the foreground.
|
Push notifications are not delivered when the RosterChirp PWA or Chrome browser loses focus on Android. The app also disconnects from the WebSocket (real-time chat) when backgrounded. Notifications only arrive while the app is open and in the foreground.
|
||||||
|
|
||||||
### Root Cause
|
### Root Cause
|
||||||
Android's battery optimization system (Doze mode + App Standby) aggressively kills background network connections for browsers. This affects two things:
|
Android's battery optimization system (Doze mode + App Standby) aggressively kills background network connections for browsers. This affects two things:
|
||||||
|
|
||||||
1. **WebSocket** — the socket.io connection is dropped when Chrome/PWA loses focus, stopping real-time updates until the user returns to the app.
|
1. **WebSocket** — the socket.io connection is dropped when Chrome/PWA loses focus, stopping real-time updates until the user returns to the app.
|
||||||
2. **Web Push** — jama uses the standard Web Push API with VAPID keys. Android throttles or blocks push delivery at the system level even when battery settings are set to "No restrictions", because that setting only controls the app's own background activity — it does not exempt the browser's push service socket.
|
2. **Web Push** — RosterChirp uses the standard Web Push API with VAPID keys. Android throttles or blocks push delivery at the system level even when battery settings are set to "No restrictions", because that setting only controls the app's own background activity — it does not exempt the browser's push service socket.
|
||||||
|
|
||||||
Setting Chrome or the PWA to "No battery restrictions" in Android settings does **not** resolve this.
|
Setting Chrome or the PWA to "No battery restrictions" in Android settings does **not** resolve this.
|
||||||
|
|
||||||
@@ -28,4 +28,4 @@ Firebase Cloud Messaging (FCM) maintains a privileged persistent connection that
|
|||||||
5. Address WebSocket reconnect-on-focus separately (frontend only, no Firebase needed)
|
5. Address WebSocket reconnect-on-focus separately (frontend only, no Firebase needed)
|
||||||
|
|
||||||
### Workaround for Users
|
### Workaround for Users
|
||||||
None at the app level. Users who need reliable Android notifications should keep the jama PWA pinned/open, or check their Android vendor's specific battery exemption settings (Samsung DeX, MIUI, etc. have additional per-app exemption controls beyond the standard Android settings).
|
None at the app level. Users who need reliable Android notifications should keep the RosterChirp PWA pinned/open, or check their Android vendor's specific battery exemption settings (Samsung DeX, MIUI, etc. have additional per-app exemption controls beyond the standard Android settings).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.11.25",
|
"version": "0.11.25",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"firebase-admin": "^12.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"sharp": "^0.33.2",
|
"sharp": "^0.33.2",
|
||||||
"socket.io": "^4.6.1",
|
"socket.io": "^4.6.1",
|
||||||
"web-push": "^3.6.7",
|
|
||||||
"csv-parse": "^5.5.6",
|
"csv-parse": "^5.5.6",
|
||||||
"pg": "^8.11.3"
|
"pg": "^8.11.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ app.use('/api/about', require('./routes/about'));
|
|||||||
app.use('/api/help', require('./routes/help'));
|
app.use('/api/help', require('./routes/help'));
|
||||||
app.use('/api/push', pushRouter);
|
app.use('/api/push', pushRouter);
|
||||||
|
|
||||||
// JAMA-HOST control plane — only registered when APP_TYPE=host
|
// RosterChirp-Host control plane — only registered when APP_TYPE=host
|
||||||
if (APP_TYPE === 'host') {
|
if (APP_TYPE === 'host') {
|
||||||
app.use('/api/host', require('./routes/host'));
|
app.use('/api/host', require('./routes/host'));
|
||||||
console.log('[Server] JAMA-HOST control plane enabled at /api/host');
|
console.log('[Server] RosterChirp-Host control plane enabled at /api/host');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Link preview proxy ────────────────────────────────────────────────────────
|
// ── Link preview proxy ────────────────────────────────────────────────────────
|
||||||
@@ -67,7 +67,7 @@ app.get('/manifest.json', async (req, res) => {
|
|||||||
const s = {};
|
const s = {};
|
||||||
for (const r of rows) s[r.key] = r.value;
|
for (const r of rows) s[r.key] = r.value;
|
||||||
|
|
||||||
const appName = s.app_name || process.env.APP_NAME || 'jama';
|
const appName = s.app_name || process.env.APP_NAME || 'rosterchirp';
|
||||||
const icon192 = s.pwa_icon_192 || '/icons/icon-192.png';
|
const icon192 = s.pwa_icon_192 || '/icons/icon-192.png';
|
||||||
const icon512 = s.pwa_icon_512 || '/icons/icon-512.png';
|
const icon512 = s.pwa_icon_512 || '/icons/icon-512.png';
|
||||||
|
|
||||||
@@ -396,7 +396,7 @@ initDb().then(async () => {
|
|||||||
console.warn('[Server] Could not load tenant cache:', e.message);
|
console.warn('[Server] Could not load tenant cache:', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
server.listen(PORT, () => console.log(`[Server] jama listening on port ${PORT}`));
|
server.listen(PORT, () => console.log(`[Server] RosterChirp listening on port ${PORT}`));
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[Server] DB init failed:', err);
|
console.error('[Server] DB init failed:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* db.js — Postgres database layer for jama
|
* db.js — Postgres database layer for rosterchirp
|
||||||
*
|
*
|
||||||
* APP_TYPE environment variable controls tenancy:
|
* APP_TYPE environment variable controls tenancy:
|
||||||
* selfhost (default) → single schema 'public', one Postgres database
|
* selfhost (default) → single schema 'public', one Postgres database
|
||||||
@@ -32,8 +32,8 @@ if (APP_TYPE !== 'host') APP_TYPE = 'selfhost'; // only two valid values
|
|||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
host: process.env.DB_HOST || 'db',
|
host: process.env.DB_HOST || 'db',
|
||||||
port: parseInt(process.env.DB_PORT || '5432'),
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
database: process.env.DB_NAME || 'jama',
|
database: process.env.DB_NAME || 'rosterchirp',
|
||||||
user: process.env.DB_USER || 'jama',
|
user: process.env.DB_USER || 'rosterchirp',
|
||||||
password: process.env.DB_PASSWORD || '',
|
password: process.env.DB_PASSWORD || '',
|
||||||
max: 20,
|
max: 20,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
@@ -52,12 +52,12 @@ function resolveSchema(req) {
|
|||||||
if (APP_TYPE === 'selfhost') return 'public';
|
if (APP_TYPE === 'selfhost') return 'public';
|
||||||
|
|
||||||
const host = (req.headers.host || '').toLowerCase().split(':')[0];
|
const host = (req.headers.host || '').toLowerCase().split(':')[0];
|
||||||
const baseDomain = (process.env.HOST_DOMAIN || 'jamachat.com').toLowerCase();
|
const baseDomain = (process.env.HOST_DOMAIN || 'rosterchirp.com').toLowerCase();
|
||||||
|
|
||||||
// Internal requests (Docker health checks, localhost) → public schema
|
// Internal requests (Docker health checks, localhost) → public schema
|
||||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
|
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
|
||||||
|
|
||||||
// Subdomain: team1.jamachat.com → tenant_team1
|
// Subdomain: team1.rosterchirp.com → tenant_team1
|
||||||
if (host.endsWith(`.${baseDomain}`)) {
|
if (host.endsWith(`.${baseDomain}`)) {
|
||||||
const slug = host.slice(0, -(baseDomain.length + 1));
|
const slug = host.slice(0, -(baseDomain.length + 1));
|
||||||
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`);
|
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`);
|
||||||
@@ -198,7 +198,7 @@ async function runMigrations(schema) {
|
|||||||
|
|
||||||
async function seedSettings(schema) {
|
async function seedSettings(schema) {
|
||||||
const defaults = [
|
const defaults = [
|
||||||
['app_name', process.env.APP_NAME || 'jama'],
|
['app_name', process.env.APP_NAME || 'rosterchirp'],
|
||||||
['logo_url', ''],
|
['logo_url', ''],
|
||||||
['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'],
|
['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'],
|
||||||
['icon_newchat', ''],
|
['icon_newchat', ''],
|
||||||
@@ -213,7 +213,7 @@ async function seedSettings(schema) {
|
|||||||
['feature_branding', 'false'],
|
['feature_branding', 'false'],
|
||||||
['feature_group_manager', 'false'],
|
['feature_group_manager', 'false'],
|
||||||
['feature_schedule_manager', 'false'],
|
['feature_schedule_manager', 'false'],
|
||||||
['app_type', 'JAMA-Chat'],
|
['app_type', 'RosterChirp-Chat'],
|
||||||
['team_group_managers', ''],
|
['team_group_managers', ''],
|
||||||
['team_schedule_managers', ''],
|
['team_schedule_managers', ''],
|
||||||
['team_tool_managers', ''],
|
['team_tool_managers', ''],
|
||||||
@@ -269,7 +269,7 @@ async function seedUserGroups(schema) {
|
|||||||
|
|
||||||
async function seedAdmin(schema) {
|
async function seedAdmin(schema) {
|
||||||
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
||||||
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@jama.local';
|
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local';
|
||||||
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
|
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
|
||||||
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
||||||
const pwReset = process.env.ADMPW_RESET === 'true';
|
const pwReset = process.env.ADMPW_RESET === 'true';
|
||||||
@@ -350,11 +350,11 @@ async function initDb() {
|
|||||||
await seedAdmin('public');
|
await seedAdmin('public');
|
||||||
await seedUserGroups('public');
|
await seedUserGroups('public');
|
||||||
|
|
||||||
// Host mode: the public schema is the host's own workspace — always full JAMA-Team plan.
|
// Host mode: the public schema is the host's own workspace — always full RosterChirp-Team plan.
|
||||||
// ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too.
|
// ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too.
|
||||||
if (APP_TYPE === 'host') {
|
if (APP_TYPE === 'host') {
|
||||||
const hostPlan = [
|
const hostPlan = [
|
||||||
['app_type', 'JAMA-Team'],
|
['app_type', 'RosterChirp-Team'],
|
||||||
['feature_branding', 'true'],
|
['feature_branding', 'true'],
|
||||||
['feature_group_manager', 'true'],
|
['feature_group_manager', 'true'],
|
||||||
['feature_schedule_manager', 'true'],
|
['feature_schedule_manager', 'true'],
|
||||||
@@ -365,7 +365,7 @@ async function initDb() {
|
|||||||
[key, value]
|
[key, value]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log('[DB] Host mode: public schema upgraded to JAMA-Team plan');
|
console.log('[DB] Host mode: public schema upgraded to RosterChirp-Team plan');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[DB] Initialisation complete');
|
console.log('[DB] Initialisation complete');
|
||||||
|
|||||||
5
backend/src/models/migrations/007_fcm_push.sql
Normal file
5
backend/src/models/migrations/007_fcm_push.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Migration 007: FCM push — add fcm_token column, relax NOT NULL on legacy web-push columns
|
||||||
|
ALTER TABLE push_subscriptions ADD COLUMN IF NOT EXISTS fcm_token TEXT;
|
||||||
|
ALTER TABLE push_subscriptions ALTER COLUMN endpoint DROP NOT NULL;
|
||||||
|
ALTER TABLE push_subscriptions ALTER COLUMN p256dh DROP NOT NULL;
|
||||||
|
ALTER TABLE push_subscriptions ALTER COLUMN auth DROP NOT NULL;
|
||||||
4
backend/src/models/migrations/008_rebrand.sql
Normal file
4
backend/src/models/migrations/008_rebrand.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration 008: Rebrand — update app_type values from JAMA-* to RosterChirp-*
|
||||||
|
UPDATE settings SET value = 'RosterChirp-Chat' WHERE key = 'app_type' AND value = 'JAMA-Chat';
|
||||||
|
UPDATE settings SET value = 'RosterChirp-Brand' WHERE key = 'app_type' AND value = 'JAMA-Brand';
|
||||||
|
UPDATE settings SET value = 'RosterChirp-Team' WHERE key = 'app_type' AND value = 'JAMA-Team';
|
||||||
@@ -28,10 +28,10 @@ router.get('/', (req, res) => {
|
|||||||
const about = {
|
const about = {
|
||||||
...DEFAULTS,
|
...DEFAULTS,
|
||||||
...overrides,
|
...overrides,
|
||||||
version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
|
version: process.env.ROSTERCHIRP_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
|
||||||
// Always expose original app identity — not overrideable via about.json or settings
|
// Always expose original app identity — not overrideable via about.json or settings
|
||||||
default_app_name: 'jama',
|
default_app_name: 'rosterchirp',
|
||||||
default_logo: '/icons/jama.png',
|
default_logo: '/icons/rosterchirp.png',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Never expose docker_image — removed from UI
|
// Never expose docker_image — removed from UI
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* routes/host.js — JAMA-HOST control plane
|
* routes/host.js — RosterChirp-Host control plane
|
||||||
*
|
*
|
||||||
* All routes require the HOST_ADMIN_KEY header.
|
* All routes require the HOST_ADMIN_KEY header.
|
||||||
* These routes operate on the 'public' schema (tenant registry).
|
* These routes operate on the 'public' schema (tenant registry).
|
||||||
@@ -141,7 +141,7 @@ router.post('/tenants', async (req, res) => {
|
|||||||
process.env.ADMIN_PASS = origPass;
|
process.env.ADMIN_PASS = origPass;
|
||||||
|
|
||||||
// 5. Set app_type based on plan
|
// 5. Set app_type based on plan
|
||||||
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
|
const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
|
||||||
await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
||||||
if (plan === 'brand' || plan === 'team') {
|
if (plan === 'brand' || plan === 'team') {
|
||||||
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'");
|
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'");
|
||||||
@@ -161,7 +161,7 @@ router.post('/tenants', async (req, res) => {
|
|||||||
// 7. Reload domain cache
|
// 7. Reload domain cache
|
||||||
await reloadTenantCache();
|
await reloadTenantCache();
|
||||||
|
|
||||||
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
|
const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
|
||||||
const tenant = tr.rows[0];
|
const tenant = tr.rows[0];
|
||||||
tenant.url = `https://${slug}.${baseDomain}`;
|
tenant.url = `https://${slug}.${baseDomain}`;
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ router.patch('/tenants/:slug', async (req, res) => {
|
|||||||
await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]);
|
await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]);
|
||||||
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]);
|
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]);
|
||||||
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]);
|
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]);
|
||||||
const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
|
const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
|
||||||
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ router.get('/status', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants');
|
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants');
|
||||||
const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'");
|
const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'");
|
||||||
const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
|
const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
appType: process.env.APP_TYPE || 'selfhost',
|
appType: process.env.APP_TYPE || 'selfhost',
|
||||||
|
|||||||
@@ -1,50 +1,62 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const webpush = require('web-push');
|
const router = express.Router();
|
||||||
const router = express.Router();
|
const { query, queryOne, exec } = require('../models/db');
|
||||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
// VAPID keys are stored in settings; lazily initialised on first request
|
// ── Firebase Admin ─────────────────────────────────────────────────────────────
|
||||||
let vapidPublicKey = null;
|
let firebaseAdmin = null;
|
||||||
|
let firebaseApp = null;
|
||||||
|
|
||||||
async function getVapidKeys(schema) {
|
function getMessaging() {
|
||||||
const pub = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_public'");
|
if (firebaseApp) return firebaseAdmin.messaging(firebaseApp);
|
||||||
const priv = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_private'");
|
const json = process.env.FIREBASE_SERVICE_ACCOUNT;
|
||||||
if (!pub?.value || !priv?.value) {
|
if (!json) return null;
|
||||||
const keys = webpush.generateVAPIDKeys();
|
|
||||||
await exec(schema,
|
|
||||||
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
|
||||||
[keys.publicKey]
|
|
||||||
);
|
|
||||||
await exec(schema,
|
|
||||||
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
|
||||||
[keys.privateKey]
|
|
||||||
);
|
|
||||||
console.log('[Push] Generated new VAPID keys');
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
return { publicKey: pub.value, privateKey: priv.value };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initWebPush(schema) {
|
|
||||||
const keys = await getVapidKeys(schema);
|
|
||||||
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
|
|
||||||
return keys.publicKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called from index.js socket push notifications — schema comes from caller
|
|
||||||
async function sendPushToUser(schema, userId, payload) {
|
|
||||||
try {
|
try {
|
||||||
if (!vapidPublicKey) vapidPublicKey = await initWebPush(schema);
|
firebaseAdmin = require('firebase-admin');
|
||||||
const subs = await query(schema, 'SELECT * FROM push_subscriptions WHERE user_id = $1', [userId]);
|
const svc = JSON.parse(json);
|
||||||
|
firebaseApp = firebaseAdmin.initializeApp({
|
||||||
|
credential: firebaseAdmin.credential.cert(svc),
|
||||||
|
});
|
||||||
|
console.log('[Push] Firebase Admin initialised');
|
||||||
|
return firebaseAdmin.messaging(firebaseApp);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Push] Firebase Admin init failed:', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Called from index.js socket push notifications
|
||||||
|
async function sendPushToUser(schema, userId, payload) {
|
||||||
|
const messaging = getMessaging();
|
||||||
|
if (!messaging) return;
|
||||||
|
try {
|
||||||
|
const subs = await query(schema,
|
||||||
|
'SELECT * FROM push_subscriptions WHERE user_id = $1 AND fcm_token IS NOT NULL',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
for (const sub of subs) {
|
for (const sub of subs) {
|
||||||
try {
|
try {
|
||||||
await webpush.sendNotification(
|
await messaging.send({
|
||||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
token: sub.fcm_token,
|
||||||
JSON.stringify(payload)
|
data: {
|
||||||
);
|
title: payload.title || 'New Message',
|
||||||
|
body: payload.body || '',
|
||||||
|
url: payload.url || '/',
|
||||||
|
groupId: payload.groupId ? String(payload.groupId) : '',
|
||||||
|
},
|
||||||
|
android: { priority: 'high' },
|
||||||
|
webpush: { headers: { Urgency: 'high' } },
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
// Remove stale tokens
|
||||||
|
const stale = [
|
||||||
|
'messaging/registration-token-not-registered',
|
||||||
|
'messaging/invalid-registration-token',
|
||||||
|
'messaging/invalid-argument',
|
||||||
|
];
|
||||||
|
if (stale.includes(err.code)) {
|
||||||
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
|
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,56 +66,47 @@ async function sendPushToUser(schema, userId, payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/vapid-public', async (req, res) => {
|
// ── Routes ─────────────────────────────────────────────────────────────────────
|
||||||
try {
|
|
||||||
if (!vapidPublicKey) vapidPublicKey = await initWebPush(req.schema);
|
// Public — frontend fetches this to initialise the Firebase JS SDK
|
||||||
res.json({ publicKey: vapidPublicKey });
|
router.get('/firebase-config', (req, res) => {
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
const apiKey = process.env.FIREBASE_API_KEY;
|
||||||
|
const projectId = process.env.FIREBASE_PROJECT_ID;
|
||||||
|
const messagingSenderId = process.env.FIREBASE_MESSAGING_SENDER_ID;
|
||||||
|
const appId = process.env.FIREBASE_APP_ID;
|
||||||
|
const vapidKey = process.env.FIREBASE_VAPID_KEY;
|
||||||
|
|
||||||
|
if (!apiKey || !projectId || !messagingSenderId || !appId) {
|
||||||
|
return res.status(503).json({ error: 'FCM not configured' });
|
||||||
|
}
|
||||||
|
res.json({ apiKey, projectId, messagingSenderId, appId, vapidKey });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register / refresh an FCM token for the logged-in user
|
||||||
router.post('/subscribe', authMiddleware, async (req, res) => {
|
router.post('/subscribe', authMiddleware, async (req, res) => {
|
||||||
const { endpoint, keys } = req.body;
|
const { fcmToken } = req.body;
|
||||||
if (!endpoint || !keys?.p256dh || !keys?.auth)
|
if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' });
|
||||||
return res.status(400).json({ error: 'Invalid subscription' });
|
|
||||||
try {
|
try {
|
||||||
const device = req.device || 'desktop';
|
const device = req.device || 'desktop';
|
||||||
await exec(req.schema,
|
await exec(req.schema,
|
||||||
'DELETE FROM push_subscriptions WHERE endpoint = $1 OR (user_id = $2 AND device = $3)',
|
'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
|
||||||
[endpoint, req.user.id, device]
|
[req.user.id, device]
|
||||||
);
|
);
|
||||||
await exec(req.schema,
|
await exec(req.schema,
|
||||||
'INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES ($1,$2,$3,$4,$5)',
|
'INSERT INTO push_subscriptions (user_id, device, fcm_token) VALUES ($1, $2, $3)',
|
||||||
[req.user.id, device, endpoint, keys.p256dh, keys.auth]
|
[req.user.id, device, fcmToken]
|
||||||
);
|
);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/generate-vapid', authMiddleware, async (req, res) => {
|
// Remove the FCM token for the logged-in user / device
|
||||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
|
|
||||||
try {
|
|
||||||
const keys = webpush.generateVAPIDKeys();
|
|
||||||
await exec(req.schema,
|
|
||||||
"INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
|
||||||
[keys.publicKey]
|
|
||||||
);
|
|
||||||
await exec(req.schema,
|
|
||||||
"INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
|
|
||||||
[keys.privateKey]
|
|
||||||
);
|
|
||||||
webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
|
|
||||||
vapidPublicKey = keys.publicKey;
|
|
||||||
res.json({ publicKey: keys.publicKey });
|
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/unsubscribe', authMiddleware, async (req, res) => {
|
router.post('/unsubscribe', authMiddleware, async (req, res) => {
|
||||||
const { endpoint } = req.body;
|
|
||||||
if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
|
|
||||||
try {
|
try {
|
||||||
|
const device = req.device || 'desktop';
|
||||||
await exec(req.schema,
|
await exec(req.schema,
|
||||||
'DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2',
|
'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
|
||||||
[req.user.id, endpoint]
|
[req.user.id, device]
|
||||||
);
|
);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ router.get('/', async (req, res) => {
|
|||||||
for (const r of rows) obj[r.key] = r.value;
|
for (const r of rows) obj[r.key] = r.value;
|
||||||
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
|
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
|
||||||
if (admin) obj.admin_email = admin.email;
|
if (admin) obj.admin_email = admin.email;
|
||||||
obj.app_version = process.env.JAMA_VERSION || 'dev';
|
obj.app_version = process.env.ROSTERCHIRP_VERSION || 'dev';
|
||||||
obj.user_pass = process.env.USER_PASS || 'user@1234';
|
obj.user_pass = process.env.USER_PASS || 'user@1234';
|
||||||
// Tell the frontend whether this request came from the HOST_DOMAIN.
|
// Tell the frontend whether this request came from the HOST_DOMAIN.
|
||||||
// Used to show/hide the Control Panel menu item — only visible on the host's own domain.
|
// Used to show/hide the Control Panel menu item — only visible on the host's own domain.
|
||||||
@@ -105,7 +105,7 @@ router.patch('/colors', authMiddleware, adminMiddleware, async (req, res) => {
|
|||||||
|
|
||||||
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
|
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const originalName = process.env.APP_NAME || 'jama';
|
const originalName = process.env.APP_NAME || 'rosterchirp';
|
||||||
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]);
|
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]);
|
||||||
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'");
|
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'");
|
||||||
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key IN ('icon_newchat','icon_groupinfo','pwa_icon_192','pwa_icon_512','color_title','color_title_dark','color_avatar_public','color_avatar_dm')");
|
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key IN ('icon_newchat','icon_groupinfo','pwa_icon_192','pwa_icon_512','color_title','color_title_dark','color_avatar_public','color_avatar_dm')");
|
||||||
@@ -114,9 +114,9 @@ router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const VALID_CODES = {
|
const VALID_CODES = {
|
||||||
'JAMA-TEAM-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
|
'ROSTERCHIRP-TEAM-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
|
||||||
'JAMA-BRAND-2024': { appType:'JAMA-Brand', branding:true, groupManager:false, scheduleManager:false },
|
'ROSTERCHIRP-BRAND-2024': { appType:'RosterChirp-Brand', branding:true, groupManager:false, scheduleManager:false },
|
||||||
'JAMA-FULL-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
|
'ROSTERCHIRP-FULL-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
|
||||||
};
|
};
|
||||||
|
|
||||||
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
|
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
@@ -124,11 +124,11 @@ router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
if (!code?.trim()) {
|
if (!code?.trim()) {
|
||||||
await setSetting(req.schema, 'registration_code', '');
|
await setSetting(req.schema, 'registration_code', '');
|
||||||
await setSetting(req.schema, 'app_type', 'JAMA-Chat');
|
await setSetting(req.schema, 'app_type', 'RosterChirp-Chat');
|
||||||
await setSetting(req.schema, 'feature_branding', 'false');
|
await setSetting(req.schema, 'feature_branding', 'false');
|
||||||
await setSetting(req.schema, 'feature_group_manager', 'false');
|
await setSetting(req.schema, 'feature_group_manager', 'false');
|
||||||
await setSetting(req.schema, 'feature_schedule_manager', 'false');
|
await setSetting(req.schema, 'feature_schedule_manager', 'false');
|
||||||
return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'JAMA-Chat'} });
|
return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'RosterChirp-Chat'} });
|
||||||
}
|
}
|
||||||
const match = VALID_CODES[code.trim().toUpperCase()];
|
const match = VALID_CODES[code.trim().toUpperCase()];
|
||||||
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
|
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ async function getLinkPreview(url) {
|
|||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: { 'User-Agent': 'JamaBot/1.0' }
|
headers: { 'User-Agent': 'RosterChirpBot/1.0' }
|
||||||
});
|
});
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
|||||||
14
build.sh
14
build.sh
@@ -1,10 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# jama — Docker build & release script
|
# rosterchirp — Docker build & release script
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./build.sh # builds jama:latest
|
# ./build.sh # builds rosterchirp:latest
|
||||||
# ./build.sh 1.2.0 # builds jama:1.2.0 AND jama:latest
|
# ./build.sh 1.2.0 # builds rosterchirp:1.2.0 AND rosterchirp:latest
|
||||||
# ./build.sh 1.2.0 push # builds, tags, and pushes to registry
|
# ./build.sh 1.2.0 push # builds, tags, and pushes to registry
|
||||||
#
|
#
|
||||||
# To push to a registry, set REGISTRY env var:
|
# To push to a registry, set REGISTRY env var:
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.11.26}"
|
VERSION="${1:-0.12.0}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|
||||||
# If a registry is set, prefix image name
|
# If a registry is set, prefix image name
|
||||||
if [[ -n "$REGISTRY" ]]; then
|
if [[ -n "$REGISTRY" ]]; then
|
||||||
@@ -26,7 +26,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "╔══════════════════════════════════════╗"
|
echo "╔══════════════════════════════════════╗"
|
||||||
echo "║ jama Docker Builder ║"
|
echo "║ rosterchirp Docker Builder ║"
|
||||||
echo "╠══════════════════════════════════════╣"
|
echo "╠══════════════════════════════════════╣"
|
||||||
echo "║ Image : ${FULL_IMAGE}"
|
echo "║ Image : ${FULL_IMAGE}"
|
||||||
echo "║ Version : ${VERSION}"
|
echo "║ Version : ${VERSION}"
|
||||||
@@ -67,7 +67,7 @@ fi
|
|||||||
echo ""
|
echo ""
|
||||||
echo "─────────────────────────────────────────"
|
echo "─────────────────────────────────────────"
|
||||||
echo "To deploy this version, set in your .env:"
|
echo "To deploy this version, set in your .env:"
|
||||||
echo " JAMA_VERSION=${VERSION}"
|
echo " ROSTERCHIRP_VERSION=${VERSION}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Then run:"
|
echo "Then run:"
|
||||||
echo " docker compose up -d"
|
echo " docker compose up -d"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# docker-compose.host.yaml — JAMA-HOST multi-tenant deployment
|
# docker-compose.host.yaml — RosterChirp-Host multi-tenant deployment
|
||||||
#
|
#
|
||||||
# Use this instead of docker-compose.yaml when running JAMA-HOST.
|
# Use this instead of docker-compose.yaml when running RosterChirp-Host.
|
||||||
# Adds Caddy as the reverse proxy for automatic wildcard SSL.
|
# Adds Caddy as the reverse proxy for automatic wildcard SSL.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
@@ -8,14 +8,14 @@
|
|||||||
#
|
#
|
||||||
# Required .env additions for host mode:
|
# Required .env additions for host mode:
|
||||||
# APP_TYPE=host
|
# APP_TYPE=host
|
||||||
# HOST_DOMAIN=jamachat.com
|
# HOST_DOMAIN=rosterchirp.com
|
||||||
# HOST_ADMIN_KEY=your_secret_host_admin_key
|
# HOST_ADMIN_KEY=your_secret_host_admin_key
|
||||||
# CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider)
|
# CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider)
|
||||||
|
|
||||||
services:
|
services:
|
||||||
jama:
|
rosterchirp:
|
||||||
image: jama:${JAMA_VERSION:-latest}
|
image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
|
||||||
container_name: ${PROJECT_NAME:-jama}
|
container_name: ${PROJECT_NAME:-rosterchirp}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# No direct port exposure — traffic comes through Caddy
|
# No direct port exposure — traffic comes through Caddy
|
||||||
expose:
|
expose:
|
||||||
@@ -25,21 +25,21 @@ services:
|
|||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- APP_TYPE=host
|
- APP_TYPE=host
|
||||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@rosterchirp.local}
|
||||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||||
- ADMPW_RESET=${ADMPW_RESET:-false}
|
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||||
- APP_NAME=${APP_NAME:-jama}
|
- APP_NAME=${APP_NAME:-rosterchirp}
|
||||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
- DB_PORT=5432
|
- DB_PORT=5432
|
||||||
- DB_NAME=${DB_NAME:-jama}
|
- DB_NAME=${DB_NAME:-rosterchirp}
|
||||||
- DB_USER=${DB_USER:-jama}
|
- DB_USER=${DB_USER:-rosterchirp}
|
||||||
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
- HOST_DOMAIN=${HOST_DOMAIN:?HOST_DOMAIN is required in host mode}
|
- HOST_DOMAIN=${HOST_DOMAIN:?HOST_DOMAIN is required in host mode}
|
||||||
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode}
|
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode}
|
||||||
volumes:
|
volumes:
|
||||||
- jama_uploads:/app/uploads
|
- rosterchirp_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -51,16 +51,16 @@ services:
|
|||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: ${PROJECT_NAME:-jama}_db
|
container_name: ${PROJECT_NAME:-rosterchirp}_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=${DB_NAME:-jama}
|
- POSTGRES_DB=${DB_NAME:-rosterchirp}
|
||||||
- POSTGRES_USER=${DB_USER:-jama}
|
- POSTGRES_USER=${DB_USER:-rosterchirp}
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
volumes:
|
volumes:
|
||||||
- jama_db:/var/lib/postgresql/data
|
- rosterchirp_db:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-rosterchirp} -d ${DB_NAME:-rosterchirp}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -70,7 +70,7 @@ services:
|
|||||||
# Pre-built images: https://github.com/abiosoft/caddy-docker
|
# Pre-built images: https://github.com/abiosoft/caddy-docker
|
||||||
# Or build your own: xcaddy build --with github.com/caddy-dns/cloudflare
|
# Or build your own: xcaddy build --with github.com/caddy-dns/cloudflare
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
container_name: ${PROJECT_NAME:-jama}_caddy
|
container_name: ${PROJECT_NAME:-rosterchirp}_caddy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
@@ -84,12 +84,12 @@ services:
|
|||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
- /var/log/caddy:/var/log/caddy
|
- /var/log/caddy:/var/log/caddy
|
||||||
depends_on:
|
depends_on:
|
||||||
- jama
|
- rosterchirp
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
jama_db:
|
rosterchirp_db:
|
||||||
driver: local
|
driver: local
|
||||||
jama_uploads:
|
rosterchirp_uploads:
|
||||||
driver: local
|
driver: local
|
||||||
caddy_data:
|
caddy_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
jama:
|
rosterchirp:
|
||||||
image: jama:${JAMA_VERSION:-latest}
|
image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
|
||||||
container_name: ${PROJECT_NAME:-jama}
|
container_name: ${PROJECT_NAME:-rosterchirp}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${PORT:-3000}:3000"
|
||||||
@@ -10,21 +10,21 @@ services:
|
|||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- APP_TYPE=${APP_TYPE:-selfhost}
|
- APP_TYPE=${APP_TYPE:-selfhost}
|
||||||
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@rosterchirp.local}
|
||||||
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
|
||||||
- ADMPW_RESET=${ADMPW_RESET:-false}
|
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||||
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
|
||||||
- APP_NAME=${APP_NAME:-jama}
|
- APP_NAME=${APP_NAME:-rosterchirp}
|
||||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
- DB_PORT=5432
|
- DB_PORT=5432
|
||||||
- DB_NAME=${DB_NAME:-jama}
|
- DB_NAME=${DB_NAME:-rosterchirp}
|
||||||
- DB_USER=${DB_USER:-jama}
|
- DB_USER=${DB_USER:-rosterchirp}
|
||||||
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
- HOST_DOMAIN=${HOST_DOMAIN:-}
|
- HOST_DOMAIN=${HOST_DOMAIN:-}
|
||||||
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-}
|
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- jama_uploads:/app/uploads
|
- rosterchirp_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -36,22 +36,22 @@ services:
|
|||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: ${PROJECT_NAME:-jama}_db
|
container_name: ${PROJECT_NAME:-rosterchirp}_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=${DB_NAME:-jama}
|
- POSTGRES_DB=${DB_NAME:-rosterchirp}
|
||||||
- POSTGRES_USER=${DB_USER:-jama}
|
- POSTGRES_USER=${DB_USER:-rosterchirp}
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
volumes:
|
volumes:
|
||||||
- jama_db:/var/lib/postgresql/data
|
- rosterchirp_db:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-rosterchirp} -d ${DB_NAME:-rosterchirp}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
jama_db:
|
rosterchirp_db:
|
||||||
driver: local
|
driver: local
|
||||||
jama_uploads:
|
rosterchirp_uploads:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/icons/jama.png" />
|
<link rel="icon" type="image/png" href="/icons/rosterchirp.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<meta name="theme-color" content="#1a73e8" />
|
<meta name="theme-color" content="#1a73e8" />
|
||||||
<meta name="description" content="jama - just another messaging app" />
|
<meta name="description" content="RosterChirp - team messaging" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
<title>jama</title>
|
<title>RosterChirp</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.11.25",
|
"version": "0.11.25",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"marked": "^12.0.0"
|
"marked": "^12.0.0",
|
||||||
|
"firebase": "^10.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama",
|
"name": "RosterChirp",
|
||||||
"short_name": "jama",
|
"short_name": "RosterChirp",
|
||||||
"description": "Modern team messaging application",
|
"description": "Modern team messaging application",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
|
|||||||
@@ -1,4 +1,27 @@
|
|||||||
const CACHE_NAME = 'jama-v1';
|
// ── Firebase Messaging (background push for Android PWA) ──────────────────────
|
||||||
|
// Fill in the values below from Firebase Console → Project Settings → General → Your apps
|
||||||
|
// Leave apiKey as '__FIREBASE_API_KEY__' if not using FCM (push will be disabled).
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js');
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js');
|
||||||
|
|
||||||
|
const FIREBASE_CONFIG = {
|
||||||
|
apiKey: "AIzaSyDx191unzXFT4WA1OvkdbrIY_c57kgruAU",
|
||||||
|
authDomain: "rosterchirp-push.firebaseapp.com",
|
||||||
|
projectId: "rosterchirp-push",
|
||||||
|
storageBucket: "rosterchirp-push.firebasestorage.app",
|
||||||
|
messagingSenderId: "126479377334",
|
||||||
|
appId: "1:126479377334:web:280abdd135cf7e0c50d717"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only initialise Firebase if the config has been filled in
|
||||||
|
let messaging = null;
|
||||||
|
if (FIREBASE_CONFIG.apiKey !== '__FIREBASE_API_KEY__') {
|
||||||
|
firebase.initializeApp(FIREBASE_CONFIG);
|
||||||
|
messaging = firebase.messaging();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
const CACHE_NAME = 'rosterchirp-v1';
|
||||||
const STATIC_ASSETS = ['/'];
|
const STATIC_ASSETS = ['/'];
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
@@ -27,53 +50,35 @@ self.addEventListener('fetch', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track badge count in SW scope
|
// ── Badge counter ─────────────────────────────────────────────────────────────
|
||||||
let badgeCount = 0;
|
let badgeCount = 0;
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
function showRosterChirpNotification(data) {
|
||||||
if (!event.data) return;
|
|
||||||
|
|
||||||
let data = {};
|
|
||||||
try { data = event.data.json(); } catch (e) { return; }
|
|
||||||
|
|
||||||
badgeCount++;
|
badgeCount++;
|
||||||
|
if (self.navigator?.setAppBadge) self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||||
|
|
||||||
// Update app badge
|
return self.registration.showNotification(data.title || 'New Message', {
|
||||||
if (self.navigator && self.navigator.setAppBadge) {
|
body: data.body || '',
|
||||||
self.navigator.setAppBadge(badgeCount).catch(() => {});
|
icon: '/icons/icon-192.png',
|
||||||
}
|
badge: '/icons/icon-192-maskable.png',
|
||||||
|
data: { url: data.url || '/' },
|
||||||
// Check if app is currently visible — if so, skip the notification
|
tag: data.groupId ? `rosterchirp-group-${data.groupId}` : 'rosterchirp-message',
|
||||||
const showNotification = clients.matchAll({
|
renotify: true,
|
||||||
type: 'window',
|
|
||||||
includeUncontrolled: true,
|
|
||||||
}).then((clientList) => {
|
|
||||||
const appVisible = clientList.some(
|
|
||||||
(c) => c.visibilityState === 'visible'
|
|
||||||
);
|
|
||||||
// Still show if app is open but hidden (minimized), skip only if truly visible
|
|
||||||
if (appVisible) return;
|
|
||||||
|
|
||||||
return self.registration.showNotification(data.title || 'New Message', {
|
|
||||||
body: data.body || '',
|
|
||||||
icon: '/icons/icon-192.png',
|
|
||||||
badge: '/icons/icon-192-maskable.png',
|
|
||||||
data: { url: data.url || '/' },
|
|
||||||
// Use unique tag per group so notifications group by conversation
|
|
||||||
tag: data.groupId ? `jama-group-${data.groupId}` : 'jama-message',
|
|
||||||
renotify: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
event.waitUntil(showNotification);
|
// ── FCM background messages ───────────────────────────────────────────────────
|
||||||
});
|
if (messaging) {
|
||||||
|
messaging.onBackgroundMessage((payload) => {
|
||||||
|
return showRosterChirpNotification(payload.data || {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification click ────────────────────────────────────────────────────────
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
badgeCount = 0;
|
badgeCount = 0;
|
||||||
if (self.navigator && self.navigator.clearAppBadge) {
|
if (self.navigator?.clearAppBadge) self.navigator.clearAppBadge().catch(() => {});
|
||||||
self.navigator.clearAppBadge().catch(() => {});
|
|
||||||
}
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
const url = event.notification.data?.url || '/';
|
const url = event.notification.data?.url || '/';
|
||||||
@@ -88,17 +93,15 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear badge when app signals it
|
// ── Badge control messages from main thread ───────────────────────────────────
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'CLEAR_BADGE') {
|
if (event.data?.type === 'CLEAR_BADGE') {
|
||||||
badgeCount = 0;
|
badgeCount = 0;
|
||||||
if (self.navigator && self.navigator.clearAppBadge) {
|
if (self.navigator?.clearAppBadge) self.navigator.clearAppBadge().catch(() => {});
|
||||||
self.navigator.clearAppBadge().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (event.data?.type === 'SET_BADGE') {
|
if (event.data?.type === 'SET_BADGE') {
|
||||||
badgeCount = event.data.count || 0;
|
badgeCount = event.data.count || 0;
|
||||||
if (self.navigator && self.navigator.setAppBadge) {
|
if (self.navigator?.setAppBadge) {
|
||||||
if (badgeCount > 0) {
|
if (badgeCount > 0) {
|
||||||
self.navigator.setAppBadge(badgeCount).catch(() => {});
|
self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function AuthRoute({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RestoreTheme() {
|
function RestoreTheme() {
|
||||||
const saved = localStorage.getItem('jama-theme') || 'light';
|
const saved = localStorage.getItem('rosterchirp-theme') || 'light';
|
||||||
document.documentElement.setAttribute('data-theme', saved);
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export default function AboutModal({ onClose }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Always use the original app identity — not the user-customised settings name/logo
|
// Always use the original app identity — not the user-customised settings name/logo
|
||||||
const appName = about?.default_app_name || 'jama';
|
const appName = about?.default_app_name || 'rosterchirp';
|
||||||
const logoSrc = about?.default_logo || '/icons/jama.png';
|
const logoSrc = about?.default_logo || '/icons/rosterchirp.png';
|
||||||
const version = about?.version || '';
|
const version = about?.version || '';
|
||||||
const a = about || {};
|
const a = about || {};
|
||||||
|
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export default function BrandingModal({ onClose }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then(({ settings }) => {
|
api.getSettings().then(({ settings }) => {
|
||||||
setSettings(settings);
|
setSettings(settings);
|
||||||
setAppName(settings.app_name || 'jama');
|
setAppName(settings.app_name || 'rosterchirp');
|
||||||
setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR);
|
setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR);
|
||||||
setColourTitleDark(settings.color_title_dark || DEFAULT_TITLE_DARK_COLOR);
|
setColourTitleDark(settings.color_title_dark || DEFAULT_TITLE_DARK_COLOR);
|
||||||
setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
|
setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
|
||||||
@@ -329,7 +329,7 @@ export default function BrandingModal({ onClose }) {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
|
const notifySidebarRefresh = () => window.dispatchEvent(new Event('rosterchirp:settings-changed'));
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
const handleSaveName = async () => {
|
||||||
if (!appName.trim()) return;
|
if (!appName.trim()) return;
|
||||||
@@ -391,7 +391,7 @@ export default function BrandingModal({ onClose }) {
|
|||||||
await api.resetSettings();
|
await api.resetSettings();
|
||||||
const { settings: fresh } = await api.getSettings();
|
const { settings: fresh } = await api.getSettings();
|
||||||
setSettings(fresh);
|
setSettings(fresh);
|
||||||
setAppName(fresh.app_name || 'jama');
|
setAppName(fresh.app_name || 'rosterchirp');
|
||||||
setColourTitle(DEFAULT_TITLE_COLOR);
|
setColourTitle(DEFAULT_TITLE_COLOR);
|
||||||
setColourTitleDark(DEFAULT_TITLE_DARK_COLOR);
|
setColourTitleDark(DEFAULT_TITLE_DARK_COLOR);
|
||||||
setColourPublic(DEFAULT_PUBLIC_COLOR);
|
setColourPublic(DEFAULT_PUBLIC_COLOR);
|
||||||
@@ -433,7 +433,7 @@ export default function BrandingModal({ onClose }) {
|
|||||||
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||||
}}>
|
}}>
|
||||||
<img src={settings.logo_url || '/icons/jama.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
<img src={settings.logo_url || '/icons/rosterchirp.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
|||||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||||
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
|
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
window.addEventListener('jama:settings-updated', handler);
|
window.addEventListener('rosterchirp:settings-updated', handler);
|
||||||
window.addEventListener('jama:settings-changed', handler);
|
window.addEventListener('rosterchirp:settings-changed', handler);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('jama:settings-updated', handler);
|
window.removeEventListener('rosterchirp:settings-updated', handler);
|
||||||
window.removeEventListener('jama:settings-changed', handler);
|
window.removeEventListener('rosterchirp:settings-changed', handler);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,24 @@ import { api } from '../utils/api.js';
|
|||||||
|
|
||||||
export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
|
export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
|
||||||
const { connected } = useSocket();
|
const { connected } = useSocket();
|
||||||
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
|
const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '' });
|
||||||
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
|
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
window.addEventListener('jama:settings-changed', handler);
|
window.addEventListener('rosterchirp:settings-changed', handler);
|
||||||
const themeObserver = new MutationObserver(() => {
|
const themeObserver = new MutationObserver(() => {
|
||||||
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
|
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
|
||||||
});
|
});
|
||||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('jama:settings-changed', handler);
|
window.removeEventListener('rosterchirp:settings-changed', handler);
|
||||||
themeObserver.disconnect();
|
themeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const appName = settings.app_name || 'jama';
|
const appName = settings.app_name || 'rosterchirp';
|
||||||
const logoUrl = settings.logo_url;
|
const logoUrl = settings.logo_url;
|
||||||
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null;
|
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null;
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="global-bar-brand">
|
<div className="global-bar-brand">
|
||||||
<img src={logoUrl || '/icons/jama.png'} alt={appName} className="global-bar-logo" />
|
<img src={logoUrl || '/icons/rosterchirp.png'} alt={appName} className="global-bar-logo" />
|
||||||
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
|
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* HostPanel.jsx — JAMA-HOST Control Panel
|
* HostPanel.jsx — RosterChirp-Host Control Panel
|
||||||
*
|
*
|
||||||
* Renders inside the main JAMA right-panel area (not a separate page/route).
|
* Renders inside the main RosterChirp right-panel area (not a separate page/route).
|
||||||
* Protected by:
|
* Protected by:
|
||||||
* 1. Only shown when is_host_domain === true (server-computed from HOST_DOMAIN)
|
* 1. Only shown when is_host_domain === true (server-computed from HOST_DOMAIN)
|
||||||
* 2. Only accessible to admin role users
|
* 2. Only accessible to admin role users
|
||||||
@@ -15,9 +15,9 @@ import UserFooter from './UserFooter.jsx';
|
|||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{ value: 'chat', label: 'JAMA-Chat', desc: 'Chat only' },
|
{ value: 'chat', label: 'RosterChirp-Chat', desc: 'Chat only' },
|
||||||
{ value: 'brand', label: 'JAMA-Brand', desc: 'Chat + Branding' },
|
{ value: 'brand', label: 'RosterChirp-Brand', desc: 'Chat + Branding' },
|
||||||
{ value: 'team', label: 'JAMA-Team', desc: 'Chat + Branding + Groups + Schedule' },
|
{ value: 'team', label: 'RosterChirp-Team', desc: 'Chat + Branding + Groups + Schedule' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PLAN_COLOURS = {
|
const PLAN_COLOURS = {
|
||||||
@@ -307,7 +307,7 @@ function KeyEntry({ onSubmit }) {
|
|||||||
setChecking(true); setError('');
|
setChecking(true); setError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/host/status', { headers: { 'X-Host-Admin-Key': key.trim() } });
|
const res = await fetch('/api/host/status', { headers: { 'X-Host-Admin-Key': key.trim() } });
|
||||||
if (res.ok) { sessionStorage.setItem('jama-host-key', key.trim()); onSubmit(key.trim()); }
|
if (res.ok) { sessionStorage.setItem('rosterchirp-host-key', key.trim()); onSubmit(key.trim()); }
|
||||||
else setError('Invalid admin key');
|
else setError('Invalid admin key');
|
||||||
} catch { setError('Connection error'); }
|
} catch { setError('Connection error'); }
|
||||||
finally { setChecking(false); }
|
finally { setChecking(false); }
|
||||||
@@ -336,7 +336,7 @@ function KeyEntry({ onSubmit }) {
|
|||||||
|
|
||||||
export default function HostPanel({ onProfile, onHelp, onAbout }) {
|
export default function HostPanel({ onProfile, onHelp, onAbout }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || '');
|
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('rosterchirp-host-key') || '');
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
const [tenants, setTenants] = useState([]);
|
const [tenants, setTenants] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -363,7 +363,7 @@ export default function HostPanel({ onProfile, onHelp, onAbout }) {
|
|||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
// Key is invalid — clear it so the prompt shows again
|
// Key is invalid — clear it so the prompt shows again
|
||||||
if (e.message.includes('401') || e.message.includes('Invalid') || e.message.includes('401')) {
|
if (e.message.includes('401') || e.message.includes('Invalid') || e.message.includes('401')) {
|
||||||
sessionStorage.removeItem('jama-host-key');
|
sessionStorage.removeItem('rosterchirp-host-key');
|
||||||
setAdminKey('');
|
setAdminKey('');
|
||||||
}
|
}
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { useToast } from '../contexts/ToastContext.jsx';
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const APP_TYPES = {
|
const APP_TYPES = {
|
||||||
'JAMA-Chat': { label: 'JAMA-Chat', desc: 'Chat only. No Branding, Group Manager or Schedule Manager.' },
|
'RosterChirp-Chat': { label: 'RosterChirp-Chat', desc: 'Chat only. No Branding, Group Manager or Schedule Manager.' },
|
||||||
'JAMA-Brand': { label: 'JAMA-Brand', desc: 'Chat and Branding.' },
|
'RosterChirp-Brand': { label: 'RosterChirp-Brand', desc: 'Chat and Branding.' },
|
||||||
'JAMA-Team': { label: 'JAMA-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' },
|
'RosterChirp-Team': { label: 'RosterChirp-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Team Management Tab ───────────────────────────────────────────────────────
|
// ── Team Management Tab ───────────────────────────────────────────────────────
|
||||||
@@ -34,7 +34,7 @@ function TeamManagementTab() {
|
|||||||
try {
|
try {
|
||||||
await api.updateTeamSettings({ toolManagers });
|
await api.updateTeamSettings({ toolManagers });
|
||||||
toast('Team settings saved', 'success');
|
toast('Team settings saved', 'success');
|
||||||
window.dispatchEvent(new Event('jama:settings-changed'));
|
window.dispatchEvent(new Event('rosterchirp:settings-changed'));
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
@@ -82,7 +82,7 @@ function RegistrationTab({ onFeaturesChanged }) {
|
|||||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const appType = settings.app_type || 'JAMA-Chat';
|
const appType = settings.app_type || 'RosterChirp-Chat';
|
||||||
const activeCode = settings.registration_code || '';
|
const activeCode = settings.registration_code || '';
|
||||||
const adminEmail = settings.admin_email || '—';
|
const adminEmail = settings.admin_email || '—';
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ function RegistrationTab({ onFeaturesChanged }) {
|
|||||||
const fresh = await api.getSettings();
|
const fresh = await api.getSettings();
|
||||||
setSettings(fresh.settings);
|
setSettings(fresh.settings);
|
||||||
toast('Registration applied successfully.', 'success');
|
toast('Registration applied successfully.', 'success');
|
||||||
window.dispatchEvent(new Event('jama:settings-changed'));
|
window.dispatchEvent(new Event('rosterchirp:settings-changed'));
|
||||||
onFeaturesChanged && onFeaturesChanged(f);
|
onFeaturesChanged && onFeaturesChanged(f);
|
||||||
} catch (e) { toast(e.message || 'Invalid registration code', 'error'); }
|
} catch (e) { toast(e.message || 'Invalid registration code', 'error'); }
|
||||||
finally { setRegLoading(false); }
|
finally { setRegLoading(false); }
|
||||||
@@ -116,12 +116,12 @@ function RegistrationTab({ onFeaturesChanged }) {
|
|||||||
const fresh = await api.getSettings();
|
const fresh = await api.getSettings();
|
||||||
setSettings(fresh.settings);
|
setSettings(fresh.settings);
|
||||||
toast('Registration cleared.', 'success');
|
toast('Registration cleared.', 'success');
|
||||||
window.dispatchEvent(new Event('jama:settings-changed'));
|
window.dispatchEvent(new Event('rosterchirp:settings-changed'));
|
||||||
onFeaturesChanged && onFeaturesChanged(f);
|
onFeaturesChanged && onFeaturesChanged(f);
|
||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeInfo = APP_TYPES[appType] || APP_TYPES['JAMA-Chat'];
|
const typeInfo = APP_TYPES[appType] || APP_TYPES['RosterChirp-Chat'];
|
||||||
const siteUrl = window.location.origin;
|
const siteUrl = window.location.origin;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,7 +132,7 @@ function RegistrationTab({ onFeaturesChanged }) {
|
|||||||
Registration {activeCode ? 'is' : 'required:'}
|
Registration {activeCode ? 'is' : 'required:'}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||||
JAMA {activeCode ? 'is' : 'will be'} registered to:<br />
|
RosterChirp {activeCode ? 'is' : 'will be'} registered to:<br />
|
||||||
<strong>Type:</strong> {typeInfo.label}<br />
|
<strong>Type:</strong> {typeInfo.label}<br />
|
||||||
<strong>URL:</strong> {siteUrl}
|
<strong>URL:</strong> {siteUrl}
|
||||||
</p>
|
</p>
|
||||||
@@ -185,9 +185,9 @@ function RegistrationTab({ onFeaturesChanged }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}>
|
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}>
|
||||||
Registration codes unlock application features. Contact your JAMA provider for a code.<br />
|
Registration codes unlock application features. Contact your RosterChirp provider for a code.<br />
|
||||||
<strong>JAMA-Brand</strong> — unlocks Branding.
|
<strong>RosterChirp-Brand</strong> — unlocks Branding.
|
||||||
<strong>JAMA-Team</strong> — unlocks Branding, Group Manager and Schedule Manager.
|
<strong>RosterChirp-Team</strong> — unlocks Branding, Group Manager and Schedule Manager.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -270,18 +270,18 @@ function WebPushTab() {
|
|||||||
// ── Main modal ────────────────────────────────────────────────────────────────
|
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||||
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
||||||
const [tab, setTab] = useState('registration');
|
const [tab, setTab] = useState('registration');
|
||||||
const [appType, setAppType] = useState('JAMA-Chat');
|
const [appType, setAppType] = useState('RosterChirp-Chat');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getSettings().then(({ settings }) => {
|
api.getSettings().then(({ settings }) => {
|
||||||
setAppType(settings.app_type || 'JAMA-Chat');
|
setAppType(settings.app_type || 'RosterChirp-Chat');
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
const handler = () => api.getSettings().then(({ settings }) => setAppType(settings.app_type || 'JAMA-Chat')).catch(() => {});
|
const handler = () => api.getSettings().then(({ settings }) => setAppType(settings.app_type || 'RosterChirp-Chat')).catch(() => {});
|
||||||
window.addEventListener('jama:settings-changed', handler);
|
window.addEventListener('rosterchirp:settings-changed', handler);
|
||||||
return () => window.removeEventListener('jama:settings-changed', handler);
|
return () => window.removeEventListener('rosterchirp:settings-changed', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isTeam = appType === 'JAMA-Team';
|
const isTeam = appType === 'RosterChirp-Team';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
isTeam && { id: 'team', label: 'Team Management' },
|
isTeam && { id: 'team', label: 'Team Management' },
|
||||||
|
|||||||
@@ -14,20 +14,20 @@ function nameToColor(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useAppSettings() {
|
function useAppSettings() {
|
||||||
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
|
const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
|
||||||
const fetchSettings = () => {
|
const fetchSettings = () => {
|
||||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
window.addEventListener('jama:settings-changed', fetchSettings);
|
window.addEventListener('rosterchirp:settings-changed', fetchSettings);
|
||||||
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
|
return () => window.removeEventListener('rosterchirp:settings-changed', fetchSettings);
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const name = settings.app_name || 'jama';
|
const name = settings.app_name || 'rosterchirp';
|
||||||
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
|
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
|
||||||
document.title = prefix + name;
|
document.title = prefix + name;
|
||||||
const faviconUrl = settings.logo_url || '/icons/jama.png';
|
const faviconUrl = settings.logo_url || '/icons/rosterchirp.png';
|
||||||
let link = document.querySelector("link[rel~='icon']");
|
let link = document.querySelector("link[rel~='icon']");
|
||||||
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
|
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
|
||||||
link.href = faviconUrl;
|
link.href = faviconUrl;
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useAuth } from '../contexts/AuthContext.jsx';
|
|||||||
import Avatar from './Avatar.jsx';
|
import Avatar from './Avatar.jsx';
|
||||||
|
|
||||||
function useTheme() {
|
function useTheme() {
|
||||||
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
|
const [dark, setDark] = useState(() => localStorage.getItem('rosterchirp-theme') === 'dark');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||||
localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
|
localStorage.setItem('rosterchirp-theme', dark ? 'dark' : 'light');
|
||||||
}, [dark]);
|
}, [dark]);
|
||||||
return [dark, setDark];
|
return [dark, setDark];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ export function AuthProvider({ children }) {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setMustChangePassword(false);
|
setMustChangePassword(false);
|
||||||
};
|
};
|
||||||
window.addEventListener('jama:session-displaced', handler);
|
window.addEventListener('rosterchirp:session-displaced', handler);
|
||||||
return () => window.removeEventListener('jama:session-displaced', handler);
|
return () => window.removeEventListener('rosterchirp:session-displaced', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates }));
|
const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates }));
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function SocketProvider({ children }) {
|
|||||||
|
|
||||||
// Session displaced: another login on the same device type has kicked this session
|
// Session displaced: another login on the same device type has kicked this session
|
||||||
socket.on('session:displaced', () => {
|
socket.on('session:displaced', () => {
|
||||||
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
|
window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected
|
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ if ('serviceWorker' in navigator) {
|
|||||||
//
|
//
|
||||||
// 2. PULL-TO-REFRESH → blocked in PWA standalone mode only.
|
// 2. PULL-TO-REFRESH → blocked in PWA standalone mode only.
|
||||||
(function () {
|
(function () {
|
||||||
const LS_KEY = 'jama_font_scale';
|
const LS_KEY = 'rosterchirp_font_scale';
|
||||||
const MIN_SCALE = 0.8;
|
const MIN_SCALE = 0.8;
|
||||||
const MAX_SCALE = 2.0;
|
const MAX_SCALE = 2.0;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function Chat() {
|
|||||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
|
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
|
||||||
const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages'
|
const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages'
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat', teamToolManagers: [], isHostDomain: false });
|
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false });
|
||||||
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const [showSidebar, setShowSidebar] = useState(true);
|
const [showSidebar, setShowSidebar] = useState(true);
|
||||||
@@ -81,7 +81,7 @@ export default function Chat() {
|
|||||||
branding: settings.feature_branding === 'true',
|
branding: settings.feature_branding === 'true',
|
||||||
groupManager: settings.feature_group_manager === 'true',
|
groupManager: settings.feature_group_manager === 'true',
|
||||||
scheduleManager: settings.feature_schedule_manager === 'true',
|
scheduleManager: settings.feature_schedule_manager === 'true',
|
||||||
appType: settings.app_type || 'JAMA-Chat',
|
appType: settings.app_type || 'RosterChirp-Chat',
|
||||||
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
|
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
|
||||||
isHostDomain: settings.is_host_domain === 'true',
|
isHostDomain: settings.is_host_domain === 'true',
|
||||||
}));
|
}));
|
||||||
@@ -93,53 +93,59 @@ export default function Chat() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFeatures();
|
loadFeatures();
|
||||||
window.addEventListener('jama:settings-changed', loadFeatures);
|
window.addEventListener('rosterchirp:settings-changed', loadFeatures);
|
||||||
return () => window.removeEventListener('jama:settings-changed', loadFeatures);
|
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
|
||||||
}, [loadFeatures]);
|
}, [loadFeatures]);
|
||||||
|
|
||||||
// Register / refresh push subscription
|
// Register / refresh FCM push subscription
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
const registerPush = async () => {
|
const registerPush = async () => {
|
||||||
try {
|
try {
|
||||||
const permission = Notification.permission;
|
if (Notification.permission === 'denied') return;
|
||||||
if (permission === 'denied') return;
|
|
||||||
|
// Fetch Firebase config from backend (returns 503 if FCM not configured)
|
||||||
|
const configRes = await fetch('/api/push/firebase-config');
|
||||||
|
if (!configRes.ok) return;
|
||||||
|
const { apiKey, projectId, messagingSenderId, appId, vapidKey } = await configRes.json();
|
||||||
|
|
||||||
|
// Dynamically import the Firebase SDK (tree-shaken, only loaded when needed)
|
||||||
|
const { initializeApp, getApps } = await import('firebase/app');
|
||||||
|
const { getMessaging, getToken } = await import('firebase/messaging');
|
||||||
|
|
||||||
|
const firebaseApp = getApps().length
|
||||||
|
? getApps()[0]
|
||||||
|
: initializeApp({ apiKey, projectId, messagingSenderId, appId });
|
||||||
|
const firebaseMessaging = getMessaging(firebaseApp);
|
||||||
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
const reg = await navigator.serviceWorker.ready;
|
||||||
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
|
|
||||||
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
|
||||||
|
|
||||||
let sub = await reg.pushManager.getSubscription();
|
if (Notification.permission !== 'granted') {
|
||||||
|
const granted = await Notification.requestPermission();
|
||||||
if (!sub) {
|
|
||||||
// First time or subscription was lost — request permission then subscribe
|
|
||||||
const granted = permission === 'granted'
|
|
||||||
? 'granted'
|
|
||||||
: await Notification.requestPermission();
|
|
||||||
if (granted !== 'granted') return;
|
if (granted !== 'granted') return;
|
||||||
sub = await reg.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always re-register subscription with the server (keeps it fresh on mobile)
|
const fcmToken = await getToken(firebaseMessaging, {
|
||||||
|
vapidKey,
|
||||||
|
serviceWorkerRegistration: reg,
|
||||||
|
});
|
||||||
|
if (!fcmToken) return;
|
||||||
|
|
||||||
|
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||||
await fetch('/api/push/subscribe', {
|
await fetch('/api/push/subscribe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||||
body: JSON.stringify(sub.toJSON()),
|
body: JSON.stringify({ fcmToken }),
|
||||||
});
|
});
|
||||||
console.log('[Push] Subscription registered');
|
console.log('[Push] FCM subscription registered');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Push] Subscription failed:', e.message);
|
console.warn('[Push] FCM subscription failed:', e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
registerPush();
|
registerPush();
|
||||||
|
|
||||||
// Bug A fix: re-register push subscription when app returns to foreground
|
|
||||||
// Mobile browsers can drop push subscriptions when the app is backgrounded
|
|
||||||
const handleVisibility = () => {
|
const handleVisibility = () => {
|
||||||
if (document.visibilityState === 'visible') registerPush();
|
if (document.visibilityState === 'visible') registerPush();
|
||||||
};
|
};
|
||||||
@@ -263,7 +269,7 @@ export default function Chat() {
|
|||||||
// so we force logout unconditionally — the new session will reconnect cleanly)
|
// so we force logout unconditionally — the new session will reconnect cleanly)
|
||||||
localStorage.removeItem('tc_token');
|
localStorage.removeItem('tc_token');
|
||||||
sessionStorage.removeItem('tc_token');
|
sessionStorage.removeItem('tc_token');
|
||||||
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
|
window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Online presence
|
// Online presence
|
||||||
@@ -320,7 +326,7 @@ export default function Chat() {
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setShowSidebar(false);
|
setShowSidebar(false);
|
||||||
// Push a history entry so swipe-back returns to sidebar instead of exiting the app
|
// Push a history entry so swipe-back returns to sidebar instead of exiting the app
|
||||||
window.history.pushState({ jamaChatOpen: true }, '');
|
window.history.pushState({ rosterchirpChatOpen: true }, '');
|
||||||
}
|
}
|
||||||
// Clear notifications and unread count for this group
|
// Clear notifications and unread count for this group
|
||||||
setNotifications(prev => prev.filter(n => n.groupId !== id));
|
setNotifications(prev => prev.filter(n => n.groupId !== id));
|
||||||
@@ -334,7 +340,7 @@ export default function Chat() {
|
|||||||
setShowSidebar(true);
|
setShowSidebar(true);
|
||||||
setActiveGroupId(null);
|
setActiveGroupId(null);
|
||||||
// Push another entry so subsequent back gestures are also intercepted
|
// Push another entry so subsequent back gestures are also intercepted
|
||||||
window.history.pushState({ jamaChatOpen: true }, '');
|
window.history.pushState({ rosterchirpChatOpen: true }, '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PLANS = [
|
const PLANS = [
|
||||||
{ value: 'chat', label: 'JAMA-Chat', desc: 'Chat only' },
|
{ value: 'chat', label: 'RosterChirp-Chat', desc: 'Chat only' },
|
||||||
{ value: 'brand', label: 'JAMA-Brand', desc: 'Chat + Branding' },
|
{ value: 'brand', label: 'RosterChirp-Brand', desc: 'Chat + Branding' },
|
||||||
{ value: 'team', label: 'JAMA-Team', desc: 'Chat + Branding + Groups + Schedule' },
|
{ value: 'team', label: 'RosterChirp-Team', desc: 'Chat + Branding + Groups + Schedule' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PLAN_BADGE = {
|
const PLAN_BADGE = {
|
||||||
@@ -393,7 +393,7 @@ function KeyEntry({ onSubmit }) {
|
|||||||
headers: { 'X-Host-Admin-Key': key.trim() },
|
headers: { 'X-Host-Admin-Key': key.trim() },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
sessionStorage.setItem('jama-host-key', key.trim());
|
sessionStorage.setItem('rosterchirp-host-key', key.trim());
|
||||||
onSubmit(key.trim());
|
onSubmit(key.trim());
|
||||||
} else {
|
} else {
|
||||||
setError('Invalid admin key');
|
setError('Invalid admin key');
|
||||||
@@ -406,7 +406,7 @@ function KeyEntry({ onSubmit }) {
|
|||||||
<div style={{ background: '#fff', borderRadius: 12, padding: 40, width: '100%', maxWidth: 380,
|
<div style={{ background: '#fff', borderRadius: 12, padding: 40, width: '100%', maxWidth: 380,
|
||||||
boxShadow: '0 2px 16px rgba(0,0,0,0.12)', textAlign: 'center' }}>
|
boxShadow: '0 2px 16px rgba(0,0,0,0.12)', textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🏠</div>
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🏠</div>
|
||||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: '0 0 4px' }}>JAMA-HOST</h1>
|
<h1 style={{ fontSize: 20, fontWeight: 700, margin: '0 0 4px' }}>RosterChirp-Host</h1>
|
||||||
<p style={{ color: '#5f6368', fontSize: 13, margin: '0 0 24px' }}>Host Administration Panel</p>
|
<p style={{ color: '#5f6368', fontSize: 13, margin: '0 0 24px' }}>Host Administration Panel</p>
|
||||||
{error && <div style={{ padding: '8px 12px', background: '#fce8e6', color: '#d93025',
|
{error && <div style={{ padding: '8px 12px', background: '#fce8e6', color: '#d93025',
|
||||||
borderRadius: 6, fontSize: 13, marginBottom: 16 }}>{error}</div>}
|
borderRadius: 6, fontSize: 13, marginBottom: 16 }}>{error}</div>}
|
||||||
@@ -428,7 +428,7 @@ function KeyEntry({ onSubmit }) {
|
|||||||
// ── Main host admin panel ─────────────────────────────────────────────────────
|
// ── Main host admin panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function HostAdmin() {
|
export default function HostAdmin() {
|
||||||
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || '');
|
const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('rosterchirp-host-key') || '');
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
const [tenants, setTenants] = useState([]);
|
const [tenants, setTenants] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -454,7 +454,7 @@ export default function HostAdmin() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
if (e.message.includes('Invalid') || e.message.includes('401')) {
|
if (e.message.includes('Invalid') || e.message.includes('401')) {
|
||||||
sessionStorage.removeItem('jama-host-key');
|
sessionStorage.removeItem('rosterchirp-host-key');
|
||||||
setAdminKey('');
|
setAdminKey('');
|
||||||
}
|
}
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
@@ -479,7 +479,7 @@ export default function HostAdmin() {
|
|||||||
!search || t.name.toLowerCase().includes(search.toLowerCase()) ||
|
!search || t.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
t.slug.toLowerCase().includes(search.toLowerCase())
|
t.slug.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
const baseDomain = status?.baseDomain || 'jamachat.com';
|
const baseDomain = status?.baseDomain || 'rosterchirp.com';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: '#f1f3f4', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }}>
|
<div style={{ minHeight: '100vh', background: '#f1f3f4', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }}>
|
||||||
@@ -489,7 +489,7 @@ export default function HostAdmin() {
|
|||||||
justifyContent: 'space-between', height: 56 }}>
|
justifyContent: 'space-between', height: 56 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{ fontSize: 20 }}>🏠</span>
|
<span style={{ fontSize: 20 }}>🏠</span>
|
||||||
<span style={{ fontWeight: 700, fontSize: 16 }}>JAMA-HOST</span>
|
<span style={{ fontWeight: 700, fontSize: 16 }}>RosterChirp-Host</span>
|
||||||
<span style={{ opacity: 0.7, fontSize: 13 }}>/ {baseDomain}</span>
|
<span style={{ opacity: 0.7, fontSize: 13 }}>/ {baseDomain}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
@@ -498,7 +498,7 @@ export default function HostAdmin() {
|
|||||||
{status.tenants.active} active · {status.tenants.total} total
|
{status.tenants.active} active · {status.tenants.total} total
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Btn size="sm" variant="secondary" onClick={() => { sessionStorage.removeItem('jama-host-key'); setAdminKey(''); }}>
|
<Btn size="sm" variant="secondary" onClick={() => { sessionStorage.removeItem('rosterchirp-host-key'); setAdminKey(''); }}>
|
||||||
Sign Out
|
Sign Out
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +581,7 @@ export default function HostAdmin() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div style={{ textAlign: 'center', marginTop: 24, fontSize: 12, color: '#9aa0a6' }}>
|
<div style={{ textAlign: 'center', marginTop: 24, fontSize: 12, color: '#9aa0a6' }}>
|
||||||
JAMA-HOST Control Plane · {baseDomain}
|
RosterChirp-Host Control Plane · {baseDomain}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const appName = settings.app_name || 'jama';
|
const appName = settings.app_name || 'rosterchirp';
|
||||||
const logoUrl = settings.logo_url;
|
const logoUrl = settings.logo_url;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -77,7 +77,7 @@ export default function Login() {
|
|||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt={appName} className="logo-img" />
|
<img src={logoUrl} alt={appName} className="logo-img" />
|
||||||
) : (
|
) : (
|
||||||
<img src="/icons/jama.png" alt="jama" className="logo-img" />
|
<img src="/icons/rosterchirp.png" alt="rosterchirp" className="logo-img" />
|
||||||
)}
|
)}
|
||||||
<h1>{appName}</h1>
|
<h1>{appName}</h1>
|
||||||
<p>Sign in to continue</p>
|
<p>Sign in to continue</p>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function req(method, path, body, opts = {}) {
|
|||||||
if (res.status === 401 && data.error?.includes('Session expired')) {
|
if (res.status === 401 && data.error?.includes('Session expired')) {
|
||||||
localStorage.removeItem('tc_token');
|
localStorage.removeItem('tc_token');
|
||||||
sessionStorage.removeItem('tc_token');
|
sessionStorage.removeItem('tc_token');
|
||||||
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
|
window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
|
||||||
}
|
}
|
||||||
throw new Error(data.error || 'Request failed');
|
throw new Error(data.error || 'Request failed');
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ export const api = {
|
|||||||
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
|
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
|
||||||
importPreview: (file) => {
|
importPreview: (file) => {
|
||||||
const fd = new FormData(); fd.append('file', file);
|
const fd = new FormData(); fd.append('file', file);
|
||||||
return fetch('/api/schedule/import/preview', { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('jama-token') }, body: fd }).then(r => r.json());
|
return fetch('/api/schedule/import/preview', { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('tc_token') }, body: fd }).then(r => r.json());
|
||||||
},
|
},
|
||||||
importConfirm: (rows) => req('POST', '/schedule/import/confirm', { rows }),
|
importConfirm: (rows) => req('POST', '/schedule/import/confirm', { rows }),
|
||||||
|
|
||||||
@@ -166,16 +166,11 @@ export const api = {
|
|||||||
},
|
},
|
||||||
resetSettings: () => req('POST', '/settings/reset'),
|
resetSettings: () => req('POST', '/settings/reset'),
|
||||||
|
|
||||||
// Push notifications
|
// Push notifications (FCM)
|
||||||
getPushKey: () => req('GET', '/push/vapid-public'),
|
getFirebaseConfig: () => req('GET', '/push/firebase-config'),
|
||||||
subscribePush: (sub) => req('POST', '/push/subscribe', sub),
|
subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }),
|
||||||
unsubscribePush: (endpoint) => req('POST', '/push/unsubscribe', { endpoint }),
|
unsubscribePush: () => req('POST', '/push/unsubscribe'),
|
||||||
|
|
||||||
// Link preview
|
// Link preview
|
||||||
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
|
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// VAPID key management (admin only)
|
|
||||||
generateVapidKeys: () => req('POST', '/push/generate-vapid'),
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user