v0.9.26 added a admin tools

This commit is contained in:
2026-03-15 16:36:01 -04:00
parent 53d665cc6f
commit 7bc0d26cdd
17 changed files with 872 additions and 96 deletions

View File

@@ -10,7 +10,7 @@
PROJECT_NAME=jama PROJECT_NAME=jama
# Image version to run (set by build.sh, or use 'latest') # 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 # App port — the host port Docker maps to the container
PORT=3000 PORT=3000

View File

@@ -1,6 +1,6 @@
{ {
"name": "jama-backend", "name": "jama-backend",
"version": "0.9.25", "version": "0.9.26",
"description": "TeamChat backend server", "description": "TeamChat backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -41,6 +41,7 @@ app.use('/api/auth', require('./routes/auth')(io));
app.use('/api/users', require('./routes/users')); app.use('/api/users', require('./routes/users'));
app.use('/api/groups', require('./routes/groups')(io)); app.use('/api/groups', require('./routes/groups')(io));
app.use('/api/messages', require('./routes/messages')(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/settings', require('./routes/settings'));
app.use('/api/about', require('./routes/about')); app.use('/api/about', require('./routes/about'));
app.use('/api/help', require('./routes/help')); app.use('/api/help', require('./routes/help'));

View File

@@ -158,6 +158,25 @@ function initDb() {
UNIQUE(user_id, device), UNIQUE(user_id, device),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 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 // Initialize default settings
@@ -173,6 +192,9 @@ function initDb() {
insertSetting.run('color_title_dark', ''); insertSetting.run('color_title_dark', '');
insertSetting.run('color_avatar_public', ''); insertSetting.run('color_avatar_public', '');
insertSetting.run('color_avatar_dm', ''); 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 // Migration: add hide_admin_tag if upgrading from older version
try { try {
@@ -262,6 +284,36 @@ function initDb() {
console.log('[DB] Migration: pinned_conversations table ready'); console.log('[DB] Migration: pinned_conversations table ready');
} catch (e) { console.error('[DB] pinned_conversations migration error:', e.message); } } 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'); console.log('[DB] Schema initialized');
return db; return db;
} }

View File

@@ -340,6 +340,7 @@ router.delete('/:id/leave', authMiddleware, (req, res) => {
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(req.params.id); 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) 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.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 userId = req.user.id;
const leaverName = req.user.display_name || req.user.name; const leaverName = req.user.display_name || req.user.name;

View File

@@ -134,4 +134,34 @@ router.post('/reset', authMiddleware, adminMiddleware, (req, res) => {
res.json({ success: true }); 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; module.exports = router;

View File

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

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.9.25}" VERSION="${1:-0.9.26}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama" IMAGE_NAME="jama"

View File

@@ -1,6 +1,6 @@
{ {
"name": "jama-frontend", "name": "jama-frontend",
"version": "0.9.25", "version": "0.9.26",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useSocket } from '../contexts/SocketContext.jsx'; import { useSocket } from '../contexts/SocketContext.jsx';
import { api } from '../utils/api.js'; import { api } from '../utils/api.js';
export default function GlobalBar({ isMobile, showSidebar }) { export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
const { connected } = useSocket(); const { connected } = useSocket();
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' }); const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark'); 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(() => {}); api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
window.addEventListener('jama:settings-changed', handler); window.addEventListener('jama:settings-changed', handler);
// Re-render when theme changes so title colour switches correctly
const themeObserver = new MutationObserver(() => { const themeObserver = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark'); setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
}); });
@@ -26,20 +25,34 @@ export default function GlobalBar({ isMobile, showSidebar }) {
const logoUrl = settings.logo_url; const logoUrl = settings.logo_url;
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null; 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; if (isMobile && !showSidebar) return null;
return ( return (
<div className="global-bar"> <div className="global-bar">
{/* Burger menu button */}
<button
onClick={onBurger}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--text-primary)', padding: '4px 6px',
display: 'flex', alignItems: 'center', flexShrink: 0, borderRadius: 8,
marginRight: 4,
}}
title="Menu"
aria-label="Open menu"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div className="global-bar-brand"> <div className="global-bar-brand">
<img <img src={logoUrl || '/icons/jama.png'} alt={appName} className="global-bar-logo" />
src={logoUrl || '/icons/jama.png'}
alt={appName}
className="global-bar-logo"
/>
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span> <span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
</div> </div>
{!connected && ( {!connected && (
<span className="global-bar-offline" title="Offline"> <span className="global-bar-offline" title="Offline">
<span className="offline-dot" /> <span className="offline-dot" />

View File

@@ -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 (
<div>
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)} style={{ marginBottom: 8 }} />
<div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
{filtered.map(u => (
<label key={u.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '9px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
<input type="checkbox" checked={selectedIds.has(u.id)} onChange={() => {
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 }} />
<Avatar user={u} size="sm" />
<span className="flex-1 text-sm">{u.display_name || u.name}</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span>
</label>
))}
{filtered.length === 0 && <div style={{ padding: '16px', textAlign: 'center', color: 'var(--text-tertiary)', fontSize: 13 }}>No users found</div>}
</div>
</div>
);
}
// ── 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 (
<div style={{ display: 'flex', gap: 24, height: '100%' }}>
{/* Group list */}
<div style={{ width: 240, flexShrink: 0, borderRight: '1px solid var(--border)', paddingRight: 20 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-secondary)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.5px' }}>User Groups</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{groups.map(g => (
<button key={g.id} onClick={() => 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,
}}>
<div>{g.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{g.member_count} member{g.member_count !== 1 ? 's' : ''}</div>
</button>
))}
{groups.length === 0 && <div style={{ fontSize: 13, color: 'var(--text-tertiary)', padding: '12px 0' }}>No groups yet</div>}
</div>
</div>
{/* Edit panel */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{!selected ? (
<div style={{ color: 'var(--text-tertiary)', fontSize: 14, paddingTop: 40, textAlign: 'center' }}>Select a group to edit</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
<label className="settings-section-label">Group Name</label>
<input className="input" value={editName} onChange={e => setEditName(e.target.value)} style={{ marginTop: 6 }} />
</div>
<div>
<label className="settings-section-label">Members</label>
<div style={{ marginTop: 6 }}>
<UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} />
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setSelected(null); setShowDelete(false); }}>Cancel</button>
<button className="btn btn-sm" style={{ marginLeft: 'auto', background: 'var(--error)', color: 'white' }} onClick={() => setShowDelete(true)}>Delete Group</button>
</div>
{showDelete && (
<div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '14px 16px' }}>
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>Delete <strong>{selected.name}</strong>? This also deletes the associated direct message group. This cannot be undone.</p>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleDelete} disabled={deleting}>{deleting ? 'Deleting…' : 'Yes, Delete'}</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowDelete(false)}>Cancel</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}
// ── 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 (
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
<label className="settings-section-label">Group Name</label>
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Coaches" style={{ marginTop: 6 }} onKeyDown={e => e.key === 'Enter' && handleCreate()} />
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>A matching Direct Message group will be created automatically with the same name.</p>
</div>
<div>
<label className="settings-section-label">Members</label>
<div style={{ marginTop: 6 }}>
<UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} />
</div>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>{members.size} user{members.size !== 1 ? 's' : ''} selected</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={handleCreate} disabled={saving}>{saving ? 'Creating…' : 'Create Group'}</button>
<button className="btn btn-secondary" onClick={() => { setName(''); setMembers(new Set()); }}>Clear</button>
</div>
</div>
);
}
// ── 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 (
<div style={{ display: 'flex', gap: 24, height: '100%' }}>
<div style={{ width: 240, flexShrink: 0, borderRight: '1px solid var(--border)', paddingRight: 20 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-secondary)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.5px' }}>Managed DM Groups</div>
{dmGroups.map(g => (
<button key={g.id} onClick={() => 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,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: 'var(--primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontSize: 10, fontWeight: 700, flexShrink: 0 }}>MG</div>
{g.name}
</div>
</button>
))}
{dmGroups.length === 0 && <div style={{ fontSize: 13, color: 'var(--text-tertiary)', padding: '12px 0' }}>No managed DM groups</div>}
</div>
<div style={{ flex: 1 }}>
{!selectedDm ? (
<div style={{ color: 'var(--text-tertiary)', fontSize: 14, paddingTop: 40, textAlign: 'center' }}>Select a group to edit its name</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 460 }}>
<div>
<label className="settings-section-label">Display Name</label>
<input className="input" value={dmName} onChange={e => setDmName(e.target.value)} style={{ marginTop: 6 }} />
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>Renaming this also renames the paired user group.</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button className="btn btn-secondary btn-sm" onClick={handleCancel}>Cancel</button>
</div>
</div>
)}
</div>
</div>
);
}
// ── 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 (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 1024, width: '96vw', height: '85vh', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<div className="flex items-center justify-between" style={{ marginBottom: 16, flexShrink: 0 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Group Manager</h2>
<button className="btn-icon" onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* Tab buttons */}
<div className="flex gap-2" style={{ marginBottom: 20, flexShrink: 0 }}>
{tabs.map(t => (
<button key={t.id} className={`btn btn-sm ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t.id)}>{t.label}</button>
))}
</div>
{/* Tab content */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{tab === 'all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} />}
{tab === 'create' && <CreateGroupTab allUsers={allUsers} onRefresh={onRefresh} />}
{tab === 'dm' && <DirectMessagesTab allUsers={allUsers} />}
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,71 @@
import { useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx';
import './NavDrawer.css';
const NAV_ICON = {
messages: <svg width="18" height="18" 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"/></svg>,
schedules: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>,
users: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
groups: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>,
branding: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 2a10 10 0 1 0 10 10"/></svg>,
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
};
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) => (
<button
className={`nav-drawer-item${disabled ? ' disabled' : ''}`}
onClick={disabled ? undefined : () => { onClose(); onClick(); }}
disabled={disabled}
>
{icon}
<span>{label}</span>
{disabled && <span className="nav-drawer-badge">Coming soon</span>}
</button>
);
return (
<>
{/* Backdrop */}
<div className={`nav-drawer-backdrop${open ? ' open' : ''}`} onClick={onClose} />
{/* Drawer */}
<div ref={drawerRef} className={`nav-drawer${open ? ' open' : ''}`}>
<div className="nav-drawer-section-label">Menu</div>
{item(NAV_ICON.messages, 'Messages', onMessages)}
{item(NAV_ICON.schedules, 'Schedules', () => {}, true)}
{isAdmin && (
<>
<div className="nav-drawer-section-label admin">Admin</div>
{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)}
</>
)}
</div>
</>
);
}

View File

@@ -2,16 +2,27 @@ 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';
export default function SettingsModal({ onClose }) { export default function SettingsModal({ onClose, onFeaturesChanged }) {
const toast = useToast(); const toast = useToast();
const [vapidPublic, setVapidPublic] = useState(''); const [vapidPublic, setVapidPublic] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [showRegenWarning, setShowRegenWarning] = 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(() => { useEffect(() => {
api.getSettings().then(({ settings }) => { api.getSettings().then(({ settings }) => {
setVapidPublic(settings.vapid_public || ''); setVapidPublic(settings.vapid_public || '');
setActiveCode(settings.registration_code || '');
setFeatures({
branding: settings.feature_branding === 'true',
groupManager: settings.feature_group_manager === 'true',
});
setLoading(false); setLoading(false);
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
}, []); }, []);
@@ -31,17 +42,35 @@ export default function SettingsModal({ onClose }) {
}; };
const handleGenerateClick = () => { const handleGenerateClick = () => {
if (vapidPublic) { if (vapidPublic) setShowRegenWarning(true);
setShowRegenWarning(true); else doGenerate();
} 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 ( 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: 480 }}> <div className="modal" style={{ maxWidth: 480 }}>
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}> <div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>Settings</h2> <h2 className="modal-title" style={{ margin: 0 }}>Settings</h2>
<button className="btn-icon" onClick={onClose}> <button className="btn-icon" onClick={onClose}>
@@ -49,84 +78,78 @@ export default function SettingsModal({ onClose }) {
</button> </button>
</div> </div>
<div> {/* Registration Code */}
<div className="settings-section-label">Web Push Notifications (VAPID)</div> <div style={{ marginBottom: 28 }}>
<div className="settings-section-label">Feature Registration</div>
{activeCode && featureList.length > 0 && (
<div style={{ marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--success)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Active unlocks: {featureList.join(', ')}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input
className="input flex-1"
placeholder="Enter registration code"
value={regCode}
onChange={e => setRegCode(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleRegister()}
/>
<button className="btn btn-primary btn-sm" onClick={handleRegister} disabled={regLoading}>
{regLoading ? '…' : 'Apply'}
</button>
</div>
{activeCode && (
<button className="btn btn-secondary btn-sm" onClick={() => { 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
</button>
)}
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
A registration code unlocks Branding and Group Manager features. Contact your jama provider for a code.
</p>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
<div className="settings-section-label">Web Push Notifications (VAPID)</div>
{loading ? ( {loading ? (
<p style={{ fontSize: 13, color: "var(--text-secondary)" }}>Loading</p> <p style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Loading</p>
) : ( ) : (
<> <>
{vapidPublic ? ( {vapidPublic ? (
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div style={{ <div style={{ background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 12px', marginBottom: 10 }}>
background: "var(--surface-variant)", <div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}>Public Key</div>
border: "1px solid var(--border)", <code style={{ fontSize: 11, color: 'var(--text-primary)', wordBreak: 'break-all', lineHeight: 1.5, display: 'block' }}>{vapidPublic}</code>
borderRadius: "var(--radius)",
padding: "10px 12px",
marginBottom: 10,
}}>
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.5px" }}>
Public Key
</div>
<code style={{
fontSize: 11,
color: "var(--text-primary)",
wordBreak: "break-all",
lineHeight: 1.5,
display: "block",
}}>
{vapidPublic}
</code>
</div> </div>
<span style={{ fontSize: 13, color: "var(--success)", display: "flex", alignItems: "center", gap: 5 }}> <span style={{ fontSize: 13, color: 'var(--success)', display: 'flex', alignItems: 'center', gap: 5 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Push notifications active Push notifications active
</span> </span>
</div> </div>
) : ( ) : (
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 12 }}> <p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
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.
</p> </p>
)} )}
{showRegenWarning && ( {showRegenWarning && (
<div style={{ <div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '14px 16px', marginBottom: 16 }}>
background: "#fce8e6", <p style={{ fontSize: 13, fontWeight: 600, color: 'var(--error)', marginBottom: 8 }}> Regenerate VAPID keys?</p>
border: "1px solid #f5c6c2", <p style={{ fontSize: 13, color: '#5c2c28', marginBottom: 12, lineHeight: 1.5 }}>
borderRadius: "var(--radius)", Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Users will need to re-enable notifications.
padding: "14px 16px",
marginBottom: 16,
}}>
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--error)", marginBottom: 8 }}>
Regenerate VAPID keys?
</p> </p>
<p style={{ fontSize: 13, color: "#5c2c28", marginBottom: 12, lineHeight: 1.5 }}> <div style={{ display: 'flex', gap: 8 }}>
Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Every user will stop receiving push notifications immediately and will need to re-enable them by opening the app. This cannot be undone. <button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={doGenerate} disabled={generating}>{generating ? 'Generating…' : 'Yes, regenerate keys'}</button>
</p> <button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>Cancel</button>
<div style={{ display: "flex", gap: 8 }}>
<button
className="btn btn-sm"
style={{ background: "var(--error)", color: "white" }}
onClick={doGenerate}
disabled={generating}
>
{generating ? "Generating…" : "Yes, regenerate keys"}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>
Cancel
</button>
</div> </div>
</div> </div>
)} )}
{!showRegenWarning && ( {!showRegenWarning && (
<button className="btn btn-primary btn-sm" onClick={handleGenerateClick} disabled={generating}> <button className="btn btn-primary btn-sm" onClick={handleGenerateClick} disabled={generating}>
{generating ? "Generating…" : vapidPublic ? "Regenerate Keys" : "Generate Keys"} {generating ? 'Generating…' : vapidPublic ? 'Regenerate Keys' : 'Generate Keys'}
</button> </button>
)} )}
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 12, lineHeight: 1.5 }}>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 12, lineHeight: 1.5 }}> Requires HTTPS. On iOS, the app must be installed to the home screen first.
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.
</p> </p>
</> </>
)} )}

View File

@@ -51,7 +51,7 @@ function formatTime(dateStr) {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); 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 { user, logout } = useAuth();
const { connected } = useSocket(); const { connected } = useSocket();
const toast = useToast(); const toast = useToast();
@@ -107,8 +107,10 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
onClick={() => onSelectGroup(group.id)} onClick={() => onSelectGroup(group.id)}
> >
<div className="group-icon-wrap"> <div className="group-icon-wrap">
{group.is_direct && group.peer_avatar ? ( {group.is_direct && group.peer_avatar && !group.is_managed ? (
<img src={group.peer_avatar} alt={group.name} className="group-icon" style={{ objectFit: 'cover', padding: 0 }} /> <img src={group.peer_avatar} alt={group.name} className="group-icon" style={{ objectFit: 'cover', padding: 0 }} />
) : group.is_managed ? (
<div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>MG</div>
) : ( ) : (
<div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#a142f4') }}> <div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#a142f4') }}>
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()} {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
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profile Profile
</button> </button>
{user?.role === 'admin' && (
<>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onUsers(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
User Manager
</button>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onBranding && onBranding(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 6V2M12 22v-4M6 12H2M22 12h-4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
Branding
</button>
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onOpenSettings(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
Settings
</button>
</>
)}
<hr className="divider" style={{ margin: '4px 0' }} />
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onHelp && onHelp(); }}> <button className="footer-menu-item" onClick={() => { setShowMenu(false); onHelp && onHelp(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Help Help

View File

@@ -13,6 +13,8 @@ import NewChatModal from '../components/NewChatModal.jsx';
import GlobalBar from '../components/GlobalBar.jsx'; import GlobalBar from '../components/GlobalBar.jsx';
import AboutModal from '../components/AboutModal.jsx'; import AboutModal from '../components/AboutModal.jsx';
import HelpModal from '../components/HelpModal.jsx'; import HelpModal from '../components/HelpModal.jsx';
import NavDrawer from '../components/NavDrawer.jsx';
import GroupManagerModal from '../components/GroupManagerModal.jsx';
import './Chat.css'; import './Chat.css';
function urlBase64ToUint8Array(base64String) { function urlBase64ToUint8Array(base64String) {
@@ -34,7 +36,9 @@ export default function Chat() {
const [activeGroupId, setActiveGroupId] = useState(null); const [activeGroupId, setActiveGroupId] = useState(null);
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [unreadGroups, setUnreadGroups] = useState(new Map()); 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 [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true); const [showSidebar, setShowSidebar] = useState(true);
@@ -65,6 +69,24 @@ export default function Chat() {
useEffect(() => { loadGroups(); }, [loadGroups]); 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 // Register / refresh push subscription
useEffect(() => { useEffect(() => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return; if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
@@ -303,7 +325,7 @@ export default function Chat() {
return ( return (
<div className="chat-layout"> <div className="chat-layout">
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */} {/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} /> <GlobalBar isMobile={isMobile} showSidebar={showSidebar} onBurger={() => setDrawerOpen(true)} />
<div className="chat-body"> <div className="chat-body">
{(!isMobile || showSidebar) && ( {(!isMobile || showSidebar) && (
@@ -318,6 +340,8 @@ export default function Chat() {
onUsers={() => setModal('users')} onUsers={() => setModal('users')}
onSettings={() => setModal('settings')} onSettings={() => setModal('settings')}
onBranding={() => setModal('branding')} onBranding={() => setModal('branding')}
onGroupManager={() => setModal('groupmanager')}
features={features}
onGroupsUpdated={loadGroups} onGroupsUpdated={loadGroups}
isMobile={isMobile} isMobile={isMobile}
onAbout={() => setModal('about')} onAbout={() => setModal('about')}
@@ -337,10 +361,21 @@ export default function Chat() {
)} )}
</div> </div>
<NavDrawer
open={drawerOpen}
onClose={() => 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' && <ProfileModal onClose={() => setModal(null)} />} {modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />} {modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />} {modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />} {modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'groupmanager' && <GroupManagerModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} {modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />} {modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />} {modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}

View File

@@ -101,6 +101,14 @@ export const api = {
getSettings: () => req('GET', '/settings'), getSettings: () => req('GET', '/settings'),
updateAppName: (name) => req('PATCH', '/settings/app-name', { name }), updateAppName: (name) => req('PATCH', '/settings/app-name', { name }),
updateColors: (body) => req('PATCH', '/settings/colors', body), 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) => { uploadLogo: (file) => {
const form = new FormData(); form.append('logo', file); const form = new FormData(); form.append('logo', file);
return req('POST', '/settings/logo', form); return req('POST', '/settings/logo', form);