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 # Copy this file to .env and customize
# Image version to run (set by build.sh, or use 'latest') # 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) # Default admin credentials (used on FIRST RUN only)
ADMIN_NAME=Admin User ADMIN_NAME=Admin User
ADMIN_EMAIL=admin@teamchat.local ADMIN_EMAIL=admin@jama.local
ADMIN_PASS=Admin@1234 ADMIN_PASS=Admin@1234
# Set to true to reset admin password to ADMIN_PASS on every restart # 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 PORT=3000
# App name (can also be changed in Settings UI) # 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 VERSION=dev
ARG BUILD_DATE=unknown 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.description="Self-hosted team chat PWA" \
org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \ org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.source="https://github.com/yourorg/teamchat" org.opencontainers.image.source="https://github.com/yourorg/jama"
ENV TEAMCHAT_VERSION=${VERSION} 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 ## Features
- 🔐 **Authentication** — Login, remember me, forced password change on first login ### Messaging
- 💬 **Real-time messaging** — WebSocket (Socket.io) powered chat - **Real-time messaging** — WebSocket-powered (Socket.io); messages appear instantly across all clients
- 👥 **Public channels** — Admin-created, all users auto-joined - **Image attachments** — Attach and send images; auto-compressed client-side before upload
- 🔒 **Private groups**User-created, owner-managed - **Message replies** — Quote and reply to any message with an inline preview
- 📷 **Image uploads**Attach images to messages - **Emoji reactions** — Quick-react with common emojis or open the full emoji picker; one reaction per user, replaceable
- 💬 **Message quoting**Reply to any message with preview - **@Mentions** — Type `@` to search and tag users with autocomplete; mentioned users receive a notification
- 😎 **Emoji reactions**Quick reactions + full emoji picker - **Link previews** — URLs are automatically expanded with Open Graph metadata (title, image, site name)
- @**Mentions** — @mention users with autocomplete, they get notified - **Typing indicators** — See when others are composing a message
- 🔗 **Link previews**Auto-fetches OG metadata for URLs - **Image lightbox** — Tap any image to open it full-screen with pinch-to-zoom support
- 📱 **PWA** — Install to home screen, works offline
- 👤 **Profiles** — Custom avatars, display names, about me ### Channels & Groups
- ⚙️ **Admin settings** — Custom logo, app name - **Public channels** — Admin-created; all users are automatically added
- 👨‍💼 **User management**Create, suspend, delete, bulk CSV import - **Private groups / DMs** — Any user can create; membership is invite-only by the owner
- 📢 **Read-only channels** — Announcement-style public channels - **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 | Layer | Technology |
- Docker & Docker Compose |---|---|
| 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 ```bash
# Build and tag as v1.0.0 (also tags :latest) # Build and tag as :latest only
./build.sh 1.0.0
# Build latest only
./build.sh ./build.sh
```
### 2. Deploy with Docker Compose # Build and tag as a specific version (also tags :latest)
```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.sh 1.0.0 ./build.sh 1.0.0
# Build and push to Docker Hub # Build and push to Docker Hub
REGISTRY=yourdockerhubuser ./build.sh 1.0.0 push 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 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 ```bash
# .env git clone https://github.com/yourorg/jama.git
TEAMCHAT_VERSION=1.2.0 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 ```bash
docker compose pull # if pulling from a registry
docker compose up -d docker compose up -d
# Follow startup logs
docker compose logs -f jama
``` ```
### Rolling back On first startup you should see:
```
```bash [DB] Default admin created: admin@yourdomain.com
# .env [DB] Default jama group created
TEAMCHAT_VERSION=1.1.0 [DB] Support group created
docker compose up -d # instantly rolls back to previous image
``` ```
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 ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |---|---|---|
| `ADMIN_NAME` | `Admin User` | Default admin display name | | `JAMA_VERSION` | `latest` | Docker image tag to run. Set by `build.sh` or manually. |
| `ADMIN_EMAIL` | `admin@teamchat.local` | Default admin email (login) | | `ADMIN_NAME` | `Admin User` | Display name of the default admin account |
| `ADMIN_PASS` | `Admin@1234` | Default admin password (first run only) | | `ADMIN_EMAIL` | `admin@jama.local` | Login email for the default admin account |
| `PW_RESET` | `false` | If `true`, resets admin password to `ADMIN_PASS` on every restart | | `ADMIN_PASS` | `Admin@1234` | Initial password for the default admin account |
| `JWT_SECRET` | *(insecure default)* | **Change this!** Used to sign auth tokens | | `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. |
| `PORT` | `3000` | HTTP port to listen on | | `JWT_SECRET` | *(insecure default)* | Secret used to sign auth tokens. **Must be changed in production.** Use a long random string. |
| `APP_NAME` | `TeamChat` | Initial app name (can be changed in Settings) | | `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` 1. Open your app URL and log in with `ADMIN_EMAIL` / `ADMIN_PASS`
2. Login with `ADMIN_EMAIL` / `ADMIN_PASS` 2. Change your password when prompted
3. You'll be prompted to **change your password** immediately 3. Open ⚙️ **Settings** (bottom-left menu → Settings):
4. You're in! The default **TeamChat** public channel is ready - 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
## PW_RESET Warning 5. Create public channels or let users create private groups
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**
--- ---
## User Management ## 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 | Action | Description |
- **Bulk import via CSV** — Format: `name,email,password,role` |---|---|
- **Reset password** — User is forced to change on next login | Create user | Set name, email, temporary password, and role |
- **Suspend / Activate** — Suspended users cannot login | Bulk CSV import | Upload a CSV to create multiple users at once |
- **Delete** — Soft delete; messages remain, sessions invalidated | Reset password | User is forced to set a new password on next login |
- **Elevate / Demote** — Change member ↔ admin role | 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 ## Group Types
| | Public Channels | Private Groups | | | Public Channels | Private Groups |
|--|--|--| |---|---|---|
| Creator | Admin only | Any user | | Who can create | Admin only | Any user |
| Members | All users (auto) | Invited by owner | | Membership | All users (automatic) | Invite-only by owner |
| Visible to admins | ✅ Yes | ❌ No (unless admin takes ownership) | | Visible to admins | ✅ Yes | ❌ No |
| Leave | ❌ Not allowed | ✅ Yes | | Leave | ❌ Not allowed | ✅ Yes |
| Rename | Admin only | Owner only | | Rename | Admin only | Owner only |
| Read-only mode | ✅ Optional | ❌ N/A | | 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 ## Data Persistence
All data is stored in Docker volumes: | Volume | Container path | Contents |
- `teamchat_db` — SQLite database |---|---|---|
- `teamchat_uploads` — User avatars, logos, message images | `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 ## PWA Installation
On mobile: **Share → Add to Home Screen** HTTPS is required for the browser install prompt to appear.
On desktop (Chrome): Click the install icon in the address bar
| 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 ## Development
```bash ```bash
# Backend # Start backend (port 3000)
cd backend && npm install && npm run dev 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 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 = {}; const s = {};
for (const r of rows) s[r.key] = r.value; for (const r of rows) s[r.key] = r.value;
const appName = s.app_name || process.env.APP_NAME || 'TeamChat'; const appName = s.app_name || process.env.APP_NAME || 'jama';
const pwa192 = s.pwa_icon_192 || ''; const pwa192 = s.pwa_icon_192 || '';
const pwa512 = s.pwa_icon_512 || ''; const pwa512 = s.pwa_icon_512 || '';
@@ -104,7 +104,12 @@ io.use((socket, next) => {
const db = getDb(); 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'); 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')); 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.user = user;
socket.token = token;
socket.device = session.device;
next(); next();
} catch (e) { } catch (e) {
next(new Error('Invalid token')); next(new Error('Invalid token'));
@@ -305,5 +310,5 @@ io.on('connection', (socket) => {
}); });
server.listen(PORT, () => { 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'; 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) { function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token; const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token;
if (!token) return res.status(401).json({ error: 'Unauthorized' }); if (!token) return res.status(401).json({ error: 'Unauthorized' });
@@ -12,7 +22,16 @@ function authMiddleware(req, res, next) {
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active'); 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' }); 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.user = user;
req.token = token;
req.device = session.device;
next(); next();
} catch (e) { } catch (e) {
return res.status(401).json({ error: 'Invalid token' }); return res.status(401).json({ error: 'Invalid token' });
@@ -28,4 +47,27 @@ function generateToken(userId) {
return jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '30d' }); 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 fs = require('fs');
const bcrypt = require('bcryptjs'); 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; let db;
@@ -118,11 +118,21 @@ function initDb() {
value TEXT NOT NULL, value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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 // Initialize default settings
const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); 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('logo_url', '');
insertSetting.run('pw_reset_active', process.env.PW_RESET === 'true' ? 'true' : 'false'); insertSetting.run('pw_reset_active', process.env.PW_RESET === 'true' ? 'true' : 'false');
insertSetting.run('icon_newchat', ''); insertSetting.run('icon_newchat', '');
@@ -136,6 +146,26 @@ function initDb() {
console.log('[DB] Migration: added hide_admin_tag column'); console.log('[DB] Migration: added hide_admin_tag column');
} catch (e) { /* column already exists */ } } 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'); console.log('[DB] Schema initialized');
return db; return db;
} }
@@ -144,7 +174,7 @@ function seedAdmin() {
const db = getDb(); const db = getDb();
// Strip any surrounding quotes from env vars (common docker-compose mistake) // 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 adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim();
const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim(); const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim();
const pwReset = process.env.PW_RESET === 'true'; const pwReset = process.env.PW_RESET === 'true';
@@ -163,17 +193,17 @@ function seedAdmin() {
console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`); console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`);
// Create default TeamChat group // Create default public group
const groupResult = db.prepare(` const groupResult = db.prepare(`
INSERT INTO groups (name, type, is_default, owner_id) INSERT INTO groups (name, type, is_default, owner_id)
VALUES ('TeamChat', 'public', 1, ?) VALUES (?, 'public', 1, ?)
`).run(result.lastInsertRowid); `).run(process.env.DEFCHAT_NAME || 'General Chat', result.lastInsertRowid);
// Add admin to default group // Add admin to default group
db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)') db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)')
.run(groupResult.lastInsertRowid, result.lastInsertRowid); .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(); seedSupportGroup();
} catch (err) { } catch (err) {
console.error('[DB] ERROR creating default admin:', err.message); console.error('[DB] ERROR creating default admin:', err.message);

View File

@@ -2,7 +2,7 @@ const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const router = express.Router(); const router = express.Router();
const { getDb, getOrCreateSupportGroup } = require('../models/db'); const { getDb, getOrCreateSupportGroup } = require('../models/db');
const { generateToken, authMiddleware } = require('../middleware/auth'); const { generateToken, authMiddleware, setActiveSession, clearActiveSession } = require('../middleware/auth');
// Login // Login
router.post('/login', (req, res) => { router.post('/login', (req, res) => {
@@ -25,6 +25,8 @@ router.post('/login', (req, res) => {
if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = generateToken(user.id); 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; const { password: _, ...userSafe } = user;
res.json({ res.json({
@@ -58,8 +60,9 @@ router.get('/me', authMiddleware, (req, res) => {
res.json({ user }); 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) => { router.post('/logout', authMiddleware, (req, res) => {
clearActiveSession(req.user.id, req.device);
res.json({ success: true }); res.json({ success: true });
}); });

View File

@@ -116,6 +116,23 @@ router.post('/:id/members', authMiddleware, (req, res) => {
res.json({ success: true }); 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 // Leave private group
router.delete('/:id/leave', authMiddleware, (req, res) => { router.delete('/:id/leave', authMiddleware, (req, res) => {
const db = getDb(); 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' }); if (!message) return res.status(404).json({ error: 'Message not found' });
const canDelete = message.user_id === req.user.id || 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); (message.group_type === 'private' && message.group_owner_id === req.user.id);
if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' }); if (!canDelete) return res.status(403).json({ error: 'Cannot delete this message' });

View File

@@ -24,7 +24,7 @@ function getVapidKeys() {
function initWebPush() { function initWebPush() {
const keys = getVapidKeys(); const keys = getVapidKeys();
webpush.setVapidDetails( webpush.setVapidDetails(
'mailto:admin@teamchat.local', 'mailto:admin@jama.local',
keys.publicKey, keys.publicKey,
keys.privateKey keys.privateKey
); );

View File

@@ -115,7 +115,7 @@ router.post('/icon-groupinfo', authMiddleware, adminMiddleware, uploadGroupInfo.
// Reset all settings to defaults (admin) // Reset all settings to defaults (admin)
router.post('/reset', authMiddleware, adminMiddleware, (req, res) => { router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
const db = getDb(); 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 = '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 = '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(); 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, { const res = await fetch(url, {
signal: controller.signal, signal: controller.signal,
headers: { 'User-Agent': 'TeamChatBot/1.0' } headers: { 'User-Agent': 'JamaBot/1.0' }
}); });
clearTimeout(timeout); clearTimeout(timeout);

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# TeamChat — Docker build & release script # jama — Docker build & release script
# #
# Usage: # Usage:
# ./build.sh # builds teamchat:latest # ./build.sh # builds jama:latest
# ./build.sh 1.2.0 # builds teamchat:1.2.0 AND teamchat: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 # ./build.sh 1.2.0 push # builds, tags, and pushes to registry
# #
# To push to a registry, set REGISTRY env var: # To push to a registry, set REGISTRY env var:
@@ -16,7 +16,7 @@ set -euo pipefail
VERSION="${1:-latest}" VERSION="${1:-latest}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="teamchat" IMAGE_NAME="jama"
# If a registry is set, prefix image name # If a registry is set, prefix image name
if [[ -n "$REGISTRY" ]]; then if [[ -n "$REGISTRY" ]]; then
@@ -26,7 +26,7 @@ else
fi fi
echo "╔══════════════════════════════════════╗" echo "╔══════════════════════════════════════╗"
echo "║ TeamChat Docker Builder ║" echo "║ jama Docker Builder ║"
echo "╠══════════════════════════════════════╣" echo "╠══════════════════════════════════════╣"
echo "║ Image : ${FULL_IMAGE}" echo "║ Image : ${FULL_IMAGE}"
echo "║ Version : ${VERSION}" echo "║ Version : ${VERSION}"
@@ -67,7 +67,7 @@ fi
echo "" echo ""
echo "─────────────────────────────────────────" echo "─────────────────────────────────────────"
echo "To deploy this version, set in your .env:" echo "To deploy this version, set in your .env:"
echo " TEAMCHAT_VERSION=${VERSION}" echo " JAMA_VERSION=${VERSION}"
echo "" echo ""
echo "Then run:" echo "Then run:"
echo " docker compose up -d" echo " docker compose up -d"

View File

@@ -1,23 +1,24 @@
version: '3.8' version: '3.8'
services: services:
teamchat: jama:
image: teamchat:${TEAMCHAT_VERSION:-latest} image: jama:${JAMA_VERSION:-latest}
container_name: teamchat container_name: jama
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PORT:-3000}:3000" - "${PORT:-3000}:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- ADMIN_NAME=${ADMIN_NAME:-Admin User} - 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} - ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- PW_RESET=${PW_RESET:-false} - PW_RESET=${PW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024} - JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
- APP_NAME=${APP_NAME:-TeamChat} - APP_NAME=${APP_NAME:-jama}
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
volumes: volumes:
- teamchat_db:/app/data - jama_db:/app/data
- teamchat_uploads:/app/uploads - jama_uploads:/app/uploads
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s interval: 30s
@@ -25,7 +26,7 @@ services:
retries: 3 retries: 3
volumes: volumes:
teamchat_db: jama_db:
driver: local driver: local
teamchat_uploads: jama_uploads:
driver: local driver: local

View File

@@ -2,13 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/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="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a73e8" /> <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="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" /> <link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>TeamChat</title> <title>jama</title>
</head> </head>
<body> <body>
<div id="root"></div> <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", "name": "jama",
"short_name": "TeamChat", "short_name": "jama",
"description": "Modern team messaging application", "description": "Modern team messaging application",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'teamchat-v2'; const CACHE_NAME = 'jama-v1';
const STATIC_ASSETS = ['/']; const STATIC_ASSETS = ['/'];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
@@ -47,7 +47,7 @@ self.addEventListener('push', (event) => {
icon: '/icons/icon-192.png', icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png', badge: '/icons/icon-192.png',
data: { url: data.url || '/' }, 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 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 || ''); setIconGroupInfo(settings.icon_groupinfo || '');
}).catch(() => {}); }).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {}); const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
window.addEventListener('teamchat:settings-changed', handler); window.addEventListener('jama:settings-changed', handler);
return () => window.removeEventListener('teamchat:settings-changed', handler); return () => window.removeEventListener('jama:settings-changed', handler);
}, []); }, []);
const scrollToBottom = useCallback((smooth = false) => { const scrollToBottom = useCallback((smooth = false) => {
@@ -172,15 +172,15 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
) : null} ) : null}
</div> </div>
<span className="chat-header-sub"> <span className="chat-header-sub">
{group.type === 'public' ? 'Public channel' : 'Private group'} {group.type === 'public' ? 'Public group' : 'Private group'}
</span> </span>
</div> </div>
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info"> <button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info">
{iconGroupInfo ? ( {iconGroupInfo ? (
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} /> <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"> <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 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" /> <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> </svg>
)} )}
</button> </button>

View File

@@ -71,6 +71,15 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
} catch (e) { toast(e.message, 'error'); } } 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 () => { const handleDelete = async () => {
if (!confirm('Delete this group? This cannot be undone.')) return; if (!confirm('Delete this group? This cannot be undone.')) return;
try { try {
@@ -125,10 +134,28 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
</div> </div>
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
{members.map(m => ( {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" /> <Avatar user={m} size="sm" />
<span className="flex-1 text-sm">{m.display_name || m.name}</span> <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>} {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>
))} ))}
</div> </div>

View File

@@ -31,7 +31,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
const canDelete = ( const canDelete = (
msg.user_id === currentUser.id || msg.user_id === currentUser.id ||
(currentUser.role === 'admin' && msg.group_type !== 'private') || currentUser.role === 'admin' ||
(msg.group_owner_id === currentUser.id) (msg.group_owner_id === currentUser.id)
); );

View File

@@ -2,51 +2,6 @@ import { useState, useEffect } from 'react';
import { api } from '../utils/api.js'; import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx'; 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 }) { export default function SettingsModal({ onClose }) {
const toast = useToast(); const toast = useToast();
const [settings, setSettings] = useState({}); const [settings, setSettings] = useState({});
@@ -58,11 +13,11 @@ export default function SettingsModal({ onClose }) {
useEffect(() => { useEffect(() => {
api.getSettings().then(({ settings }) => { api.getSettings().then(({ settings }) => {
setSettings(settings); setSettings(settings);
setAppName(settings.app_name || 'TeamChat'); setAppName(settings.app_name || 'jama');
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
const notifySidebarRefresh = () => window.dispatchEvent(new Event('teamchat:settings-changed')); const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
const handleSaveName = async () => { const handleSaveName = async () => {
if (!appName.trim()) return; 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 () => { const handleReset = async () => {
setResetting(true); setResetting(true);
try { try {
await api.resetSettings(); await api.resetSettings();
const { settings: fresh } = await api.getSettings(); const { settings: fresh } = await api.getSettings();
setSettings(fresh); setSettings(fresh);
setAppName(fresh.app_name || 'TeamChat'); setAppName(fresh.app_name || 'jama');
toast('Settings reset to defaults', 'success'); toast('Settings reset to defaults', 'success');
notifySidebarRefresh(); notifySidebarRefresh();
setShowResetConfirm(false); 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 ( return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}> <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 460 }}> <div className="modal" style={{ maxWidth: 460 }}>
@@ -146,23 +84,20 @@ export default function SettingsModal({ onClose }) {
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex', border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0 alignItems: 'center', justifyContent: 'center', flexShrink: 0
}}> }}>
{settings.logo_url ? ( <img
<img src={settings.logo_url} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> src={settings.logo_url || '/icons/jama.png'}
) : ( alt="logo"
<svg viewBox="0 0 48 48" fill="none" style={{ width: 48, height: 48 }}> style={{ width: '100%', height: '100%', objectFit: 'contain' }}
<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> </div>
<div> <div>
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}> <label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
Upload Logo Upload Logo
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} /> <input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
</label> </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> </div>
</div> </div>
@@ -178,58 +113,37 @@ export default function SettingsModal({ onClose }) {
</div> </div>
</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 */} {/* Reset + Version */}
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}> <div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
<div className="settings-section-label">Reset</div> <div className="settings-section-label">Reset</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
{!showResetConfirm ? ( {!showResetConfirm ? (
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}> <button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>
Reset All to Defaults Reset All to Defaults
</button> </button>
) : ( ) : (
<div style={{ <div style={{
background: '#fce8e6', border: '1px solid #f5c6c2', background: '#fce8e6', border: '1px solid #f5c6c2',
borderRadius: 'var(--radius)', padding: '12px 14px' borderRadius: 'var(--radius)', padding: '12px 14px'
}}> }}>
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}> <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> </p>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}> <button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
{resetting ? 'Resetting...' : 'Yes, Reset Everything'} {resetting ? 'Resetting...' : 'Yes, Reset Everything'}
</button> </button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}> <button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>Cancel</button>
Cancel </div>
</button>
</div> </div>
</div> )}
)} {settings.app_version && (
{settings.app_version && ( <span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}> v{settings.app_version}
v{settings.app_version} </span>
</span> )}
)} </div>
</div>{/* end flex row */} </div>
</div>{/* end Reset section */}
{settings.pw_reset_active === 'true' && ( {settings.pw_reset_active === 'true' && (
<div className="warning-banner"> <div className="warning-banner">

View File

@@ -32,7 +32,7 @@
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--text-tertiary); background: #e53935;
} }
.sidebar-search { .sidebar-search {
@@ -188,14 +188,25 @@
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-logo-default {
width: 56px;
height: 56px;
flex-shrink: 0;
}
.sidebar-logo-default svg { /* Unread message indicator */
width: 56px; .group-item.has-unread {
height: 56px; background: var(--primary-light);
display: block; }
.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 Avatar from './Avatar.jsx';
import './Sidebar.css'; 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() { function useAppSettings() {
const [settings, setSettings] = useState({ app_name: 'TeamChat', logo_url: '' }); const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
const fetchSettings = () => { const fetchSettings = () => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
@@ -16,20 +27,20 @@ function useAppSettings() {
useEffect(() => { useEffect(() => {
fetchSettings(); fetchSettings();
// Re-fetch when settings are saved from the SettingsModal // Re-fetch when settings are saved from the SettingsModal
window.addEventListener('teamchat:settings-changed', fetchSettings); window.addEventListener('jama:settings-changed', fetchSettings);
return () => window.removeEventListener('teamchat:settings-changed', fetchSettings); return () => window.removeEventListener('jama:settings-changed', fetchSettings);
}, []); }, []);
// Update page title and favicon whenever settings change // Update page title and favicon whenever settings change
useEffect(() => { useEffect(() => {
const name = settings.app_name || 'TeamChat'; const name = settings.app_name || 'jama';
// Update <title> // Update <title>
document.title = name; document.title = name;
// Update favicon // Update favicon
const logoUrl = settings.logo_url; const logoUrl = settings.logo_url;
const faviconUrl = logoUrl || '/logo.svg'; const faviconUrl = logoUrl || '/icons/jama.png';
let link = document.querySelector("link[rel~='icon']"); let link = document.querySelector("link[rel~='icon']");
if (!link) { if (!link) {
link = document.createElement('link'); link = document.createElement('link');
@@ -42,15 +53,16 @@ function useAppSettings() {
return settings; 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 { user, logout } = useAuth();
const { connected } = useSocket(); const { connected } = useSocket();
const toast = useToast(); const toast = useToast();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const settings = useAppSettings(); 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 logoUrl = settings.logo_url;
const allGroups = [ const allGroups = [
@@ -73,7 +85,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
const GroupItem = ({ group }) => { const GroupItem = ({ group }) => {
const notifs = getNotifCount(group.id); 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; const isActive = group.id === activeGroupId;
return ( return (
@@ -93,7 +106,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{group.last_message || (group.is_readonly ? '📢 Read-only' : 'No messages yet')} {group.last_message || (group.is_readonly ? '📢 Read-only' : 'No messages yet')}
</span> </span>
{notifs > 0 && <span className="badge shrink-0">{notifs}</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> </div>
</div> </div>
@@ -108,14 +121,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{logoUrl ? ( {logoUrl ? (
<img src={logoUrl} alt={appName} className="sidebar-logo" /> <img src={logoUrl} alt={appName} className="sidebar-logo" />
) : ( ) : (
<div className="sidebar-logo-default"> <img src="/icons/jama.png" alt="jama" className="sidebar-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>
)} )}
<h2 className="sidebar-title truncate">{appName}</h2> <h2 className="sidebar-title truncate">{appName}</h2>
{!connected && <span className="offline-dot" title="Offline" />} {!connected && <span className="offline-dot" title="Offline" />}
@@ -124,9 +130,9 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{settings.icon_newchat ? ( {settings.icon_newchat ? (
<img src={settings.icon_newchat} alt="New Chat" style={{ width: 20, height: 20, objectFit: 'contain' }} /> <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"> <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" /> <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>
)} )}
</button> </button>
</div> </div>
@@ -145,7 +151,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
<div className="groups-list"> <div className="groups-list">
{publicFiltered.length > 0 && ( {publicFiltered.length > 0 && (
<div className="group-section"> <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} />)} {publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
</div> </div>
)} )}
@@ -166,16 +172,40 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
{/* User footer */} {/* User footer */}
<div className="sidebar-footer"> <div className="sidebar-footer">
<button className="user-footer-btn" onClick={() => setShowMenu(!showMenu)}> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Avatar user={user} size="sm" /> <button className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}> <Avatar user={user} size="sm" />
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span> <div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span> <span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
</div> <span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> </div>
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
</svg> <circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</button> </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 && ( {showMenu && (
<div className="footer-menu" onClick={() => setShowMenu(false)}> <div className="footer-menu" onClick={() => setShowMenu(false)}>

View File

@@ -46,6 +46,16 @@ export function AuthProvider({ children }) {
setMustChangePassword(false); 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 })); const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates }));
return ( return (

View File

@@ -197,3 +197,49 @@ a { color: inherit; text-decoration: none; }
color: var(--text-tertiary); color: var(--text-tertiary);
margin-bottom: 12px; 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 App from './App.jsx';
import './index.css'; 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 // Register service worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {

View File

@@ -28,7 +28,7 @@ export default function Chat() {
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] }); const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
const [activeGroupId, setActiveGroupId] = useState(null); const [activeGroupId, setActiveGroupId] = useState(null);
const [notifications, setNotifications] = useState([]); 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 [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat'
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true); const [showSidebar, setShowSidebar] = useState(true);
@@ -89,6 +89,7 @@ export default function Chat() {
if (!socket) return; if (!socket) return;
const handleNewMsg = (msg) => { const handleNewMsg = (msg) => {
// Update group preview text
setGroups(prev => { setGroups(prev => {
const updateGroup = (g) => g.id === msg.group_id const updateGroup = (g) => g.id === msg.group_id
? { ...g, last_message: msg.content || (msg.image_url ? '📷 Image' : ''), last_message_at: msg.created_at } ? { ...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), 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) => { const handleNotification = (notif) => {
if (notif.type === 'private_message') { 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 => { setUnreadGroups(prev => {
if (notif.groupId === activeGroupId) return prev; if (notif.groupId === activeGroupId) return prev;
const next = new Set(prev); const next = new Map(prev);
next.add(notif.groupId); next.set(notif.groupId, (next.get(notif.groupId) || 0) + 1);
return next; return next;
}); });
} else { } else {
@@ -127,9 +136,9 @@ export default function Chat() {
const selectGroup = (id) => { const selectGroup = (id) => {
setActiveGroupId(id); setActiveGroupId(id);
if (isMobile) setShowSidebar(false); 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)); 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 = [ const activeGroup = [

View File

@@ -29,12 +29,6 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
.default-logo svg {
width: 72px;
height: 72px;
margin-bottom: 16px;
}
.login-logo h1 { .login-logo h1 {
font-size: 28px; font-size: 28px;
font-weight: 700; 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; const logoUrl = settings.logo_url;
return ( return (
@@ -76,14 +76,7 @@ export default function Login() {
{logoUrl ? ( {logoUrl ? (
<img src={logoUrl} alt={appName} className="logo-img" /> <img src={logoUrl} alt={appName} className="logo-img" />
) : ( ) : (
<div className="default-logo"> <img src="/icons/jama.png" alt="jama" className="logo-img" />
<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>
)} )}
<h1>{appName}</h1> <h1>{appName}</h1>
<p>Sign in to continue</p> <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 res = await fetch(BASE + path, fetchOpts);
const data = await res.json(); 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; return data;
} }
@@ -54,6 +62,7 @@ export const api = {
renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }), renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }),
getMembers: (id) => req('GET', `/groups/${id}/members`), getMembers: (id) => req('GET', `/groups/${id}/members`),
addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }), 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`), leaveGroup: (id) => req('DELETE', `/groups/${id}/leave`),
takeOwnership: (id) => req('POST', `/groups/${id}/take-ownership`), takeOwnership: (id) => req('POST', `/groups/${id}/take-ownership`),
deleteGroup: (id) => req('DELETE', `/groups/${id}`), deleteGroup: (id) => req('DELETE', `/groups/${id}`),