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 ( + + ); + })} + {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 */} +
+ + 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 ( + + ); + }) + )} +
+ )} + + {/* Quick actions */} +
+ + +
+ + {/* Save */} +
+ + {isDirty && ( + + )} + {!isDirty && !saving && ( + No unsaved changes + )} +
+
+ )} +
+
+ ); +} + // ── Main page ───────────────────────────────────────────────────────────────── export default function GroupManagerPage() { const [tab, setTab] = useState('all'); @@ -291,6 +501,7 @@ export default function GroupManagerPage() {
+
@@ -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);