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 @@ - + - + - TeamChat + jama
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..438f460 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png index 7887c7e..b0a2c1f 100644 Binary files a/frontend/public/icons/icon-192.png and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png index b0b341a..57f160e 100644 Binary files a/frontend/public/icons/icon-512.png and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/public/icons/jama.png b/frontend/public/icons/jama.png new file mode 100644 index 0000000..8874c29 Binary files /dev/null and b/frontend/public/icons/jama.png differ diff --git a/frontend/public/icons/logo-64.png b/frontend/public/icons/logo-64.png new file mode 100644 index 0000000..2cfcf91 Binary files /dev/null and b/frontend/public/icons/logo-64.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index a0c2d06..c2776a0 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,6 +1,6 @@ { - "name": "TeamChat", - "short_name": "TeamChat", + "name": "jama", + "short_name": "jama", "description": "Modern team messaging application", "start_url": "/", "scope": "/", diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 9b9de64..63066d8 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'teamchat-v2'; +const CACHE_NAME = 'jama-v1'; const STATIC_ASSETS = ['/']; self.addEventListener('install', (event) => { @@ -47,7 +47,7 @@ self.addEventListener('push', (event) => { icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', data: { url: data.url || '/' }, - tag: 'teamchat-message', // replaces previous notification instead of stacking + tag: 'jama-message', // replaces previous notification instead of stacking renotify: true, // still vibrate/sound even if replacing }) ); diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx index 3bcd600..162a547 100644 --- a/frontend/src/components/ChatWindow.jsx +++ b/frontend/src/components/ChatWindow.jsx @@ -29,8 +29,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) { setIconGroupInfo(settings.icon_groupinfo || ''); }).catch(() => {}); const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {}); - window.addEventListener('teamchat:settings-changed', handler); - return () => window.removeEventListener('teamchat:settings-changed', handler); + window.addEventListener('jama:settings-changed', handler); + return () => window.removeEventListener('jama:settings-changed', handler); }, []); const scrollToBottom = useCallback((smooth = false) => { @@ -172,15 +172,15 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) { ) : null} - {group.type === 'public' ? 'Public channel' : 'Private group'} + {group.type === 'public' ? 'Public group' : 'Private group'} diff --git a/frontend/src/components/GroupInfoModal.jsx b/frontend/src/components/GroupInfoModal.jsx index 3a011cd..57bb72c 100644 --- a/frontend/src/components/GroupInfoModal.jsx +++ b/frontend/src/components/GroupInfoModal.jsx @@ -71,6 +71,15 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) { } catch (e) { toast(e.message, 'error'); } }; + const handleRemove = async (member) => { + if (!confirm(`Remove ${member.display_name || member.name} from this group?`)) return; + try { + await api.removeMember(group.id, member.id); + toast(`${member.display_name || member.name} removed`, 'success'); + setMembers(prev => prev.filter(m => m.id !== member.id)); + } catch (e) { toast(e.message, 'error'); } + }; + const handleDelete = async () => { if (!confirm('Delete this group? This cannot be undone.')) return; try { @@ -125,10 +134,28 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
{members.map(m => ( -
+
{m.display_name || m.name} {m.id === group.owner_id && Owner} + {canManage && m.id !== group.owner_id && ( + + )}
))}
diff --git a/frontend/src/components/Message.jsx b/frontend/src/components/Message.jsx index 8a96e2b..92fbf96 100644 --- a/frontend/src/components/Message.jsx +++ b/frontend/src/components/Message.jsx @@ -31,7 +31,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl const canDelete = ( msg.user_id === currentUser.id || - (currentUser.role === 'admin' && msg.group_type !== 'private') || + currentUser.role === 'admin' || (msg.group_owner_id === currentUser.id) ); diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx index e1761ac..2965f47 100644 --- a/frontend/src/components/SettingsModal.jsx +++ b/frontend/src/components/SettingsModal.jsx @@ -2,51 +2,6 @@ import { useState, useEffect } from 'react'; import { api } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; -function IconUploadRow({ label, settingKey, currentUrl, onUploaded, defaultSvg }) { - const toast = useToast(); - - const handleUpload = async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - if (file.size > 1024 * 1024) return toast(`${label} icon must be less than 1MB`, 'error'); - try { - let result; - if (settingKey === 'icon_newchat') result = await api.uploadIconNewChat(file); - else result = await api.uploadIconGroupInfo(file); - onUploaded(settingKey, result.iconUrl); - toast(`${label} icon updated`, 'success'); - } catch (e) { - toast(e.message, 'error'); - } - }; - - return ( -
-
- {currentUrl ? ( - {label} - ) : ( - {defaultSvg} - )} -
-
-
{label}
- - {currentUrl && ( - Custom icon active - )} -
-
- ); -} - export default function SettingsModal({ onClose }) { const toast = useToast(); const [settings, setSettings] = useState({}); @@ -58,11 +13,11 @@ export default function SettingsModal({ onClose }) { useEffect(() => { api.getSettings().then(({ settings }) => { setSettings(settings); - setAppName(settings.app_name || 'TeamChat'); + setAppName(settings.app_name || 'jama'); }).catch(() => {}); }, []); - const notifySidebarRefresh = () => window.dispatchEvent(new Event('teamchat:settings-changed')); + const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed')); const handleSaveName = async () => { if (!appName.trim()) return; @@ -93,18 +48,13 @@ export default function SettingsModal({ onClose }) { } }; - const handleIconUploaded = (key, url) => { - setSettings(prev => ({ ...prev, [key]: url })); - notifySidebarRefresh(); - }; - const handleReset = async () => { setResetting(true); try { await api.resetSettings(); const { settings: fresh } = await api.getSettings(); setSettings(fresh); - setAppName(fresh.app_name || 'TeamChat'); + setAppName(fresh.app_name || 'jama'); toast('Settings reset to defaults', 'success'); notifySidebarRefresh(); setShowResetConfirm(false); @@ -115,18 +65,6 @@ export default function SettingsModal({ onClose }) { } }; - const newChatSvg = ( - - - - - ); - const groupInfoSvg = ( - - - - ); - return (
e.target === e.currentTarget && onClose()}>
@@ -146,23 +84,20 @@ export default function SettingsModal({ onClose }) { border: '1px solid var(--border)', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> - {settings.logo_url ? ( - logo - ) : ( - - - - - - - )} + logo
-

Square format, max 1MB. Used in sidebar, login page and browser tab.

+

+ Square format, max 1MB. Used in sidebar, login page and browser tab. +

@@ -178,58 +113,37 @@ export default function SettingsModal({ onClose }) { - {/* Custom Icons */} -
-
Interface Icons
- - -
- {/* Reset + Version */}
Reset
- {!showResetConfirm ? ( - - ) : ( -
-

- This will reset the app name, logo, and all custom icons to their install defaults. This cannot be undone. -

-
- - + {!showResetConfirm ? ( + + ) : ( +
+

+ This will reset the app name and logo to their install defaults. This cannot be undone. +

+
+ + +
-
- )} - {settings.app_version && ( - - v{settings.app_version} - - )} -
{/* end flex row */} -
{/* end Reset section */} + )} + {settings.app_version && ( + + v{settings.app_version} + + )} +
+ {settings.pw_reset_active === 'true' && (
diff --git a/frontend/src/components/Sidebar.css b/frontend/src/components/Sidebar.css index ab9d016..9fb7e7c 100644 --- a/frontend/src/components/Sidebar.css +++ b/frontend/src/components/Sidebar.css @@ -32,7 +32,7 @@ width: 8px; height: 8px; border-radius: 50%; - background: var(--text-tertiary); + background: #e53935; } .sidebar-search { @@ -188,14 +188,25 @@ flex-shrink: 0; } -.sidebar-logo-default { - width: 56px; - height: 56px; - flex-shrink: 0; -} -.sidebar-logo-default svg { - width: 56px; - height: 56px; - display: block; +/* Unread message indicator */ +.group-item.has-unread { + background: var(--primary-light); +} +.unread-name { + font-weight: 700; + color: var(--text-primary) !important; +} +.badge-unread { + background: var(--text-secondary); + color: white; + font-size: 11px; + font-weight: 600; + min-width: 18px; + height: 18px; + border-radius: 9px; + padding: 0 5px; + display: flex; + align-items: center; + justify-content: center; } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index ad0d587..e3e715f 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -6,8 +6,19 @@ import { useToast } from '../contexts/ToastContext.jsx'; import Avatar from './Avatar.jsx'; import './Sidebar.css'; +function useTheme() { + const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark'); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); + localStorage.setItem('jama-theme', dark ? 'dark' : 'light'); + }, [dark]); + + return [dark, setDark]; +} + function useAppSettings() { - const [settings, setSettings] = useState({ app_name: 'TeamChat', logo_url: '' }); + const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' }); const fetchSettings = () => { api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); @@ -16,20 +27,20 @@ function useAppSettings() { useEffect(() => { fetchSettings(); // Re-fetch when settings are saved from the SettingsModal - window.addEventListener('teamchat:settings-changed', fetchSettings); - return () => window.removeEventListener('teamchat:settings-changed', fetchSettings); + window.addEventListener('jama:settings-changed', fetchSettings); + return () => window.removeEventListener('jama:settings-changed', fetchSettings); }, []); // Update page title and favicon whenever settings change useEffect(() => { - const name = settings.app_name || 'TeamChat'; + const name = settings.app_name || 'jama'; // Update document.title = name; // Update favicon const logoUrl = settings.logo_url; - const faviconUrl = logoUrl || '/logo.svg'; + const faviconUrl = logoUrl || '/icons/jama.png'; let link = document.querySelector("link[rel~='icon']"); if (!link) { link = document.createElement('link'); @@ -42,15 +53,16 @@ function useAppSettings() { return settings; } -export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Set(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) { +export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) { const { user, logout } = useAuth(); const { connected } = useSocket(); const toast = useToast(); const [search, setSearch] = useState(''); const [showMenu, setShowMenu] = useState(false); const settings = useAppSettings(); + const [dark, setDark] = useTheme(); - const appName = settings.app_name || 'TeamChat'; + const appName = settings.app_name || 'jama'; const logoUrl = settings.logo_url; const allGroups = [ @@ -73,7 +85,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica const GroupItem = ({ group }) => { const notifs = getNotifCount(group.id); - const hasUnread = unreadGroups.has(group.id); + const unreadCount = unreadGroups.get(group.id) || 0; + const hasUnread = unreadCount > 0; const isActive = group.id === activeGroupId; return ( @@ -93,7 +106,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica {group.last_message || (group.is_readonly ? 'πŸ“’ Read-only' : 'No messages yet')} </span> {notifs > 0 && <span className="badge shrink-0">{notifs}</span>} - {hasUnread && notifs === 0 && <span className="unread-dot shrink-0" />} + {hasUnread && notifs === 0 && <span className="badge badge-unread shrink-0">{unreadCount}</span>} </div> </div> </div> @@ -108,14 +121,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica {logoUrl ? ( <img src={logoUrl} alt={appName} className="sidebar-logo" /> ) : ( - <div className="sidebar-logo-default"> - <svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> - <circle cx="24" cy="24" r="24" fill="#1a73e8"/> - <path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/> - <circle cx="36" cy="32" r="8" fill="#34a853"/> - <path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> - </svg> - </div> + <img src="/icons/jama.png" alt="jama" className="sidebar-logo" /> )} <h2 className="sidebar-title truncate">{appName}</h2> {!connected && <span className="offline-dot" title="Offline" />} @@ -124,9 +130,9 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica {settings.icon_newchat ? ( <img src={settings.icon_newchat} alt="New Chat" style={{ width: 20, height: 20, objectFit: 'contain' }} /> ) : ( - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30"> - <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /> - </svg> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30"> + <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /> + </svg> )} </button> </div> @@ -145,7 +151,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica <div className="groups-list"> {publicFiltered.length > 0 && ( <div className="group-section"> - <div className="section-label">CHANNELS</div> + <div className="section-label">PUBLIC MESSAGES</div> {publicFiltered.map(g => <GroupItem key={g.id} group={g} />)} </div> )} @@ -166,16 +172,40 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica {/* User footer */} <div className="sidebar-footer"> - <button className="user-footer-btn" onClick={() => setShowMenu(!showMenu)}> - <Avatar user={user} size="sm" /> - <div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}> - <span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span> - <span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span> - </div> - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> - <circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/> - </svg> - </button> + <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> + <button className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}> + <Avatar user={user} size="sm" /> + <div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}> + <span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span> + <span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span> + </div> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/> + </svg> + </button> + <button + className="btn-icon" + onClick={() => setDark(d => !d)} + title={dark ? 'Switch to light mode' : 'Switch to dark mode'} + style={{ flexShrink: 0, padding: 8 }} + > + {dark ? ( + /* Sun icon β€” click to go light */ + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> + ) : ( + /* Moon icon β€” click to go dark */ + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> + )} + </button> + </div> {showMenu && ( <div className="footer-menu" onClick={() => setShowMenu(false)}> diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 8518ceb..561a668 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -46,6 +46,16 @@ export function AuthProvider({ children }) { setMustChangePassword(false); }; + // Listen for session displacement (another device logged in) + useEffect(() => { + const handler = () => { + setUser(null); + setMustChangePassword(false); + }; + window.addEventListener('jama:session-displaced', handler); + return () => window.removeEventListener('jama:session-displaced', handler); + }, []); + const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates })); return ( diff --git a/frontend/src/index.css b/frontend/src/index.css index df71973..b13b0f5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -197,3 +197,49 @@ a { color: inherit; text-decoration: none; } color: var(--text-tertiary); margin-bottom: 12px; } + +/* ── Dark mode ─────────────────────────────────────────── */ +[data-theme="dark"] { + --primary: #6ab0f5; + --primary-dark: #4d9de0; + --primary-light: #1a2d4a; + --surface: #1e1e2e; + --surface-variant: #252535; + --background: #13131f; + --border: #2e2e45; + --text-primary: #e2e2f0; + --text-secondary: #9898b8; + --text-tertiary: #6060808; + --bubble-out: #4d8fd4; + --bubble-in: #252535; +} +[data-theme="dark"] body, +[data-theme="dark"] html, +[data-theme="dark"] #root { background: var(--background); } +[data-theme="dark"] .modal { background: var(--surface); } +[data-theme="dark"] .footer-menu { background: var(--surface); } +[data-theme="dark"] .sidebar { background: var(--surface); } +[data-theme="dark"] .chat-window { background: var(--background); } +[data-theme="dark"] .chat-header { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .messages-container { background: var(--background); } +[data-theme="dark"] .input { background: var(--surface-variant); border-color: var(--border); color: var(--text-primary); } +[data-theme="dark"] .card { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .message-input-area { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .message-input-wrap { background: var(--surface-variant); border-color: var(--border); } +[data-theme="dark"] .btn-secondary { border-color: var(--border); color: var(--primary); } +[data-theme="dark"] .btn-secondary:hover { background: var(--primary-light); } +[data-theme="dark"] .search-input { background: var(--surface-variant); color: var(--text-primary); } +[data-theme="dark"] .group-item:hover { background: var(--surface-variant); } +[data-theme="dark"] .group-item.active { background: var(--primary-light); } +[data-theme="dark"] .user-footer-btn:hover { background: var(--surface-variant); } +[data-theme="dark"] .footer-menu-item:hover { background: var(--surface-variant); } +[data-theme="dark"] .footer-menu-item.danger:hover { background: #3a1a1a; } +[data-theme="dark"] .btn-icon { color: var(--text-primary); } +[data-theme="dark"] .btn-icon:hover { background: var(--surface-variant); } +[data-theme="dark"] .msg-actions { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .reaction-btn:hover { background: var(--surface-variant); } +[data-theme="dark"] .emoji-picker-wrap { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .reply-preview { background: var(--surface-variant); border-color: var(--primary); } +[data-theme="dark"] .load-more-btn { background: var(--surface-variant); color: var(--text-secondary); } +[data-theme="dark"] .readonly-bar { background: var(--surface); border-color: var(--border); color: var(--text-secondary); } +[data-theme="dark"] .warning-banner { background: #2a1f00; border-color: #6a4a00; color: #ffb74d; } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 0a37a6c..660b8a5 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,6 +3,10 @@ import ReactDOM from 'react-dom/client'; import App from './App.jsx'; import './index.css'; +// Apply saved theme immediately to avoid flash of wrong theme +const savedTheme = localStorage.getItem('jama-theme') || 'light'; +document.documentElement.setAttribute('data-theme', savedTheme); + // Register service worker if ('serviceWorker' in navigator) { window.addEventListener('load', () => { diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx index b61c90c..512a1ad 100644 --- a/frontend/src/pages/Chat.jsx +++ b/frontend/src/pages/Chat.jsx @@ -28,7 +28,7 @@ export default function Chat() { const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] }); const [activeGroupId, setActiveGroupId] = useState(null); const [notifications, setNotifications] = useState([]); - const [unreadGroups, setUnreadGroups] = useState(new Set()); + const [unreadGroups, setUnreadGroups] = useState(new Map()); const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [showSidebar, setShowSidebar] = useState(true); @@ -89,6 +89,7 @@ export default function Chat() { if (!socket) return; const handleNewMsg = (msg) => { + // Update group preview text setGroups(prev => { const updateGroup = (g) => g.id === msg.group_id ? { ...g, last_message: msg.content || (msg.image_url ? 'πŸ“· Image' : ''), last_message_at: msg.created_at } @@ -98,15 +99,23 @@ export default function Chat() { privateGroups: prev.privateGroups.map(updateGroup), }; }); + // Increment unread count for the group if not currently viewing it + setUnreadGroups(prev => { + if (msg.group_id === activeGroupId) return prev; + const next = new Map(prev); + next.set(msg.group_id, (next.get(msg.group_id) || 0) + 1); + return next; + }); }; const handleNotification = (notif) => { if (notif.type === 'private_message') { - // Show unread dot on private group in sidebar (if not currently viewing it) + // Private message unread is already handled by handleNewMsg above + // (kept for push notification path when socket is not the source) setUnreadGroups(prev => { if (notif.groupId === activeGroupId) return prev; - const next = new Set(prev); - next.add(notif.groupId); + const next = new Map(prev); + next.set(notif.groupId, (next.get(notif.groupId) || 0) + 1); return next; }); } else { @@ -127,9 +136,9 @@ export default function Chat() { const selectGroup = (id) => { setActiveGroupId(id); if (isMobile) setShowSidebar(false); - // Clear notifications for this group + // Clear notifications and unread count for this group setNotifications(prev => prev.filter(n => n.groupId !== id)); - setUnreadGroups(prev => { const next = new Set(prev); next.delete(id); return next; }); + setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; }); }; const activeGroup = [ diff --git a/frontend/src/pages/Login.css b/frontend/src/pages/Login.css index 0c2f5d2..0ec2d29 100644 --- a/frontend/src/pages/Login.css +++ b/frontend/src/pages/Login.css @@ -29,12 +29,6 @@ margin-bottom: 16px; } -.default-logo svg { - width: 72px; - height: 72px; - margin-bottom: 16px; -} - .login-logo h1 { font-size: 28px; font-weight: 700; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 6d3a53c..69dde75 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -66,7 +66,7 @@ export default function Login() { } }; - const appName = settings.app_name || 'TeamChat'; + const appName = settings.app_name || 'jama'; const logoUrl = settings.logo_url; return ( @@ -76,14 +76,7 @@ export default function Login() { {logoUrl ? ( <img src={logoUrl} alt={appName} className="logo-img" /> ) : ( - <div className="default-logo"> - <svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> - <circle cx="24" cy="24" r="24" fill="#1a73e8"/> - <path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/> - <circle cx="36" cy="32" r="8" fill="#34a853"/> - <path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> - </svg> - </div> + <img src="/icons/jama.png" alt="jama" className="logo-img" /> )} <h1>{appName}</h1> <p>Sign in to continue</p> 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}`),