diff --git a/.env.example b/.env.example
index aba3b70..0921f2d 100644
--- a/.env.example
+++ b/.env.example
@@ -10,7 +10,7 @@
PROJECT_NAME=jama
# Image version to run (set by build.sh, or use 'latest')
-JAMA_VERSION=0.9.25
+JAMA_VERSION=0.9.26
# App port — the host port Docker maps to the container
PORT=3000
diff --git a/backend/package.json b/backend/package.json
index 8b0cb2b..41fafc0 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-backend",
- "version": "0.9.25",
+ "version": "0.9.26",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {
diff --git a/backend/src/index.js b/backend/src/index.js
index 6b32fd5..4a4f40b 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -41,6 +41,7 @@ app.use('/api/auth', require('./routes/auth')(io));
app.use('/api/users', require('./routes/users'));
app.use('/api/groups', require('./routes/groups')(io));
app.use('/api/messages', require('./routes/messages')(io));
+app.use('/api/usergroups', require('./routes/usergroups')(io));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/about', require('./routes/about'));
app.use('/api/help', require('./routes/help'));
diff --git a/backend/src/models/db.js b/backend/src/models/db.js
index bafc87e..6bc1116 100644
--- a/backend/src/models/db.js
+++ b/backend/src/models/db.js
@@ -158,6 +158,25 @@ function initDb() {
UNIQUE(user_id, device),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
+
+ -- User groups (admin-managed, separate from chat groups)
+ CREATE TABLE IF NOT EXISTS user_groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ dm_group_id INTEGER, -- paired private group in groups table
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
+ );
+
+ -- Members of user groups
+ CREATE TABLE IF NOT EXISTS user_group_members (
+ user_group_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ PRIMARY KEY (user_group_id, user_id),
+ FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
`);
// Initialize default settings
@@ -173,6 +192,9 @@ function initDb() {
insertSetting.run('color_title_dark', '');
insertSetting.run('color_avatar_public', '');
insertSetting.run('color_avatar_dm', '');
+ insertSetting.run('registration_code', '');
+ insertSetting.run('feature_branding', 'false');
+ insertSetting.run('feature_group_manager', 'false');
// Migration: add hide_admin_tag if upgrading from older version
try {
@@ -262,6 +284,36 @@ function initDb() {
console.log('[DB] Migration: pinned_conversations table ready');
} catch (e) { console.error('[DB] pinned_conversations migration error:', e.message); }
+ // Migration: is_managed flag on groups (admin-managed DMs via Group Manager)
+ try {
+ db.exec("ALTER TABLE groups ADD COLUMN is_managed INTEGER NOT NULL DEFAULT 0");
+ console.log('[DB] Migration: added is_managed column to groups');
+ } catch (e) { /* already exists */ }
+
+ // Migration: user_groups and user_group_members tables
+ try {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS user_groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ dm_group_id INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL
+ )
+ `);
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS user_group_members (
+ user_group_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ PRIMARY KEY (user_group_id, user_id),
+ FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ )
+ `);
+ console.log('[DB] Migration: user_groups tables ready');
+ } catch (e) { console.error('[DB] user_groups migration error:', e.message); }
+
console.log('[DB] Schema initialized');
return db;
}
diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js
index 60f47b7..a6332e3 100644
--- a/backend/src/routes/groups.js
+++ b/backend/src/routes/groups.js
@@ -340,6 +340,7 @@ router.delete('/:id/leave', authMiddleware, (req, res) => {
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 === 'public') return res.status(400).json({ error: 'Cannot leave public groups' });
+ if (group.is_managed && req.user.role !== 'admin') return res.status(403).json({ error: 'This group is managed by an administrator. Contact an admin to be removed.' });
const userId = req.user.id;
const leaverName = req.user.display_name || req.user.name;
diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js
index d418aba..3939f64 100644
--- a/backend/src/routes/settings.js
+++ b/backend/src/routes/settings.js
@@ -134,4 +134,34 @@ router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
res.json({ success: true });
});
+// ── Registration code ─────────────────────────────────────────────────────────
+// Valid codes — in production these would be stored/validated server-side
+const VALID_CODES = {
+ 'JAMA-FULL-2024': { branding: true, groupManager: true },
+ 'JAMA-BRAND-2024': { branding: true, groupManager: false },
+};
+
+router.post('/register', authMiddleware, adminMiddleware, (req, res) => {
+ const { code } = req.body;
+ const db = getDb();
+ const upd = db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')");
+
+ if (!code?.trim()) {
+ // Clear registration
+ upd.run('registration_code', '', '');
+ upd.run('feature_branding', 'false', 'false');
+ upd.run('feature_group_manager', 'false', 'false');
+ return res.json({ success: true, features: { branding: false, groupManager: false } });
+ }
+
+ const match = VALID_CODES[code.trim().toUpperCase()];
+ if (!match) return res.status(400).json({ error: 'Invalid registration code' });
+
+ upd.run('registration_code', code.trim(), code.trim());
+ upd.run('feature_branding', match.branding ? 'true' : 'false', match.branding ? 'true' : 'false');
+ upd.run('feature_group_manager', match.groupManager ? 'true' : 'false', match.groupManager ? 'true' : 'false');
+
+ res.json({ success: true, features: { branding: match.branding, groupManager: match.groupManager } });
+});
+
module.exports = router;
diff --git a/backend/src/routes/usergroups.js b/backend/src/routes/usergroups.js
new file mode 100644
index 0000000..efbd2de
--- /dev/null
+++ b/backend/src/routes/usergroups.js
@@ -0,0 +1,172 @@
+const express = require('express');
+const router = express.Router();
+const { getDb } = require('../models/db');
+const { authMiddleware, adminMiddleware } = require('../middleware/auth');
+
+module.exports = function(io) {
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function postSysMsg(db, groupId, userId, content) {
+ const r = db.prepare(`INSERT INTO messages (group_id, user_id, content, type) VALUES (?, ?, ?, 'system')`).run(groupId, userId, content);
+ const msg = db.prepare(`
+ SELECT m.*, u.name as user_name, u.display_name as user_display_name,
+ u.avatar as user_avatar, u.role as user_role, u.status as user_status,
+ u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me, u.allow_dm as user_allow_dm
+ FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?
+ `).get(r.lastInsertRowid);
+ if (msg) { msg.reactions = []; io.to(`group:${groupId}`).emit('message:new', msg); }
+}
+
+function addUserToDmGroup(db, dmGroupId, userId, actorId) {
+ db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(dmGroupId, userId);
+ io.in(`user:${userId}`).socketsJoin(`group:${dmGroupId}`);
+ const dmGroup = db.prepare('SELECT * FROM groups WHERE id = ?').get(dmGroupId);
+ io.to(`user:${userId}`).emit('group:new', { group: dmGroup });
+ const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
+ postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has joined the conversation.`);
+}
+
+function removeUserFromDmGroup(db, dmGroupId, userId, actorId) {
+ db.prepare('DELETE FROM group_members WHERE group_id = ? AND user_id = ?').run(dmGroupId, userId);
+ io.in(`user:${userId}`).socketsLeave(`group:${dmGroupId}`);
+ io.to(`user:${userId}`).emit('group:deleted', { groupId: dmGroupId });
+ const u = db.prepare('SELECT name, display_name FROM users WHERE id = ?').get(userId);
+ postSysMsg(db, dmGroupId, actorId, `${u?.display_name || u?.name || 'A user'} has been removed from the conversation.`);
+}
+
+// ── List all user groups ───────────────────────────────────────────────────────
+
+router.get('/', authMiddleware, adminMiddleware, (req, res) => {
+ const db = getDb();
+ const groups = db.prepare(`
+ SELECT ug.*, g.name as dm_name,
+ (SELECT COUNT(*) FROM user_group_members WHERE user_group_id = ug.id) as member_count
+ FROM user_groups ug
+ LEFT JOIN groups g ON g.id = ug.dm_group_id
+ ORDER BY ug.name ASC
+ `).all();
+ res.json({ groups });
+});
+
+// ── Get single user group with members ────────────────────────────────────────
+
+router.get('/:id', authMiddleware, adminMiddleware, (req, res) => {
+ const db = getDb();
+ const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
+ if (!group) return res.status(404).json({ error: 'Not found' });
+ const members = db.prepare(`
+ SELECT u.id, u.name, u.display_name, u.avatar, u.role, u.status
+ FROM user_group_members ugm
+ JOIN users u ON u.id = ugm.user_id
+ WHERE ugm.user_group_id = ?
+ ORDER BY u.name ASC
+ `).all(req.params.id);
+ res.json({ group, members });
+});
+
+// ── Create user group ─────────────────────────────────────────────────────────
+
+router.post('/', authMiddleware, adminMiddleware, (req, res) => {
+ const { name, memberIds = [] } = req.body;
+ if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
+ const db = getDb();
+
+ // Check unique name
+ if (db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
+ return res.status(400).json({ error: 'A group with that name already exists' });
+ }
+
+ // Create the paired managed DM group in groups table
+ const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get();
+ const dmResult = db.prepare(`
+ INSERT INTO groups (name, type, owner_id, is_readonly, is_direct, is_managed)
+ VALUES (?, 'private', ?, 0, 0, 1)
+ `).run(name.trim(), admin?.id || req.user.id);
+ const dmGroupId = dmResult.lastInsertRowid;
+
+ // Create the user group
+ const ugResult = db.prepare(`
+ INSERT INTO user_groups (name, dm_group_id) VALUES (?, ?)
+ `).run(name.trim(), dmGroupId);
+ const ugId = ugResult.lastInsertRowid;
+
+ // Add members to both
+ const validIds = Array.isArray(memberIds) ? memberIds.map(Number).filter(Boolean) : [];
+ const addMember = db.prepare('INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)');
+ for (const uid of validIds) {
+ addMember.run(ugId, uid);
+ db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)').run(dmGroupId, uid);
+ io.in(`user:${uid}`).socketsJoin(`group:${dmGroupId}`);
+ const dmGroup = db.prepare('SELECT * FROM groups WHERE id = ?').get(dmGroupId);
+ io.to(`user:${uid}`).emit('group:new', { group: dmGroup });
+ }
+
+ const group = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(ugId);
+ res.json({ group });
+});
+
+// ── Update user group (name + members) ────────────────────────────────────────
+
+router.patch('/:id', authMiddleware, adminMiddleware, (req, res) => {
+ const db = getDb();
+ const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
+ if (!ug) return res.status(404).json({ error: 'Not found' });
+
+ const { name, memberIds } = req.body;
+
+ // Rename
+ if (name && name.trim() !== ug.name) {
+ const conflict = db.prepare('SELECT id FROM user_groups WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), ug.id);
+ if (conflict) return res.status(400).json({ error: 'Name already in use' });
+ db.prepare("UPDATE user_groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), ug.id);
+ if (ug.dm_group_id) {
+ db.prepare("UPDATE groups SET name = ?, updated_at = datetime('now') WHERE id = ?").run(name.trim(), ug.dm_group_id);
+ }
+ }
+
+ // Sync members
+ if (Array.isArray(memberIds) && ug.dm_group_id) {
+ const newIds = new Set(memberIds.map(Number).filter(Boolean));
+ const current = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?').all(ug.id).map(r => r.user_id);
+ const currentSet = new Set(current);
+
+ // Add new members
+ for (const uid of newIds) {
+ if (!currentSet.has(uid)) {
+ db.prepare('INSERT OR IGNORE INTO user_group_members (user_group_id, user_id) VALUES (?, ?)').run(ug.id, uid);
+ addUserToDmGroup(db, ug.dm_group_id, uid, req.user.id);
+ }
+ }
+ // Remove dropped members
+ for (const uid of currentSet) {
+ if (!newIds.has(uid)) {
+ db.prepare('DELETE FROM user_group_members WHERE user_group_id = ? AND user_id = ?').run(ug.id, uid);
+ removeUserFromDmGroup(db, ug.dm_group_id, uid, req.user.id);
+ }
+ }
+ }
+
+ const updated = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
+ res.json({ group: updated });
+});
+
+// ── Delete user group ─────────────────────────────────────────────────────────
+
+router.delete('/:id', authMiddleware, adminMiddleware, (req, res) => {
+ const db = getDb();
+ const ug = db.prepare('SELECT * FROM user_groups WHERE id = ?').get(req.params.id);
+ if (!ug) return res.status(404).json({ error: 'Not found' });
+
+ // Notify all DM group members before deleting
+ if (ug.dm_group_id) {
+ const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(ug.dm_group_id).map(r => r.user_id);
+ db.prepare('DELETE FROM groups WHERE id = ?').run(ug.dm_group_id);
+ for (const uid of members) io.to(`user:${uid}`).emit('group:deleted', { groupId: ug.dm_group_id });
+ }
+ db.prepare('DELETE FROM user_groups WHERE id = ?').run(ug.id);
+ res.json({ success: true });
+});
+
+return router;
+};
diff --git a/build.sh b/build.sh
index 1eec4d5..7eb7410 100644
--- a/build.sh
+++ b/build.sh
@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
-VERSION="${1:-0.9.25}"
+VERSION="${1:-0.9.26}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"
diff --git a/frontend/package.json b/frontend/package.json
index f090f7c..fdd0048 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
- "version": "0.9.25",
+ "version": "0.9.26",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/GlobalBar.jsx b/frontend/src/components/GlobalBar.jsx
index 10e5c36..e4e8b2d 100644
--- a/frontend/src/components/GlobalBar.jsx
+++ b/frontend/src/components/GlobalBar.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useSocket } from '../contexts/SocketContext.jsx';
import { api } from '../utils/api.js';
-export default function GlobalBar({ isMobile, showSidebar }) {
+export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
const { connected } = useSocket();
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
@@ -11,7 +11,6 @@ export default function GlobalBar({ isMobile, showSidebar }) {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
window.addEventListener('jama:settings-changed', handler);
- // Re-render when theme changes so title colour switches correctly
const themeObserver = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
});
@@ -26,20 +25,34 @@ export default function GlobalBar({ isMobile, showSidebar }) {
const logoUrl = settings.logo_url;
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null;
- // On mobile: show bar only when sidebar is visible (chat list view)
- // On desktop: always show
if (isMobile && !showSidebar) return null;
return (
+ {/* Burger menu button */}
+
+
+
+
+
+
+
+
-
+
{appName}
+
{!connected && (
diff --git a/frontend/src/components/GroupManagerModal.jsx b/frontend/src/components/GroupManagerModal.jsx
new file mode 100644
index 0000000..2d08e97
--- /dev/null
+++ b/frontend/src/components/GroupManagerModal.jsx
@@ -0,0 +1,306 @@
+import { useState, useEffect } from 'react';
+import { api } from '../utils/api.js';
+import { useToast } from '../contexts/ToastContext.jsx';
+import Avatar from './Avatar.jsx';
+
+// ── Shared user list checkbox ─────────────────────────────────────────────────
+function UserCheckList({ allUsers, selectedIds, onChange }) {
+ const [search, setSearch] = useState('');
+ const filtered = allUsers.filter(u =>
+ (u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
+ u.email?.toLowerCase().includes(search.toLowerCase())
+ );
+ return (
+
+
setSearch(e.target.value)} style={{ marginBottom: 8 }} />
+
+ {filtered.map(u => (
+
+ {
+ const next = new Set(selectedIds);
+ next.has(u.id) ? next.delete(u.id) : next.add(u.id);
+ onChange(next);
+ }} style={{ accentColor: 'var(--primary)', width: 15, height: 15 }} />
+
+ {u.display_name || u.name}
+ {u.role}
+
+ ))}
+ {filtered.length === 0 &&
No users found
}
+
+
+ );
+}
+
+// ── All Groups tab ────────────────────────────────────────────────────────────
+function AllGroupsTab({ allUsers, onRefresh }) {
+ const toast = useToast();
+ const [groups, setGroups] = useState([]);
+ const [selected, setSelected] = useState(null); // selected user group
+ const [members, setMembers] = useState(new Set());
+ const [editName, setEditName] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [showDelete, setShowDelete] = useState(false);
+
+ const load = () => api.getUserGroups().then(({ groups }) => setGroups(groups)).catch(() => {});
+ useEffect(() => { load(); }, []);
+
+ const selectGroup = async (g) => {
+ const { group, members: mems } = await api.getUserGroup(g.id);
+ setSelected(group);
+ setEditName(group.name);
+ setMembers(new Set(mems.map(m => m.id)));
+ };
+
+ const handleSave = async () => {
+ if (!editName.trim()) return toast('Name required', 'error');
+ setSaving(true);
+ try {
+ await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members] });
+ toast('Group updated', 'success');
+ load();
+ setSelected(prev => ({ ...prev, name: editName.trim() }));
+ onRefresh();
+ } catch (e) { toast(e.message, 'error'); }
+ finally { setSaving(false); }
+ };
+
+ const handleDelete = async () => {
+ setDeleting(true);
+ try {
+ await api.deleteUserGroup(selected.id);
+ toast('Group deleted', 'success');
+ setSelected(null); setShowDelete(false);
+ load(); onRefresh();
+ } catch (e) { toast(e.message, 'error'); }
+ finally { setDeleting(false); }
+ };
+
+ return (
+
+ {/* Group list */}
+
+
User Groups
+
+ {groups.map(g => (
+
selectGroup(g)} style={{
+ textAlign: 'left', padding: '9px 12px', borderRadius: 8, border: 'none',
+ background: selected?.id === g.id ? 'var(--primary-light)' : 'transparent',
+ color: selected?.id === g.id ? 'var(--primary)' : 'var(--text-primary)',
+ cursor: 'pointer', fontWeight: selected?.id === g.id ? 600 : 400, fontSize: 14,
+ }}>
+ {g.name}
+ {g.member_count} member{g.member_count !== 1 ? 's' : ''}
+
+ ))}
+ {groups.length === 0 &&
No groups yet
}
+
+
+
+ {/* Edit panel */}
+
+ {!selected ? (
+
Select a group to edit
+ ) : (
+
+
+ Group Name
+ setEditName(e.target.value)} style={{ marginTop: 6 }} />
+
+
+
+ {saving ? 'Saving…' : 'Save Changes'}
+ { setSelected(null); setShowDelete(false); }}>Cancel
+ setShowDelete(true)}>Delete Group
+
+ {showDelete && (
+
+
Delete {selected.name} ? This also deletes the associated direct message group. This cannot be undone.
+
+ {deleting ? 'Deleting…' : 'Yes, Delete'}
+ setShowDelete(false)}>Cancel
+
+
+ )}
+
+ )}
+
+
+ );
+}
+
+// ── Create Group tab ──────────────────────────────────────────────────────────
+function CreateGroupTab({ allUsers, onRefresh }) {
+ const toast = useToast();
+ const [name, setName] = useState('');
+ const [members, setMembers] = useState(new Set());
+ const [saving, setSaving] = useState(false);
+
+ const handleCreate = async () => {
+ if (!name.trim()) return toast('Group name required', 'error');
+ setSaving(true);
+ try {
+ await api.createUserGroup({ name: name.trim(), memberIds: [...members] });
+ toast(`Group "${name.trim()}" created`, 'success');
+ setName(''); setMembers(new Set());
+ onRefresh();
+ } catch (e) { toast(e.message, 'error'); }
+ finally { setSaving(false); }
+ };
+
+ return (
+
+
+
Group Name
+
setName(e.target.value)} placeholder="e.g. Coaches" style={{ marginTop: 6 }} onKeyDown={e => e.key === 'Enter' && handleCreate()} />
+
A matching Direct Message group will be created automatically with the same name.
+
+
+
Members
+
+
+
+
{members.size} user{members.size !== 1 ? 's' : ''} selected
+
+
+ {saving ? 'Creating…' : 'Create Group'}
+ { setName(''); setMembers(new Set()); }}>Clear
+
+
+ );
+}
+
+// ── Direct Messages tab ───────────────────────────────────────────────────────
+function DirectMessagesTab({ allUsers }) {
+ const toast = useToast();
+ const [groups, setGroups] = useState([]); // user groups
+ const [dmGroups, setDmGroups] = useState([]); // managed DM groups
+ const [selectedDm, setSelectedDm] = useState(null);
+ const [dmName, setDmName] = useState('');
+ const [dmGroupMembers, setDmGroupMembers] = useState(new Set()); // selected user group IDs
+ const [saving, setSaving] = useState(false);
+
+ const load = async () => {
+ const [ugRes] = await Promise.all([api.getUserGroups()]);
+ setGroups(ugRes.groups || []);
+ // Also need managed DM groups — these are the paired DMs (from groups table, is_managed=1, is_direct=0)
+ // We get them via the user groups list (each has dm_group_id)
+ setDmGroups(ugRes.groups || []);
+ };
+ useEffect(() => { load(); }, []);
+
+ const selectDm = (g) => {
+ setSelectedDm(g);
+ setDmName(g.name);
+ // Pre-select this group (since each managed DM maps 1:1 to a user group)
+ setDmGroupMembers(new Set([g.id]));
+ };
+
+ const handleSave = async () => {
+ if (!dmName.trim()) return toast('Name required', 'error');
+ if (!selectedDm) return;
+ saving || setSaving(true);
+ try {
+ await api.updateUserGroup(selectedDm.id, { name: dmName.trim() });
+ toast('Direct message group renamed', 'success');
+ load();
+ setSelectedDm(null); setDmName(''); setDmGroupMembers(new Set());
+ } catch (e) { toast(e.message, 'error'); }
+ finally { setSaving(false); }
+ };
+
+ const handleCancel = () => { setSelectedDm(null); setDmName(''); setDmGroupMembers(new Set()); };
+
+ return (
+
+
+
Managed DM Groups
+ {dmGroups.map(g => (
+
selectDm(g)} style={{
+ display: 'block', width: '100%', textAlign: 'left', padding: '9px 12px', borderRadius: 8, border: 'none',
+ background: selectedDm?.id === g.id ? 'var(--primary-light)' : 'transparent',
+ color: selectedDm?.id === g.id ? 'var(--primary)' : 'var(--text-primary)',
+ cursor: 'pointer', fontWeight: selectedDm?.id === g.id ? 600 : 400, fontSize: 14, marginBottom: 2,
+ }}>
+
+
+ ))}
+ {dmGroups.length === 0 &&
No managed DM groups
}
+
+
+
+ {!selectedDm ? (
+
Select a group to edit its name
+ ) : (
+
+
+
Display Name
+
setDmName(e.target.value)} style={{ marginTop: 6 }} />
+
Renaming this also renames the paired user group.
+
+
+ {saving ? 'Saving…' : 'Save'}
+ Cancel
+
+
+ )}
+
+
+ );
+}
+
+// ── Main modal ────────────────────────────────────────────────────────────────
+export default function GroupManagerModal({ onClose }) {
+ const [tab, setTab] = useState('all');
+ const [allUsers, setAllUsers] = useState([]);
+ const [refreshKey, setRefreshKey] = useState(0);
+ const onRefresh = () => setRefreshKey(k => k + 1);
+
+ useEffect(() => {
+ // Fetch all active users (ignore allow_dm — admin can add anyone)
+ api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status === 'active'))).catch(() => {});
+ }, [refreshKey]);
+
+ const tabs = [
+ { id: 'all', label: 'All Groups' },
+ { id: 'create', label: 'Create Group' },
+ { id: 'dm', label: 'Direct Messages' },
+ ];
+
+ return (
+ e.target === e.currentTarget && onClose()}>
+
+ {/* Header */}
+
+
Group Manager
+
+
+
+
+
+ {/* Tab buttons */}
+
+ {tabs.map(t => (
+ setTab(t.id)}>{t.label}
+ ))}
+
+
+ {/* Tab content */}
+
+ {tab === 'all' &&
}
+ {tab === 'create' &&
}
+ {tab === 'dm' &&
}
+
+
+
+ );
+}
diff --git a/frontend/src/components/NavDrawer.css b/frontend/src/components/NavDrawer.css
new file mode 100644
index 0000000..da7673b
--- /dev/null
+++ b/frontend/src/components/NavDrawer.css
@@ -0,0 +1,79 @@
+.nav-drawer-backdrop {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.4);
+ z-index: 1200;
+ opacity: 0;
+ transition: opacity 0.25s;
+}
+.nav-drawer-backdrop.open {
+ display: block;
+ opacity: 1;
+}
+
+.nav-drawer {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 260px;
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ z-index: 1300;
+ transform: translateX(-100%);
+ transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
+ display: flex;
+ flex-direction: column;
+ padding: 20px 12px;
+ overflow-y: auto;
+ box-shadow: 4px 0 24px rgba(0,0,0,0.12);
+}
+.nav-drawer.open {
+ transform: translateX(0);
+}
+
+.nav-drawer-section-label {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.8px;
+ text-transform: uppercase;
+ color: var(--text-tertiary);
+ padding: 4px 12px 8px;
+ margin-top: 8px;
+}
+.nav-drawer-section-label:first-child { margin-top: 0; }
+.nav-drawer-section-label.admin {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+}
+
+.nav-drawer-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ text-align: left;
+ transition: background var(--transition);
+}
+.nav-drawer-item:hover:not(.disabled) { background: var(--background); }
+.nav-drawer-item.disabled { opacity: 0.45; cursor: default; }
+
+.nav-drawer-badge {
+ margin-left: auto;
+ font-size: 10px;
+ font-weight: 600;
+ background: var(--surface-variant);
+ color: var(--text-tertiary);
+ border-radius: 20px;
+ padding: 2px 7px;
+}
diff --git a/frontend/src/components/NavDrawer.jsx b/frontend/src/components/NavDrawer.jsx
new file mode 100644
index 0000000..b4f282d
--- /dev/null
+++ b/frontend/src/components/NavDrawer.jsx
@@ -0,0 +1,71 @@
+import { useEffect, useRef } from 'react';
+import { useAuth } from '../contexts/AuthContext.jsx';
+import './NavDrawer.css';
+
+const NAV_ICON = {
+ messages: ,
+ schedules: ,
+ users: ,
+ groups: ,
+ branding: ,
+ settings: ,
+};
+
+export default function NavDrawer({ open, onClose, onMessages, onGroupManager, onBranding, onSettings, onUsers, features = {} }) {
+ const { user } = useAuth();
+ const drawerRef = useRef(null);
+ const isAdmin = user?.role === 'admin';
+
+ // Close on outside click
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e) => {
+ if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose();
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, [open, onClose]);
+
+ // Close on Escape
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [open, onClose]);
+
+ const item = (icon, label, onClick, disabled = false) => (
+ { onClose(); onClick(); }}
+ disabled={disabled}
+ >
+ {icon}
+ {label}
+ {disabled && Coming soon }
+
+ );
+
+ return (
+ <>
+ {/* Backdrop */}
+
+ {/* Drawer */}
+
+
Menu
+ {item(NAV_ICON.messages, 'Messages', onMessages)}
+ {item(NAV_ICON.schedules, 'Schedules', () => {}, true)}
+
+ {isAdmin && (
+ <>
+
Admin
+ {item(NAV_ICON.users, 'User Manager', onUsers)}
+ {features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager)}
+ {features.branding && item(NAV_ICON.branding, 'Branding', onBranding)}
+ {item(NAV_ICON.settings, 'Settings', onSettings)}
+ >
+ )}
+
+ >
+ );
+}
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx
index f564600..e38a06a 100644
--- a/frontend/src/components/SettingsModal.jsx
+++ b/frontend/src/components/SettingsModal.jsx
@@ -2,16 +2,27 @@ import { useState, useEffect } from 'react';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
-export default function SettingsModal({ onClose }) {
+export default function SettingsModal({ onClose, onFeaturesChanged }) {
const toast = useToast();
const [vapidPublic, setVapidPublic] = useState('');
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [showRegenWarning, setShowRegenWarning] = useState(false);
+ // Registration
+ const [regCode, setRegCode] = useState('');
+ const [activeCode, setActiveCode] = useState('');
+ const [features, setFeatures] = useState({ branding: false, groupManager: false });
+ const [regLoading, setRegLoading] = useState(false);
+
useEffect(() => {
api.getSettings().then(({ settings }) => {
setVapidPublic(settings.vapid_public || '');
+ setActiveCode(settings.registration_code || '');
+ setFeatures({
+ branding: settings.feature_branding === 'true',
+ groupManager: settings.feature_group_manager === 'true',
+ });
setLoading(false);
}).catch(() => setLoading(false));
}, []);
@@ -31,17 +42,35 @@ export default function SettingsModal({ onClose }) {
};
const handleGenerateClick = () => {
- if (vapidPublic) {
- setShowRegenWarning(true);
- } else {
- doGenerate();
+ if (vapidPublic) setShowRegenWarning(true);
+ else doGenerate();
+ };
+
+ const handleRegister = async () => {
+ setRegLoading(true);
+ try {
+ const { features: f } = await api.registerCode(regCode.trim());
+ setFeatures(f);
+ setActiveCode(regCode.trim());
+ setRegCode('');
+ toast(regCode.trim() ? 'Registration code applied.' : 'Registration cleared.', 'success');
+ window.dispatchEvent(new Event('jama:settings-changed'));
+ onFeaturesChanged && onFeaturesChanged(f);
+ } catch (e) {
+ toast(e.message || 'Invalid code', 'error');
+ } finally {
+ setRegLoading(false);
}
};
+ const featureList = [
+ features.branding && 'Branding',
+ features.groupManager && 'Group Manager',
+ ].filter(Boolean);
+
return (
e.target === e.currentTarget && onClose()}>
-
Settings
@@ -49,84 +78,78 @@ export default function SettingsModal({ onClose }) {
-
-
Web Push Notifications (VAPID)
+ {/* Registration Code */}
+
+
Feature Registration
+ {activeCode && featureList.length > 0 && (
+
+
+ Active — unlocks: {featureList.join(', ')}
+
+ )}
+
+ setRegCode(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleRegister()}
+ />
+
+ {regLoading ? '…' : 'Apply'}
+
+
+ {activeCode && (
+
{ setRegCode(''); api.registerCode('').then(() => { setFeatures({ branding: false, groupManager: false }); setActiveCode(''); window.dispatchEvent(new Event('jama:settings-changed')); onFeaturesChanged && onFeaturesChanged({ branding: false, groupManager: false }); }); }}>
+ Clear Registration
+
+ )}
+
+ A registration code unlocks Branding and Group Manager features. Contact your jama provider for a code.
+
+
+
+
Web Push Notifications (VAPID)
{loading ? (
-
Loading…
+
Loading…
) : (
<>
{vapidPublic ? (
-
-
- Public Key
-
-
- {vapidPublic}
-
+
+
Public Key
+
{vapidPublic}
-
+
Push notifications active
) : (
-
- No VAPID keys found. Generate keys to enable Web Push notifications — users can then receive alerts when the app is closed or in the background.
+
+ No VAPID keys found. Generate keys to enable Web Push notifications.
)}
-
{showRegenWarning && (
-
-
- ⚠️ Regenerate VAPID keys?
+
+
⚠️ Regenerate VAPID keys?
+
+ Generating new keys will invalidate all existing push subscriptions . Users will need to re-enable notifications.
-
- Generating new keys will invalidate all existing push subscriptions . Every user will stop receiving push notifications immediately and will need to re-enable them by opening the app. This cannot be undone.
-
-
-
- {generating ? "Generating…" : "Yes, regenerate keys"}
-
-
setShowRegenWarning(false)}>
- Cancel
-
+
+ {generating ? 'Generating…' : 'Yes, regenerate keys'}
+ setShowRegenWarning(false)}>Cancel
)}
-
{!showRegenWarning && (
- {generating ? "Generating…" : vapidPublic ? "Regenerate Keys" : "Generate Keys"}
+ {generating ? 'Generating…' : vapidPublic ? 'Regenerate Keys' : 'Generate Keys'}
)}
-
-
- Requires HTTPS. After generating, users will be prompted to enable notifications on their next visit. On iOS, the app must be installed to the home screen first.
+
+ Requires HTTPS. On iOS, the app must be installed to the home screen first.
>
)}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index e271400..70844b3 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -51,7 +51,7 @@ function formatTime(dateStr) {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
-export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set() }) {
+export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupManager, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set(), features = {} }) {
const { user, logout } = useAuth();
const { connected } = useSocket();
const toast = useToast();
@@ -107,8 +107,10 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
onClick={() => onSelectGroup(group.id)}
>
- {group.is_direct && group.peer_avatar ? (
+ {group.is_direct && group.peer_avatar && !group.is_managed ? (
+ ) : group.is_managed ? (
+
MG
) : (
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
@@ -225,23 +227,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
Profile
- {user?.role === 'admin' && (
- <>
-
{ setShowMenu(false); onUsers(); }}>
-
- User Manager
-
-
{ setShowMenu(false); onBranding && onBranding(); }}>
-
- Branding
-
-
{ setShowMenu(false); onOpenSettings(); }}>
-
- Settings
-
- >
- )}
-
{ setShowMenu(false); onHelp && onHelp(); }}>
Help
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx
index 3f52e1a..f2d9d39 100644
--- a/frontend/src/pages/Chat.jsx
+++ b/frontend/src/pages/Chat.jsx
@@ -13,6 +13,8 @@ import NewChatModal from '../components/NewChatModal.jsx';
import GlobalBar from '../components/GlobalBar.jsx';
import AboutModal from '../components/AboutModal.jsx';
import HelpModal from '../components/HelpModal.jsx';
+import NavDrawer from '../components/NavDrawer.jsx';
+import GroupManagerModal from '../components/GroupManagerModal.jsx';
import './Chat.css';
function urlBase64ToUint8Array(base64String) {
@@ -34,7 +36,9 @@ export default function Chat() {
const [activeGroupId, setActiveGroupId] = useState(null);
const [notifications, setNotifications] = useState([]);
const [unreadGroups, setUnreadGroups] = useState(new Map());
- const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help'
+ const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [features, setFeatures] = useState({ branding: false, groupManager: false });
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true);
@@ -65,6 +69,24 @@ export default function Chat() {
useEffect(() => { loadGroups(); }, [loadGroups]);
+ // Load feature flags on mount
+ useEffect(() => {
+ api.getSettings().then(({ settings }) => {
+ setFeatures({
+ branding: settings.feature_branding === 'true',
+ groupManager: settings.feature_group_manager === 'true',
+ });
+ }).catch(() => {});
+ const handler = () => api.getSettings().then(({ settings }) => {
+ setFeatures({
+ branding: settings.feature_branding === 'true',
+ groupManager: settings.feature_group_manager === 'true',
+ });
+ }).catch(() => {});
+ window.addEventListener('jama:settings-changed', handler);
+ return () => window.removeEventListener('jama:settings-changed', handler);
+ }, []);
+
// Register / refresh push subscription
useEffect(() => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
@@ -303,7 +325,7 @@ export default function Chat() {
return (
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
-
+
setDrawerOpen(true)} />
{(!isMobile || showSidebar) && (
@@ -318,6 +340,8 @@ export default function Chat() {
onUsers={() => setModal('users')}
onSettings={() => setModal('settings')}
onBranding={() => setModal('branding')}
+ onGroupManager={() => setModal('groupmanager')}
+ features={features}
onGroupsUpdated={loadGroups}
isMobile={isMobile}
onAbout={() => setModal('about')}
@@ -337,10 +361,21 @@ export default function Chat() {
)}
+ setDrawerOpen(false)}
+ onMessages={() => { setDrawerOpen(false); }}
+ onGroupManager={() => { setDrawerOpen(false); setModal('groupmanager'); }}
+ onBranding={() => { setDrawerOpen(false); setModal('branding'); }}
+ onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
+ onUsers={() => { setDrawerOpen(false); setModal('users'); }}
+ features={features}
+ />
{modal === 'profile' && setModal(null)} />}
{modal === 'users' && setModal(null)} />}
- {modal === 'settings' && setModal(null)} />}
+ {modal === 'settings' && setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && setModal(null)} />}
+ {modal === 'groupmanager' && setModal(null)} />}
{modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && setModal(null)} />}
{modal === 'help' && setModal(null)} dismissed={helpDismissed} />}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index d8ba50b..1816ec1 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -101,6 +101,14 @@ export const api = {
getSettings: () => req('GET', '/settings'),
updateAppName: (name) => req('PATCH', '/settings/app-name', { name }),
updateColors: (body) => req('PATCH', '/settings/colors', body),
+ registerCode: (code) => req('POST', '/settings/register', { code }),
+
+ // User groups (Group Manager)
+ getUserGroups: () => req('GET', '/usergroups'),
+ getUserGroup: (id) => req('GET', `/usergroups/${id}`),
+ createUserGroup: (body) => req('POST', '/usergroups', body),
+ updateUserGroup: (id, body) => req('PATCH', `/usergroups/${id}`, body),
+ deleteUserGroup: (id) => req('DELETE', `/usergroups/${id}`),
uploadLogo: (file) => {
const form = new FormData(); form.append('logo', file);
return req('POST', '/settings/logo', form);