diff --git a/.env.example b/.env.example index d6d3db1..22baf48 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,13 @@ -# TeamChat Configuration +# jama Configuration +# just another messaging app # Copy this file to .env and customize # Image version to run (set by build.sh, or use 'latest') -TEAMCHAT_VERSION=latest +JAMA_VERSION=latest # Default admin credentials (used on FIRST RUN only) ADMIN_NAME=Admin User -ADMIN_EMAIL=admin@teamchat.local +ADMIN_EMAIL=admin@jama.local ADMIN_PASS=Admin@1234 # Set to true to reset admin password to ADMIN_PASS on every restart @@ -20,4 +21,7 @@ JWT_SECRET=changeme_super_secret_jwt_key_change_in_production PORT=3000 # App name (can also be changed in Settings UI) -APP_NAME=TeamChat + +# Default public group name (created on first run only) +DEFCHAT_NAME=General Chat +APP_NAME=jama diff --git a/Dockerfile b/Dockerfile index a3d3b6b..8b03c00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,11 +18,11 @@ FROM node:20-alpine ARG VERSION=dev ARG BUILD_DATE=unknown -LABEL org.opencontainers.image.title="TeamChat" \ +LABEL org.opencontainers.image.title="jama" \ org.opencontainers.image.description="Self-hosted team chat PWA" \ org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.created="${BUILD_DATE}" \ - org.opencontainers.image.source="https://github.com/yourorg/teamchat" + org.opencontainers.image.source="https://github.com/yourorg/jama" ENV TEAMCHAT_VERSION=${VERSION} diff --git a/README.md b/README.md index e3100bd..08e90ed 100644 --- a/README.md +++ b/README.md @@ -1,221 +1,443 @@ -# TeamChat π¬ +# jama π¬ +### *just another messaging app* -A modern, self-hosted team chat Progressive Web App (PWA) β similar to Google Messages / Facebook Messenger for teams. +A modern, self-hosted team messaging Progressive Web App (PWA) built for small to medium teams. jama runs entirely in a single Docker container with no external database dependencies β all data is stored locally using SQLite. --- ## Features -- π **Authentication** β Login, remember me, forced password change on first login -- π¬ **Real-time messaging** β WebSocket (Socket.io) powered chat -- π₯ **Public channels** β Admin-created, all users auto-joined -- π **Private groups** β User-created, owner-managed -- π· **Image uploads** β Attach images to messages -- π¬ **Message quoting** β Reply to any message with preview -- π **Emoji reactions** β Quick reactions + full emoji picker -- @**Mentions** β @mention users with autocomplete, they get notified -- π **Link previews** β Auto-fetches OG metadata for URLs -- π± **PWA** β Install to home screen, works offline -- π€ **Profiles** β Custom avatars, display names, about me -- βοΈ **Admin settings** β Custom logo, app name -- π¨βπΌ **User management** β Create, suspend, delete, bulk CSV import -- π’ **Read-only channels** β Announcement-style public channels +### Messaging +- **Real-time messaging** β WebSocket-powered (Socket.io); messages appear instantly across all clients +- **Image attachments** β Attach and send images; auto-compressed client-side before upload +- **Message replies** β Quote and reply to any message with an inline preview +- **Emoji reactions** β Quick-react with common emojis or open the full emoji picker; one reaction per user, replaceable +- **@Mentions** β Type `@` to search and tag users with autocomplete; mentioned users receive a notification +- **Link previews** β URLs are automatically expanded with Open Graph metadata (title, image, site name) +- **Typing indicators** β See when others are composing a message +- **Image lightbox** β Tap any image to open it full-screen with pinch-to-zoom support + +### Channels & Groups +- **Public channels** β Admin-created; all users are automatically added +- **Private groups / DMs** β Any user can create; membership is invite-only by the owner +- **Read-only channels** β Admin-configurable announcement-style channels; only admins can post +- **Support group** β A private admin-only group that receives submissions from the login page contact form + +### Users & Profiles +- **Authentication** β Email/password login with optional Remember Me (30-day session) +- **Forced password change** β New users must change their password on first login +- **User profiles** β Custom display name, avatar upload, About Me text +- **Profile popup** β Click any user's avatar in chat to view their profile card +- **Admin badge** β Admins display a role badge; can be hidden per-user in Profile settings + +### Notifications +- **In-app notifications** β Mention alerts with toast notifications +- **Unread indicators** β Private groups with new unread messages are highlighted and bolded in the sidebar +- **Web Push notifications** β Badge and push notifications for mentions and new private messages when the app is backgrounded or closed (requires HTTPS) + +### Admin & Settings +- **User Manager** β Create, suspend, activate, delete users; reset passwords; change roles +- **Bulk CSV import** β Import multiple users at once from a CSV file +- **App branding** β Customize app name, logo, New Chat icon, and Group Info icon via the Settings panel +- **Reset to defaults** β One-click reset of all branding customizations +- **Version display** β Current app version shown in the Settings panel + +### PWA +- **Installable** β Install to home screen on mobile and desktop via the browser install prompt +- **Dynamic app icon** β Uploaded logo is automatically resized to 192Γ192 and 512Γ512 and used as the PWA shortcut icon +- **Dynamic manifest** β App name and icons in the PWA manifest update live when changed in Settings +- **Offline fallback** β Basic offline support via service worker caching + +### Contact Form +- **Login page contact form** β A "Contact Support" button on the login page opens a form (name, email, message, math captcha) that posts directly into the admin Support group --- -## Quick Start +## Tech Stack -### Prerequisites -- Docker & Docker Compose +| Layer | Technology | +|---|---| +| Backend | Node.js, Express, Socket.io | +| Database | SQLite (better-sqlite3) | +| Frontend | React 18, Vite | +| Image processing | sharp | +| Push notifications | web-push (VAPID) | +| Containerization | Docker, Docker Compose | +| Reverse proxy / SSL | Caddy (recommended) | -### 1. Build a versioned image +--- + +## Requirements + +- **Docker** and **Docker Compose v2** +- A domain name with DNS pointed at your server (required for HTTPS and Web Push notifications) +- Ports **80** and **443** open on your server firewall (if using Caddy for SSL) + +--- + +## Building the Image + +All builds use `build.sh`. No host Node.js installation is required β `npm install` and the Vite build run inside Docker. ```bash -# Build and tag as v1.0.0 (also tags :latest) -./build.sh 1.0.0 - -# Build latest only +# Build and tag as :latest only ./build.sh -``` -### 2. Deploy with Docker Compose - -```bash -cp .env.example .env -# Edit .env β set TEAMCHAT_VERSION, admin credentials, JWT_SECRET -nano .env - -docker compose up -d - -# View logs -docker compose logs -f -``` - -App will be available at **http://localhost:3000** - ---- - -## Release Workflow - -TeamChat uses a **build-then-run** pattern. You build the image once on your build machine (or CI), then the compose file just runs the pre-built image β no build step at deploy time. - -``` -βββββββββββββββββββββββ ββββββββββββββββββββββββββββ -β Build machine / CI β β Server / Portainer β -β β β β -β ./build.sh 1.2.0 βββββββΆβ TEAMCHAT_VERSION=1.2.0 β -β (or push to β β docker compose up -d β -β registry first) β β β -βββββββββββββββββββββββ ββββββββββββββββββββββββββββ -``` - -### Build script usage - -```bash -# Build locally (image stays on this machine) +# Build and tag as a specific version (also tags :latest) ./build.sh 1.0.0 # Build and push to Docker Hub REGISTRY=yourdockerhubuser ./build.sh 1.0.0 push -# Build and push to GHCR +# Build and push to GitHub Container Registry REGISTRY=ghcr.io/yourorg ./build.sh 1.0.0 push ``` -### Deploying a specific version +After a successful build the script prints the exact `.env` and `docker compose` commands needed to deploy. -Set `TEAMCHAT_VERSION` in your `.env` before running compose: +--- + +## Installation + +### 1. Clone the repository ```bash -# .env -TEAMCHAT_VERSION=1.2.0 +git clone https://github.com/yourorg/jama.git +cd jama ``` +### 2. Build the Docker image + +```bash +./build.sh 1.0.0 +``` + +### 3. Configure environment + +```bash +cp .env.example .env +nano .env +``` + +At minimum, change `ADMIN_EMAIL`, `ADMIN_PASS`, and `JWT_SECRET`. See [Environment Variables](#environment-variables) for all options. + +### 4. Start the container + ```bash -docker compose pull # if pulling from a registry docker compose up -d + +# Follow startup logs +docker compose logs -f jama ``` -### Rolling back - -```bash -# .env -TEAMCHAT_VERSION=1.1.0 - -docker compose up -d # instantly rolls back to previous image +On first startup you should see: +``` +[DB] Default admin created: admin@yourdomain.com +[DB] Default jama group created +[DB] Support group created ``` -Data volumes are unaffected by version changes. +### 5. Log in + +Open `http://your-server:3000` in a browser, log in with your `ADMIN_EMAIL` and `ADMIN_PASS`, and change your password when prompted. + +--- + +## HTTPS & SSL (Required for Web Push and PWA install prompt) + +jama does not manage SSL itself. Use **Caddy** as a reverse proxy β it obtains and renews Let's Encrypt certificates automatically. + +### docker-compose.yaml (with Caddy) + +```yaml +version: '3.8' +services: + jama: + image: jama:${JAMA_VERSION:-latest} + container_name: jama + restart: unless-stopped + expose: + - "3000" # internal only β Caddy is the sole entry point + environment: + - NODE_ENV=production + - ADMIN_NAME=${ADMIN_NAME:-Admin User} + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local} + - ADMIN_PASS=${ADMIN_PASS:-Admin@1234} + - PW_RESET=${PW_RESET:-false} + - JWT_SECRET=${JWT_SECRET:-changeme} + - APP_NAME=${APP_NAME:-jama} + - JAMA_VERSION=${JAMA_VERSION:-latest} + volumes: + - jama_db:/app/data + - jama_uploads:/app/uploads + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + + caddy: + image: caddy:alpine + container_name: caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_certs:/config + depends_on: + - jama + +volumes: + jama_db: + jama_uploads: + caddy_data: + caddy_certs: +``` + +### Caddyfile + +Create a `Caddyfile` in the same directory as `docker-compose.yaml`: + +``` +chat.yourdomain.com { + reverse_proxy jama:3000 +} +``` + +> **Prerequisites:** Your domain's DNS A record must point to your server's public IP *before* starting Caddy, so the Let's Encrypt HTTP challenge can complete. + +--- + +## docker-compose.yaml Reference (without Caddy) + +The default `docker-compose.yaml` exposes jama directly on a host port: + +```yaml +version: '3.8' +services: + jama: + image: jama:${JAMA_VERSION:-latest} + container_name: jama + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" # change PORT in .env to use a different host port + environment: + - NODE_ENV=production + - ADMIN_NAME=${ADMIN_NAME:-Admin User} + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local} + - ADMIN_PASS=${ADMIN_PASS:-Admin@1234} + - PW_RESET=${PW_RESET:-false} + - JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024} + - APP_NAME=${APP_NAME:-jama} + volumes: + - jama_db:/app/data # SQLite database + - jama_uploads:/app/uploads # avatars, logos, message images + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + jama_db: + driver: local + jama_uploads: + driver: local +``` --- ## Environment Variables | Variable | Default | Description | -|----------|---------|-------------| -| `ADMIN_NAME` | `Admin User` | Default admin display name | -| `ADMIN_EMAIL` | `admin@teamchat.local` | Default admin email (login) | -| `ADMIN_PASS` | `Admin@1234` | Default admin password (first run only) | -| `PW_RESET` | `false` | If `true`, resets admin password to `ADMIN_PASS` on every restart | -| `JWT_SECRET` | *(insecure default)* | **Change this!** Used to sign auth tokens | -| `PORT` | `3000` | HTTP port to listen on | -| `APP_NAME` | `TeamChat` | Initial app name (can be changed in Settings) | +|---|---|---| +| `JAMA_VERSION` | `latest` | Docker image tag to run. Set by `build.sh` or manually. | +| `ADMIN_NAME` | `Admin User` | Display name of the default admin account | +| `ADMIN_EMAIL` | `admin@jama.local` | Login email for the default admin account | +| `ADMIN_PASS` | `Admin@1234` | Initial password for the default admin account | +| `PW_RESET` | `false` | If `true`, resets the admin password to `ADMIN_PASS` on every container restart. Shows a warning banner on the login page. For emergency access recovery only. | +| `JWT_SECRET` | *(insecure default)* | Secret used to sign auth tokens. **Must be changed in production.** Use a long random string. | +| `PORT` | `3000` | Host port to bind (only applies when not using Caddy's `expose` setup) | +| `APP_NAME` | `jama` | Initial application name. Can also be changed at any time in the Settings UI. | -> **Important:** `ADMIN_EMAIL` and `ADMIN_PASS` are only used on the very first run to create the admin account. After the admin changes their password, these variables are ignored β **unless** `PW_RESET=true`. +> **Note:** `ADMIN_EMAIL` and `ADMIN_PASS` are only used on the **very first run** to seed the admin account. Once the database exists these values are ignored β unless `PW_RESET=true`. + +### Example `.env` + +```env +JAMA_VERSION=1.0.0 + +ADMIN_NAME=Your Name +ADMIN_EMAIL=admin@yourdomain.com +ADMIN_PASS=ChangeThisNow! + +PW_RESET=false + +JWT_SECRET=replace-this-with-a-long-random-string-at-least-32-chars + +PORT=3000 +APP_NAME=jama +``` --- -## First Login +## First Login & Setup Checklist -1. Navigate to `http://localhost:3000` -2. Login with `ADMIN_EMAIL` / `ADMIN_PASS` -3. You'll be prompted to **change your password** immediately -4. You're in! The default **TeamChat** public channel is ready - ---- - -## PW_RESET Warning - -If you set `PW_RESET=true`: -- The admin password resets to `ADMIN_PASS` on **every container restart** -- A β οΈ warning banner appears on the login page -- This is intentional for emergency access recovery -- **Always set back to `false` after recovering access** +1. Open your app URL and log in with `ADMIN_EMAIL` / `ADMIN_PASS` +2. Change your password when prompted +3. Open βοΈ **Settings** (bottom-left menu β Settings): + - Upload a custom logo + - Set the app name + - Optionally upload custom New Chat and Group Info icons +4. Open π₯ **User Manager** to create accounts for your team +5. Create public channels or let users create private groups --- ## User Management -Admins can access **User Manager** from the bottom menu: +Accessible from the bottom-left menu (admin only). -- **Create single user** β Name, email, temp password, role -- **Bulk import via CSV** β Format: `name,email,password,role` -- **Reset password** β User is forced to change on next login -- **Suspend / Activate** β Suspended users cannot login -- **Delete** β Soft delete; messages remain, sessions invalidated -- **Elevate / Demote** β Change member β admin role +| Action | Description | +|---|---| +| Create user | Set name, email, temporary password, and role | +| Bulk CSV import | Upload a CSV to create multiple users at once | +| Reset password | User is forced to set a new password on next login | +| Suspend | Blocks login; messages are preserved | +| Activate | Re-enables a suspended account | +| Delete | Removes account; messages remain attributed to user | +| Change role | Promote member β admin or demote admin β member | + +### CSV Import Format + +```csv +name,email,password,role +John Doe,john@example.com,TempPass123,member +Jane Smith,jane@example.com,Admin@456,admin +``` + +- `role` must be `member` or `admin` +- `password` is optional β defaults to `TempPass@123` if omitted +- All imported users must change their password on first login --- ## Group Types | | Public Channels | Private Groups | -|--|--|--| -| Creator | Admin only | Any user | -| Members | All users (auto) | Invited by owner | -| Visible to admins | β Yes | β No (unless admin takes ownership) | +|---|---|---| +| Who can create | Admin only | Any user | +| Membership | All users (automatic) | Invite-only by owner | +| Visible to admins | β Yes | β No | | Leave | β Not allowed | β Yes | | Rename | Admin only | Owner only | | Read-only mode | β Optional | β N/A | -| Default group | TeamChat (permanent) | β | - ---- - -## CSV Import Format - -```csv -name,email,password,role -John Doe,john@example.com,TempPass123,member -Jane Admin,jane@example.com,Admin@456,admin -``` - -- `role` can be `member` or `admin` -- `password` defaults to `TempPass@123` if omitted -- All imported users must change password on first login --- ## Data Persistence -All data is stored in Docker volumes: -- `teamchat_db` β SQLite database -- `teamchat_uploads` β User avatars, logos, message images +| Volume | Container path | Contents | +|---|---|---| +| `jama_db` | `/app/data` | SQLite database (`jama.db`) | +| `jama_uploads` | `/app/uploads` | Avatars, logos, PWA icons, message images | -Data survives container restarts and redeployments. +Both volumes survive container restarts, image upgrades, and rollbacks. + +### Backup + +```bash +# Backup database +docker run --rm \ + -v jama_db:/data \ + -v $(pwd):/backup alpine \ + tar czf /backup/jama_db_$(date +%Y%m%d).tar.gz -C /data . + +# Backup uploads +docker run --rm \ + -v jama_uploads:/data \ + -v $(pwd):/backup alpine \ + tar czf /backup/jama_uploads_$(date +%Y%m%d).tar.gz -C /data . +``` + +--- + +## Versioning, Upgrades & Rollbacks + +jama uses a build-once, deploy-anywhere pattern: + +``` +Build machine Server +./build.sh 1.1.0 β JAMA_VERSION=1.1.0 β docker compose up -d +``` + +### Upgrade + +```bash +# 1. Build new version +./build.sh 1.1.0 + +# 2. Update .env +JAMA_VERSION=1.1.0 + +# 3. Redeploy (data volumes untouched) +docker compose up -d +``` + +### Rollback + +```bash +# 1. Set previous version in .env +JAMA_VERSION=1.0.0 + +# 2. Redeploy +docker compose up -d +``` --- ## PWA Installation -On mobile: **Share β Add to Home Screen** -On desktop (Chrome): Click the install icon in the address bar +HTTPS is required for the browser install prompt to appear. + +| Platform | How to install | +|---|---| +| Android (Chrome) | Tap the install banner, or Menu β Add to Home Screen | +| iOS (Safari) | Share β Add to Home Screen | +| Desktop Chrome/Edge | Click the install icon (β) in the address bar | + +After uploading a custom logo in Settings, the PWA shortcut icon updates automatically on the next app load. --- -## Portainer / Dockhand Deployment +## PW_RESET Flag -Use the `docker-compose.yaml` directly in Portainer's Stack editor. Set environment variables in the `.env` section or directly in the compose file. +Setting `PW_RESET=true` resets the default admin password to `ADMIN_PASS` on **every container restart**. Use only for emergency access recovery. + +When active, a β οΈ warning banner is shown on the login page and in the Settings panel. + +**Always set `PW_RESET=false` and redeploy after recovering access.** --- ## Development ```bash -# Backend +# Start backend (port 3000) cd backend && npm install && npm run dev -# Frontend (in another terminal) +# Start frontend in a separate terminal (port 5173) cd frontend && npm install && npm run dev ``` -Frontend dev server proxies API calls to `localhost:3000`. +The Vite dev server proxies all `/api` and `/socket.io` requests to the backend automatically. + +--- + +## License + +MIT diff --git a/backend/src/index.js b/backend/src/index.js index c0549f4..bfa479e 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -55,7 +55,7 @@ app.get('/manifest.json', (req, res) => { const s = {}; for (const r of rows) s[r.key] = r.value; - const appName = s.app_name || process.env.APP_NAME || 'TeamChat'; + const appName = s.app_name || process.env.APP_NAME || 'jama'; const pwa192 = s.pwa_icon_192 || ''; const pwa512 = s.pwa_icon_512 || ''; @@ -104,7 +104,12 @@ io.use((socket, next) => { const db = getDb(); const user = db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active'); if (!user) return next(new Error('User not found')); + // Per-device enforcement: token must match an active session row + const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token); + if (!session) return next(new Error('Session displaced')); socket.user = user; + socket.token = token; + socket.device = session.device; next(); } catch (e) { next(new Error('Invalid token')); @@ -305,5 +310,5 @@ io.on('connection', (socket) => { }); server.listen(PORT, () => { - console.log(`TeamChat server running on port ${PORT}`); + console.log(`jama server running on port ${PORT}`); }); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index bddb8c1..a464b25 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -3,6 +3,16 @@ const { getDb } = require('../models/db'); const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret'; +// Classify a User-Agent string into 'mobile' or 'desktop'. +// Tablets are treated as mobile (one shared slot). +function getDeviceClass(ua) { + if (!ua) return 'desktop'; + const s = ua.toLowerCase(); + if (/mobile|android(?!.*tablet)|iphone|ipod|blackberry|windows phone|opera mini|silk/.test(s)) return 'mobile'; + if (/tablet|ipad|kindle|playbook|android/.test(s)) return 'mobile'; + return 'desktop'; +} + function authMiddleware(req, res, next) { const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token; if (!token) return res.status(401).json({ error: 'Unauthorized' }); @@ -12,7 +22,16 @@ function authMiddleware(req, res, next) { const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active'); if (!user) return res.status(401).json({ error: 'User not found or suspended' }); + + // Per-device enforcement: token must match an active session row + const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token); + if (!session) { + return res.status(401).json({ error: 'Session expired. Please log in again.' }); + } + req.user = user; + req.token = token; + req.device = session.device; next(); } catch (e) { return res.status(401).json({ error: 'Invalid token' }); @@ -28,4 +47,27 @@ function generateToken(userId) { return jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '30d' }); } -module.exports = { authMiddleware, adminMiddleware, generateToken }; +// Upsert the active session for this user+device class. +// Displaces any prior session on the same device class; the other device class is unaffected. +function setActiveSession(userId, token, userAgent) { + const db = getDb(); + const device = getDeviceClass(userAgent); + db.prepare(` + INSERT INTO active_sessions (user_id, device, token, ua, created_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id, device) DO UPDATE SET token = ?, ua = ?, created_at = datetime('now') + `).run(userId, device, token, userAgent || null, token, userAgent || null); + return device; +} + +// Clear one device slot on logout, or all slots (no device arg) for suspend/delete +function clearActiveSession(userId, device) { + const db = getDb(); + if (device) { + db.prepare('DELETE FROM active_sessions WHERE user_id = ? AND device = ?').run(userId, device); + } else { + db.prepare('DELETE FROM active_sessions WHERE user_id = ?').run(userId); + } +} + +module.exports = { authMiddleware, adminMiddleware, generateToken, setActiveSession, clearActiveSession, getDeviceClass }; diff --git a/backend/src/models/db.js b/backend/src/models/db.js index 3618289..feb221e 100644 --- a/backend/src/models/db.js +++ b/backend/src/models/db.js @@ -3,7 +3,7 @@ const path = require('path'); const fs = require('fs'); const bcrypt = require('bcryptjs'); -const DB_PATH = process.env.DB_PATH || '/app/data/teamchat.db'; +const DB_PATH = process.env.DB_PATH || '/app/data/jama.db'; let db; @@ -118,11 +118,21 @@ function initDb() { value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS active_sessions ( + user_id INTEGER NOT NULL, + device TEXT NOT NULL DEFAULT 'desktop', + token TEXT NOT NULL, + ua TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, device), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); `); // Initialize default settings const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); - insertSetting.run('app_name', process.env.APP_NAME || 'TeamChat'); + insertSetting.run('app_name', process.env.APP_NAME || 'jama'); insertSetting.run('logo_url', ''); insertSetting.run('pw_reset_active', process.env.PW_RESET === 'true' ? 'true' : 'false'); insertSetting.run('icon_newchat', ''); @@ -136,6 +146,26 @@ function initDb() { console.log('[DB] Migration: added hide_admin_tag column'); } catch (e) { /* column already exists */ } + // Migration: replace single-session active_sessions with per-device version + try { + const cols = db.prepare("PRAGMA table_info(active_sessions)").all().map(c => c.name); + if (!cols.includes('device')) { + db.exec("DROP TABLE IF EXISTS active_sessions"); + db.exec(` + CREATE TABLE IF NOT EXISTS active_sessions ( + user_id INTEGER NOT NULL, + device TEXT NOT NULL DEFAULT 'desktop', + token TEXT NOT NULL, + ua TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, device), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + console.log('[DB] Migration: rebuilt active_sessions for per-device sessions'); + } + } catch (e) { console.error('[DB] active_sessions migration error:', e.message); } + console.log('[DB] Schema initialized'); return db; } @@ -144,7 +174,7 @@ function seedAdmin() { const db = getDb(); // Strip any surrounding quotes from env vars (common docker-compose mistake) - const adminEmail = (process.env.ADMIN_EMAIL || 'admin@teamchat.local').replace(/^["']|["']$/g, '').trim(); + const adminEmail = (process.env.ADMIN_EMAIL || 'admin@jama.local').replace(/^["']|["']$/g, '').trim(); const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim(); const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim(); const pwReset = process.env.PW_RESET === 'true'; @@ -163,17 +193,17 @@ function seedAdmin() { console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`); - // Create default TeamChat group + // Create default public group const groupResult = db.prepare(` INSERT INTO groups (name, type, is_default, owner_id) - VALUES ('TeamChat', 'public', 1, ?) - `).run(result.lastInsertRowid); + VALUES (?, 'public', 1, ?) + `).run(process.env.DEFCHAT_NAME || 'General Chat', result.lastInsertRowid); // Add admin to default group db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)') .run(groupResult.lastInsertRowid, result.lastInsertRowid); - console.log('[DB] Default TeamChat group created'); + console.log(`[DB] Default group created: ${process.env.DEFCHAT_NAME || 'General Chat'}`); seedSupportGroup(); } catch (err) { console.error('[DB] ERROR creating default admin:', err.message); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 2707130..8a1ace5 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -2,7 +2,7 @@ const express = require('express'); const bcrypt = require('bcryptjs'); const router = express.Router(); const { getDb, getOrCreateSupportGroup } = require('../models/db'); -const { generateToken, authMiddleware } = require('../middleware/auth'); +const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth'); // Login router.post('/login', (req, res) => { @@ -25,6 +25,8 @@ router.post('/login', (req, res) => { if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); const token = generateToken(user.id); + const ua = req.headers['user-agent'] || ''; + const device = setActiveSession(user.id, token, ua); // displaces prior session on same device class const { password: _, ...userSafe } = user; res.json({ @@ -58,8 +60,9 @@ router.get('/me', authMiddleware, (req, res) => { res.json({ user }); }); -// Logout (client-side token removal, but we can track it) +// Logout β clear active session for this device class only router.post('/logout', authMiddleware, (req, res) => { + clearActiveSession(req.user.id, req.device); res.json({ success: true }); }); diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index 7634d58..11fed65 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -116,6 +116,23 @@ router.post('/:id/members', authMiddleware, (req, res) => { res.json({ success: true }); }); +// Remove a member from a private group (owner or admin only) +router.delete('/:id/members/:userId', authMiddleware, (req, res) => { + const db = getDb(); + const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); + if (!group) return res.status(404).json({ error: 'Group not found' }); + if (group.type !== 'private') return res.status(400).json({ error: 'Cannot remove members from public groups' }); + if (group.owner_id !== req.user.id && req.user.role !== 'admin') { + return res.status(403).json({ error: 'Only owner or admin can remove members' }); + } + const targetId = parseInt(req.params.userId); + if (targetId === group.owner_id) { + return res.status(400).json({ error: 'Cannot remove the group owner' }); + } + db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(group.id, targetId); + res.json({ success: true }); +}); + // Leave private group router.delete('/:id/leave', authMiddleware, (req, res) => { const db = getDb(); diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js index 6f0d92c..ead673d 100644 --- a/backend/src/routes/messages.js +++ b/backend/src/routes/messages.js @@ -141,7 +141,7 @@ router.delete('/:id', authMiddleware, (req, res) => { if (!message) return res.status(404).json({ error: 'Message not found' }); const canDelete = message.user_id === req.user.id || - (req.user.role === 'admin' && message.group_type === 'public') || + req.user.role === 'admin' || (message.group_type === 'private' && message.group_owner_id === req.user.id); if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' }); diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js index a1bb0f9..8d235b3 100644 --- a/backend/src/routes/push.js +++ b/backend/src/routes/push.js @@ -24,7 +24,7 @@ function getVapidKeys() { function initWebPush() { const keys = getVapidKeys(); webpush.setVapidDetails( - 'mailto:admin@teamchat.local', + 'mailto:admin@jama.local', keys.publicKey, keys.privateKey ); diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 86704d6..b287fe1 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -115,7 +115,7 @@ router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo. // Reset all settings to defaults (admin) router.post('/reset', authMiddleware, adminMiddleware, (req, res) => { const db = getDb(); - const originalName = process.env.APP_NAME || 'TeamChat'; + const originalName = process.env.APP_NAME || 'jama'; db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'app_name'").run(originalName); db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key = 'logo_url'").run(); db.prepare("UPDATE settings SET value = '', updated_at = datetime('now') WHERE key IN ('icon_newchat', 'icon_groupinfo', 'pwa_icon_192', 'pwa_icon_512')").run(); diff --git a/backend/src/utils/linkPreview.js b/backend/src/utils/linkPreview.js index b2ce194..a644e23 100644 --- a/backend/src/utils/linkPreview.js +++ b/backend/src/utils/linkPreview.js @@ -7,7 +7,7 @@ async function getLinkPreview(url) { const res = await fetch(url, { signal: controller.signal, - headers: { 'User-Agent': 'TeamChatBot/1.0' } + headers: { 'User-Agent': 'JamaBot/1.0' } }); clearTimeout(timeout); diff --git a/build.sh b/build.sh index a5fa269..81bf7d6 100644 --- a/build.sh +++ b/build.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ -# TeamChat β Docker build & release script +# jama β Docker build & release script # # Usage: -# ./build.sh # builds teamchat:latest -# ./build.sh 1.2.0 # builds teamchat:1.2.0 AND teamchat:latest +# ./build.sh # builds jama:latest +# ./build.sh 1.2.0 # builds jama:1.2.0 AND jama:latest # ./build.sh 1.2.0 push # builds, tags, and pushes to registry # # To push to a registry, set REGISTRY env var: @@ -16,7 +16,7 @@ set -euo pipefail VERSION="${1:-latest}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" -IMAGE_NAME="teamchat" +IMAGE_NAME="jama" # If a registry is set, prefix image name if [[ -n "$REGISTRY" ]]; then @@ -26,7 +26,7 @@ else fi echo "ββββββββββββββββββββββββββββββββββββββββ" -echo "β TeamChat Docker Builder β" +echo "β jama Docker Builder β" echo "β βββββββββββββββββββββββββββββββββββββββ£" echo "β Image : ${FULL_IMAGE}" echo "β Version : ${VERSION}" @@ -67,7 +67,7 @@ fi echo "" echo "βββββββββββββββββββββββββββββββββββββββββ" echo "To deploy this version, set in your .env:" -echo " TEAMCHAT_VERSION=${VERSION}" +echo " JAMA_VERSION=${VERSION}" echo "" echo "Then run:" echo " docker compose up -d" diff --git a/docker-compose.yaml b/docker-compose.yaml index 5a9757a..43355cb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,23 +1,24 @@ version: '3.8' services: - teamchat: - image: teamchat:${TEAMCHAT_VERSION:-latest} - container_name: teamchat + jama: + image: jama:${JAMA_VERSION:-latest} + container_name: jama restart: unless-stopped ports: - "${PORT:-3000}:3000" environment: - NODE_ENV=production - ADMIN_NAME=${ADMIN_NAME:-Admin User} - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@teamchat.local} + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local} - ADMIN_PASS=${ADMIN_PASS:-Admin@1234} - PW_RESET=${PW_RESET:-false} - JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024} - - APP_NAME=${APP_NAME:-TeamChat} + - APP_NAME=${APP_NAME:-jama} + - DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat} volumes: - - teamchat_db:/app/data - - teamchat_uploads:/app/uploads + - jama_db:/app/data + - jama_uploads:/app/uploads healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] interval: 30s @@ -25,7 +26,7 @@ services: retries: 3 volumes: - teamchat_db: + jama_db: driver: local - teamchat_uploads: + jama_uploads: driver: local diff --git a/frontend/index.html b/frontend/index.html index 1898338..e865711 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,13 +2,13 @@
- + - + -Square format, max 1MB. Used in sidebar, login page and browser tab.
++ Square format, max 1MB. Used in sidebar, login page and browser tab. +
- This will reset the app name, logo, and all custom icons to their install defaults. This cannot be undone. -
-+ This will reset the app name and logo to their install defaults. This cannot be undone. +
+Sign in to continue
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index f14ad70..615db1c 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -20,7 +20,15 @@ async function req(method, path, body, opts = {}) { const res = await fetch(BASE + path, fetchOpts); const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Request failed'); + if (!res.ok) { + // Session displaced by a new login elsewhere β force logout + if (res.status === 401 && data.error?.includes('Session expired')) { + localStorage.removeItem('tc_token'); + sessionStorage.removeItem('tc_token'); + window.dispatchEvent(new CustomEvent('jama:session-displaced')); + } + throw new Error(data.error || 'Request failed'); + } return data; } @@ -54,6 +62,7 @@ export const api = { renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }), getMembers: (id) => req('GET', `/groups/${id}/members`), addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }), + removeMember: (groupId, userId) => req('DELETE', `/groups/${groupId}/members/${userId}`), leaveGroup: (id) => req('DELETE', `/groups/${id}/leave`), takeOwnership: (id) => req('POST', `/groups/${id}/take-ownership`), deleteGroup: (id) => req('DELETE', `/groups/${id}`),