version 0.0.24

This commit is contained in:
2026-03-06 22:37:48 -05:00
parent 4517746692
commit edbee5c8ef
35 changed files with 743 additions and 372 deletions

View File

@@ -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

View File

@@ -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}

492
README.md
View File

@@ -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

View File

@@ -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}`);
});

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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 });
});

View File

@@ -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();

View File

@@ -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' });

View File

@@ -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
);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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"

View File

@@ -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

View File

@@ -2,13 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/png" href="/icons/jama.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a73e8" />
<meta name="description" content="TeamChat - Modern team messaging" />
<meta name="description" content="jama - just another messaging app" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>TeamChat</title>
<title>jama</title>
</head>
<body>
<div id="root"></div>

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 B

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "TeamChat",
"short_name": "TeamChat",
"name": "jama",
"short_name": "jama",
"description": "Modern team messaging application",
"start_url": "/",
"scope": "/",

View File

@@ -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
})
);

View File

@@ -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}
</div>
<span className="chat-header-sub">
{group.type === 'public' ? 'Public channel' : 'Private group'}
{group.type === 'public' ? 'Public group' : 'Private group'}
</span>
</div>
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info">
{iconGroupInfo ? (
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="24" height="24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="24" height="24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
</svg>
)}
</button>

View File

@@ -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 }) {
</div>
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
{members.map(m => (
<div key={m.id} className="flex items-center gap-2" style={{ gap: 10, padding: '6px 0' }}>
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
<Avatar user={m} size="sm" />
<span className="flex-1 text-sm">{m.display_name || m.name}</span>
{m.id === group.owner_id && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
{canManage && m.id !== group.owner_id && (
<button
onClick={() => handleRemove(m)}
title="Remove from group"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4,
lineHeight: 1, fontSize: 16,
transition: 'color var(--transition)',
}}
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
)}
</div>
))}
</div>

View File

@@ -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)
);

View File

@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
<div style={{
width: 48, height: 48, borderRadius: 10, background: 'var(--background)',
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0
}}>
{currentUrl ? (
<img src={currentUrl} alt={label} style={{ width: 32, height: 32, objectFit: 'contain' }} />
) : (
<span style={{ opacity: 0.35 }}>{defaultSvg}</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{label}</div>
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
Upload PNG
<input type="file" accept="image/png,image/svg+xml,image/*" style={{ display: 'none' }} onChange={handleUpload} />
</label>
{currentUrl && (
<span style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-tertiary)' }}>Custom icon active</span>
)}
</div>
</div>
);
}
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 = (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
</svg>
);
const groupInfoSvg = (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 460 }}>
@@ -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 ? (
<img src={settings.logo_url} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<svg viewBox="0 0 48 48" fill="none" style={{ width: 48, height: 48 }}>
<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>
)}
<img
src={settings.logo_url || '/icons/jama.png'}
alt="logo"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
<div>
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
Upload Logo
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
</label>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>Square format, max 1MB. Used in sidebar, login page and browser tab.</p>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>
Square format, max 1MB. Used in sidebar, login page and browser tab.
</p>
</div>
</div>
</div>
@@ -178,25 +113,6 @@ export default function SettingsModal({ onClose }) {
</div>
</div>
{/* Custom Icons */}
<div style={{ marginBottom: 24 }}>
<div className="settings-section-label">Interface Icons</div>
<IconUploadRow
label="New Chat Button"
settingKey="icon_newchat"
currentUrl={settings.icon_newchat}
onUploaded={handleIconUploaded}
defaultSvg={newChatSvg}
/>
<IconUploadRow
label="Group Info Button"
settingKey="icon_groupinfo"
currentUrl={settings.icon_groupinfo}
onUploaded={handleIconUploaded}
defaultSvg={groupInfoSvg}
/>
</div>
{/* Reset + Version */}
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
<div className="settings-section-label">Reset</div>
@@ -211,15 +127,13 @@ export default function SettingsModal({ onClose }) {
borderRadius: 'var(--radius)', padding: '12px 14px'
}}>
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
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.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>Cancel</button>
</div>
</div>
)}
@@ -228,8 +142,8 @@ export default function SettingsModal({ onClose }) {
v{settings.app_version}
</span>
)}
</div>{/* end flex row */}
</div>{/* end Reset section */}
</div>
</div>
{settings.pw_reset_active === 'true' && (
<div className="warning-banner">

View File

@@ -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;
}

View File

@@ -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 <title>
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" />}
@@ -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,7 +172,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{/* User footer */}
<div className="sidebar-footer">
<button className="user-footer-btn" onClick={() => setShowMenu(!showMenu)}>
<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>
@@ -176,6 +183,29 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
<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)}>

View File

@@ -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 (

View File

@@ -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; }

View File

@@ -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', () => {

View File

@@ -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 = [

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}`),