version 0.0.24
12
.env.example
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
12
build.sh
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1021 B After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 144 KiB |
BIN
frontend/public/icons/jama.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
frontend/public/icons/logo-64.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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": "/",
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||