From 241d913e0fc150c5287000387664f55d342f1021 Mon Sep 17 00:00:00 2001
From: Ricky Stretch
Date: Fri, 20 Mar 2026 20:27:44 -0400
Subject: [PATCH] v0.10.5 added some new permission options
---
backend/package.json | 2 +-
backend/src/models/db.js | 30 ++-
.../migrations/005_u2u_restrictions.sql | 30 +++
backend/src/routes/groups.js | 40 ++++
backend/src/routes/host.js | 5 +-
backend/src/routes/usergroups.js | 39 ++++
build.sh | 2 +-
frontend/package.json | 2 +-
frontend/src/components/UserProfilePopup.jsx | 13 +-
frontend/src/pages/GroupManagerPage.jsx | 212 ++++++++++++++++++
frontend/src/utils/api.js | 6 +
11 files changed, 374 insertions(+), 7 deletions(-)
create mode 100644 backend/src/models/migrations/005_u2u_restrictions.sql
diff --git a/backend/package.json b/backend/package.json
index 98d9c86..8f7c7aa 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-backend",
- "version": "0.10.3",
+ "version": "0.10.5",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {
diff --git a/backend/src/models/db.js b/backend/src/models/db.js
index 01d5763..24b1786 100644
--- a/backend/src/models/db.js
+++ b/backend/src/models/db.js
@@ -240,6 +240,33 @@ async function seedEventTypes(schema) {
);
}
+async function seedUserGroups(schema) {
+ // Seed three default user groups with their associated DM groups.
+ // Uses ON CONFLICT DO NOTHING so re-runs on existing installs are safe.
+ const defaults = ['Coaches', 'Players', 'Parents'];
+ for (const name of defaults) {
+ // Skip if a group with this name already exists
+ const existing = await queryOne(schema,
+ 'SELECT id FROM user_groups WHERE name = $1', [name]
+ );
+ if (existing) continue;
+
+ // Create the managed DM chat group first
+ const gr = await queryResult(schema,
+ "INSERT INTO groups (name, type, is_readonly, is_managed) VALUES ($1, 'private', FALSE, TRUE) RETURNING id",
+ [name]
+ );
+ const dmGroupId = gr.rows[0].id;
+
+ // Create the user group linked to the DM group
+ await exec(schema,
+ 'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING',
+ [name, dmGroupId]
+ );
+ console.log(`[DB:${schema}] Default user group created: ${name}`);
+ }
+}
+
async function seedAdmin(schema) {
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@jama.local';
@@ -317,6 +344,7 @@ async function initDb() {
await seedSettings('public');
await seedEventTypes('public');
await seedAdmin('public');
+ await seedUserGroups('public');
// Host mode: the public schema is the host's own workspace — always full JAMA-Team plan.
// ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too.
@@ -391,5 +419,5 @@ module.exports = {
tenantMiddleware, resolveSchema, refreshTenantCache,
APP_TYPE, pool,
addUserToPublicGroups, getOrCreateSupportGroup,
- seedSettings, seedEventTypes, seedAdmin,
+ seedSettings, seedEventTypes, seedAdmin, seedUserGroups,
};
diff --git a/backend/src/models/migrations/005_u2u_restrictions.sql b/backend/src/models/migrations/005_u2u_restrictions.sql
new file mode 100644
index 0000000..9b99aec
--- /dev/null
+++ b/backend/src/models/migrations/005_u2u_restrictions.sql
@@ -0,0 +1,30 @@
+-- Migration 005: User-to-user DM restrictions
+--
+-- Stores which user groups are blocked from initiating 1-to-1 DMs with
+-- users in another group. This is an allowlist-by-omission model:
+-- - No rows for a group = no restrictions (can DM anyone)
+-- - A row (A, B) = users in group A cannot INITIATE a DM with users in group B
+--
+-- Enforcement rules:
+-- - Restriction is one-way (A→B does not imply B→A)
+-- - Least-restrictive-wins: if the initiating user is in any group that is
+-- NOT restricted from the target, the DM is allowed
+-- - Own group is always exempt (users can DM members of their own groups)
+-- - Admins are always exempt from all restrictions
+-- - Existing DMs are preserved when a restriction is added
+-- - Only 1-to-1 DMs are affected; group chats (3+ people) are always allowed
+
+CREATE TABLE IF NOT EXISTS user_group_dm_restrictions (
+ restricting_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
+ blocked_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (restricting_group_id, blocked_group_id),
+ -- A group cannot restrict itself (own group is always exempt)
+ CHECK (restricting_group_id != blocked_group_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_dm_restrictions_restricting
+ ON user_group_dm_restrictions(restricting_group_id);
+
+CREATE INDEX IF NOT EXISTS idx_dm_restrictions_blocked
+ ON user_group_dm_restrictions(blocked_group_id);
diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js
index 6c223bf..37bdb47 100644
--- a/backend/src/routes/groups.js
+++ b/backend/src/routes/groups.js
@@ -97,6 +97,46 @@ router.post('/', authMiddleware, async (req, res) => {
// Direct message
if (isDirect && memberIds?.length === 1) {
const otherUserId = memberIds[0], userId = req.user.id;
+
+ // U2U restriction check — admins always exempt
+ if (req.user.role !== 'admin') {
+ // Get all user groups the initiating user belongs to
+ const initiatorGroups = await query(req.schema,
+ 'SELECT user_group_id FROM user_group_members WHERE user_id = $1', [userId]
+ );
+ const initiatorGroupIds = initiatorGroups.map(r => r.user_group_id);
+
+ // Get all user groups the target user belongs to
+ const targetGroups = await query(req.schema,
+ 'SELECT user_group_id FROM user_group_members WHERE user_id = $1', [otherUserId]
+ );
+ const targetGroupIds = targetGroups.map(r => r.user_group_id);
+
+ // Least-restrictive-wins: the initiator needs at least ONE group
+ // that has no restriction against ALL of the target's groups.
+ // If initiatorGroups is empty, no restrictions apply (user not in any managed group).
+ if (initiatorGroupIds.length > 0 && targetGroupIds.length > 0) {
+ // For each initiator group, check if it is restricted from ANY of the target groups
+ let canDm = false;
+ for (const igId of initiatorGroupIds) {
+ const restrictions = await query(req.schema,
+ 'SELECT blocked_group_id FROM user_group_dm_restrictions WHERE restricting_group_id = $1',
+ [igId]
+ );
+ const blockedIds = new Set(restrictions.map(r => r.blocked_group_id));
+ // This initiator group is unrestricted if none of the target's groups are blocked
+ const isRestricted = targetGroupIds.some(tgId => blockedIds.has(tgId));
+ if (!isRestricted) { canDm = true; break; }
+ }
+ if (!canDm) {
+ return res.status(403).json({
+ error: 'Direct messages with this user are not permitted.',
+ code: 'DM_RESTRICTED'
+ });
+ }
+ }
+ }
+
const existing = await queryOne(req.schema, `
SELECT g.id FROM groups g
JOIN group_members gm1 ON gm1.group_id=g.id AND gm1.user_id=$1
diff --git a/backend/src/routes/host.js b/backend/src/routes/host.js
index c53ab8b..6a75037 100644
--- a/backend/src/routes/host.js
+++ b/backend/src/routes/host.js
@@ -13,7 +13,7 @@ const router = express.Router();
const {
query, queryOne, queryResult, exec,
runMigrations, ensureSchema,
- seedSettings, seedEventTypes, seedAdmin,
+ seedSettings, seedEventTypes, seedAdmin, seedUserGroups,
refreshTenantCache,
} = require('../models/db');
@@ -123,6 +123,9 @@ router.post('/tenants', async (req, res) => {
// 3. Seed event types
await seedEventTypes(schemaName);
+ // 3b. Seed default user groups (Coaches, Players, Parents)
+ await seedUserGroups(schemaName);
+
// 4. Seed admin user — temporarily override env vars for this tenant
const origEmail = process.env.ADMIN_EMAIL;
const origName = process.env.ADMIN_NAME;
diff --git a/backend/src/routes/usergroups.js b/backend/src/routes/usergroups.js
index 46be700..c04cbca 100644
--- a/backend/src/routes/usergroups.js
+++ b/backend/src/routes/usergroups.js
@@ -310,5 +310,44 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
} catch (e) { res.status(500).json({ error: e.message }); }
});
+
+// ── U2U DM Restrictions ───────────────────────────────────────────────────────
+
+// GET /:id/restrictions — get blocked group IDs for a user group
+router.get('/:id/restrictions', authMiddleware, teamManagerMiddleware, async (req, res) => {
+ try {
+ const rows = await query(req.schema,
+ 'SELECT blocked_group_id FROM user_group_dm_restrictions WHERE restricting_group_id = $1',
+ [req.params.id]
+ );
+ res.json({ blockedGroupIds: rows.map(r => r.blocked_group_id) });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// PUT /:id/restrictions — replace the full restriction list for a user group
+// Body: { blockedGroupIds: [id, id, ...] }
+router.put('/:id/restrictions', authMiddleware, teamManagerMiddleware, async (req, res) => {
+ const { blockedGroupIds = [] } = req.body;
+ const restrictingId = parseInt(req.params.id);
+ try {
+ const ug = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE id = $1', [restrictingId]);
+ if (!ug) return res.status(404).json({ error: 'User group not found' });
+
+ // Clear all existing restrictions for this group then insert new ones
+ await exec(req.schema,
+ 'DELETE FROM user_group_dm_restrictions WHERE restricting_group_id = $1',
+ [restrictingId]
+ );
+ for (const blockedId of blockedGroupIds) {
+ if (parseInt(blockedId) === restrictingId) continue; // cannot restrict own group
+ await exec(req.schema,
+ 'INSERT INTO user_group_dm_restrictions (restricting_group_id, blocked_group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
+ [restrictingId, parseInt(blockedId)]
+ );
+ }
+ res.json({ success: true, blockedGroupIds });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
return router;
};
diff --git a/build.sh b/build.sh
index 4129028..ab2980c 100644
--- a/build.sh
+++ b/build.sh
@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
-VERSION="${1:-0.10.3}"
+VERSION="${1:-0.10.5}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="jama"
diff --git a/frontend/package.json b/frontend/package.json
index bd60ebc..212ec51 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "jama-frontend",
- "version": "0.10.3",
+ "version": "0.10.5",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/UserProfilePopup.jsx b/frontend/src/components/UserProfilePopup.jsx
index ac69803..5892ea9 100644
--- a/frontend/src/components/UserProfilePopup.jsx
+++ b/frontend/src/components/UserProfilePopup.jsx
@@ -38,9 +38,11 @@ export default function UserProfilePopup({ user: profileUser, anchorEl, onClose,
popup.style.left = `${left}px`;
}, [anchorEl]);
+ const [dmError, setDmError] = useState('');
const handleDM = async () => {
if (!onDirectMessage) return;
setStarting(true);
+ setDmError('');
try {
const { group } = await api.createGroup({
type: 'private',
@@ -50,7 +52,11 @@ export default function UserProfilePopup({ user: profileUser, anchorEl, onClose,
onClose();
onDirectMessage(group);
} catch (e) {
- console.error('DM error', e);
+ if (e.message?.includes('DM_RESTRICTED') || e.message?.includes('not permitted')) {
+ setDmError('Direct messages with this user are not permitted.');
+ } else {
+ console.error('DM error', e);
+ }
} finally {
setStarting(false);
}
@@ -97,7 +103,10 @@ export default function UserProfilePopup({ user: profileUser, anchorEl, onClose,
)}
{!isSelf && onDirectMessage && (
- profileUser.allow_dm === 0 ? (
+ {dmError && (
+ {dmError}
+ )}
+ {profileUser.allow_dm === 0 ? (
{
+ setLoading(true);
+ try {
+ const { blockedGroupIds } = await api.getGroupRestrictions(group.id);
+ const blocked = new Set(blockedGroupIds.map(Number));
+ setBlockedIds(blocked);
+ setSavedBlockedIds(blocked);
+ } catch (e) { toast(e.message, 'error'); }
+ finally { setLoading(false); }
+ };
+
+ const selectGroup = (g) => {
+ setSelectedGroup(g);
+ setSearch('');
+ loadRestrictions(g);
+ };
+
+ const clearSelection = () => {
+ setSelectedGroup(null);
+ setBlockedIds(new Set());
+ setSavedBlockedIds(new Set());
+ };
+
+ const toggleGroup = (id) => {
+ setBlockedIds(prev => {
+ const next = new Set(prev);
+ next.has(id) ? next.delete(id) : next.add(id);
+ return next;
+ });
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ await api.setGroupRestrictions(selectedGroup.id, [...blockedIds]);
+ setSavedBlockedIds(new Set(blockedIds));
+ toast('Restrictions saved', 'success');
+ } catch (e) { toast(e.message, 'error'); }
+ finally { setSaving(false); }
+ };
+
+ const isDirty = [...blockedIds].some(id => !savedBlockedIds.has(id)) ||
+ [...savedBlockedIds].some(id => !blockedIds.has(id));
+
+ // Other groups (excluding the selected group itself)
+ const otherGroups = allUserGroups.filter(g => g.id !== selectedGroup?.id);
+ const filteredGroups = search.trim()
+ ? otherGroups.filter(g => g.name.toLowerCase().includes(search.toLowerCase()))
+ : otherGroups;
+
+ return (
+
+ {/* Group selector sidebar */}
+
+
+ Select Group
+
+ {allUserGroups.map(g => {
+ const hasRestrictions = g.id === selectedGroup?.id ? blockedIds.size > 0 : false;
+ return (
+
selectGroup(g)} style={{
+ display:'block', width:'100%', textAlign:'left', padding:'8px 10px',
+ borderRadius:'var(--radius)', border:'none',
+ background: selectedGroup?.id===g.id ? 'var(--primary-light)' : 'transparent',
+ color: selectedGroup?.id===g.id ? 'var(--primary)' : 'var(--text-primary)',
+ cursor:'pointer', fontWeight: selectedGroup?.id===g.id ? 600 : 400, fontSize:13, marginBottom:2,
+ }}>
+
+
UG
+
+
{g.name}
+
{g.member_count} member{g.member_count!==1?'s':''}
+
+ {hasRestrictions && (
+
+ )}
+
+
+ );
+ })}
+ {allUserGroups.length === 0 && (
+
No user groups yet
+ )}
+
+
+ {/* Restriction editor */}
+
+ {!selectedGroup ? (
+
+
+
+
+
+
Select a group
+
Choose a user group from the left to configure its DM restrictions.
+
+
+ ) : (
+
+ {/* Header */}
+
+
{selectedGroup.name}
+
+ Members of {selectedGroup.name} can initiate 1-to-1 direct messages with members of all checked groups.
+ Unchecking a group blocks initiation — existing conversations are preserved.
+ Admins are always exempt. If a user is in multiple groups, the least restrictive rule applies.
+
+
+
+ {/* Info banner if restrictions exist */}
+ {blockedIds.size > 0 && (
+
+
+
{blockedIds.size} group{blockedIds.size!==1?'s are':' is'} currently blocked from receiving DMs initiated by {selectedGroup.name} members.
+
+ )}
+
+ {/* Search + group list */}
+
+
+ Allowed Groups ({otherGroups.length - blockedIds.size} of {otherGroups.length} allowed)
+
+ setSearch(e.target.value)} style={{ marginBottom:8 }}
+ autoComplete="new-password" />
+
+
+ {loading ? (
+
+ ) : (
+
+ {filteredGroups.length === 0 ? (
+
+ {search ? 'No groups match your search.' : 'No other groups exist.'}
+
+ ) : (
+ filteredGroups.map((g, i) => {
+ const isBlocked = blockedIds.has(g.id);
+ return (
+
+ toggleGroup(g.id)}
+ style={{ accentColor:'var(--primary)', width:16, height:16, flexShrink:0 }} />
+
+
+ {g.name}
+ {isBlocked && BLOCKED }
+
+
{g.member_count} member{g.member_count!==1?'s':''}
+
+
+ );
+ })
+ )}
+
+ )}
+
+ {/* Quick actions */}
+
+ setBlockedIds(new Set())}>
+ Allow All
+
+ setBlockedIds(new Set(otherGroups.map(g => g.id)))}>
+ Block All
+
+
+
+ {/* Save */}
+
+
+ {saving ? 'Saving…' : 'Save Restrictions'}
+
+ {isDirty && (
+ setBlockedIds(new Set(savedBlockedIds))}>
+ Discard Changes
+
+ )}
+ {!isDirty && !saving && (
+ No unsaved changes
+ )}
+
+
+ )}
+
+
+ );
+}
+
// ── Main page ─────────────────────────────────────────────────────────────────
export default function GroupManagerPage() {
const [tab, setTab] = useState('all');
@@ -291,6 +501,7 @@ export default function GroupManagerPage() {
setTab('all')}>User Groups
setTab('dm')}>Multi-Group DMs
+ setTab('u2u')}>U2U Restrictions
@@ -299,6 +510,7 @@ export default function GroupManagerPage() {
{tab==='all' &&
}
{tab==='dm' &&
}
+ {tab==='u2u' &&
}
);
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index eafd92e..aa9f1c2 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -139,11 +139,17 @@ export const api = {
getMultiGroupDms: () => req('GET', '/usergroups/multigroup'),
createMultiGroupDm: (body) => req('POST', '/usergroups/multigroup', body),
deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`),
+ // U2U Restrictions
+ getGroupRestrictions: (id) => req('GET', `/usergroups/${id}/restrictions`),
+ setGroupRestrictions: (id, blockedGroupIds) => req('PUT', `/usergroups/${id}/restrictions`, { blockedGroupIds }),
// Multi-group DMs
getMultiGroupDms: () => req('GET', '/usergroups/multigroup'),
createMultiGroupDm: (body) => req('POST', '/usergroups/multigroup', body),
updateMultiGroupDm: (id, body) => req('PATCH', `/usergroups/multigroup/${id}`, body),
deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`),
+ // U2U Restrictions
+ getGroupRestrictions: (id) => req('GET', `/usergroups/${id}/restrictions`),
+ setGroupRestrictions: (id, blockedGroupIds) => req('PUT', `/usergroups/${id}/restrictions`, { blockedGroupIds }),
uploadLogo: (file) => {
const form = new FormData(); form.append('logo', file);
return req('POST', '/settings/logo', form);