From 7bc0d26cddaae83b795763d37c31d983c0a6f5b9 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Sun, 15 Mar 2026 16:36:01 -0400 Subject: [PATCH] v0.9.26 added a admin tools --- .env.example | 2 +- backend/package.json | 2 +- backend/src/index.js | 1 + backend/src/models/db.js | 52 +++ backend/src/routes/groups.js | 1 + backend/src/routes/settings.js | 30 ++ backend/src/routes/usergroups.js | 172 ++++++++++ build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/GlobalBar.jsx | 31 +- frontend/src/components/GroupManagerModal.jsx | 306 ++++++++++++++++++ frontend/src/components/NavDrawer.css | 79 +++++ frontend/src/components/NavDrawer.jsx | 71 ++++ frontend/src/components/SettingsModal.jsx | 145 +++++---- frontend/src/components/Sidebar.jsx | 23 +- frontend/src/pages/Chat.jsx | 41 ++- frontend/src/utils/api.js | 8 + 17 files changed, 872 insertions(+), 96 deletions(-) create mode 100644 backend/src/routes/usergroups.js create mode 100644 frontend/src/components/GroupManagerModal.jsx create mode 100644 frontend/src/components/NavDrawer.css create mode 100644 frontend/src/components/NavDrawer.jsx 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} + {appName} {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 => ( + + ))} + {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 => ( + + ))} + {groups.length === 0 &&
No groups yet
} +
+
+ + {/* Edit panel */} +
+ {!selected ? ( +
Select a group to edit
+ ) : ( +
+
+ + setEditName(e.target.value)} style={{ marginTop: 6 }} /> +
+
+ +
+ +
+
+
+ + + +
+ {showDelete && ( +
+

Delete {selected.name}? This also deletes the associated direct message group. This cannot be undone.

+
+ + +
+
+ )} +
+ )} +
+
+ ); +} + +// ── 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 ( +
+
+ + 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.size} user{members.size !== 1 ? 's' : ''} selected

+
+
+ + +
+
+ ); +} + +// ── 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 => ( + + ))} + {dmGroups.length === 0 &&
No managed DM groups
} +
+ +
+ {!selectedDm ? ( +
Select a group to edit its name
+ ) : ( +
+
+ + setDmName(e.target.value)} style={{ marginTop: 6 }} /> +

Renaming this also renames the paired user group.

+
+
+ + +
+
+ )} +
+
+ ); +} + +// ── 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 => ( + + ))} +
+ + {/* 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) => ( + + ); + + 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

-
-
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()} + /> + +
+ {activeCode && ( + + )} +

+ 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. -

-
- - +
+ +
)} - {!showRegenWarning && ( )} - -

- 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.name} + ) : 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' && ( - <> - - - - - )} -