v0.10.7 UI rule changes
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.11.6",
|
"version": "0.11.7",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -255,7 +255,15 @@ router.delete('/:id/members/:userId', authMiddleware, async (req, res) => {
|
|||||||
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot remove members from public groups' });
|
if (group.type !== 'private') return res.status(400).json({ error: 'Cannot remove members from public groups' });
|
||||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner or admin can remove members' });
|
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner or admin can remove members' });
|
||||||
const targetId = parseInt(req.params.userId);
|
const targetId = parseInt(req.params.userId);
|
||||||
if (targetId === group.owner_id) return res.status(400).json({ error: 'Cannot remove the group owner' });
|
// Admins can remove the owner only if the owner is a deleted user (orphan cleanup)
|
||||||
|
const targetUser = await queryOne(req.schema, 'SELECT status FROM users WHERE id=$1', [targetId]);
|
||||||
|
const isDeletedOrphan = targetUser?.status === 'deleted';
|
||||||
|
if (targetId === group.owner_id && !isDeletedOrphan && req.user.role !== 'admin') {
|
||||||
|
return res.status(400).json({ error: 'Cannot remove the group owner' });
|
||||||
|
}
|
||||||
|
if (targetId === group.owner_id && !isDeletedOrphan) {
|
||||||
|
return res.status(400).json({ error: 'Cannot remove the group owner' });
|
||||||
|
}
|
||||||
const removedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [targetId]);
|
const removedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [targetId]);
|
||||||
const removedName = removedUser?.display_name || removedUser?.name || 'Unknown';
|
const removedName = removedUser?.display_name || removedUser?.name || 'Unknown';
|
||||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, targetId]);
|
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [group.id, targetId]);
|
||||||
|
|||||||
@@ -349,5 +349,21 @@ router.put('/:id/restrictions', authMiddleware, teamManagerMiddleware, async (re
|
|||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// DELETE /api/usergroups/:id/members/:userId — admin force-remove (for deleted/orphaned users)
|
||||||
|
router.delete('/:id/members/:userId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ugId = parseInt(req.params.id);
|
||||||
|
const userId = parseInt(req.params.userId);
|
||||||
|
const ug = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE id=$1', [ugId]);
|
||||||
|
if (!ug) return res.status(404).json({ error: 'User group not found' });
|
||||||
|
await exec(req.schema,
|
||||||
|
'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2',
|
||||||
|
[ugId, userId]
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.11.6}"
|
VERSION="${1:-0.11.7}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.11.6",
|
"version": "0.11.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -198,14 +198,16 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
|||||||
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
|
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
|
||||||
<Avatar user={m} size="sm" />
|
<Avatar user={m} size="sm" />
|
||||||
<span className="flex-1 text-sm">{m.name}</span>
|
<span className="flex-1 text-sm">{m.name}</span>
|
||||||
{m.id === group.owner_id && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
|
{m.status === 'deleted' && <span className="text-xs" style={{ color: 'var(--error)', marginRight: 4 }}>Deleted</span>}
|
||||||
{canManage && m.id !== group.owner_id && (
|
{m.id === group.owner_id && m.status !== 'deleted' && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
|
||||||
|
{/* Allow removal if: canManage + not owner, OR admin + deleted orphan */}
|
||||||
|
{(( canManage && m.id !== group.owner_id) || (isAdmin && m.status === 'deleted')) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(m)}
|
onClick={() => handleRemove(m)}
|
||||||
title="Remove"
|
title={m.status === 'deleted' ? 'Remove orphaned member' : 'Remove'}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4, lineHeight: 1, transition: 'color var(--transition)' }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: m.status === 'deleted' ? 'var(--error)' : 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4, lineHeight: 1, transition: 'color var(--transition)' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'}
|
onMouseLeave={e => e.currentTarget.style.color = m.status === 'deleted' ? 'var(--error)' : 'var(--text-tertiary)'}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export function SocketProvider({ children }) {
|
|||||||
socket.on('user:online', ({ userId }) => setOnlineUsers(prev => new Set([...prev, userId])));
|
socket.on('user:online', ({ userId }) => setOnlineUsers(prev => new Set([...prev, userId])));
|
||||||
socket.on('user:offline', ({ userId }) => setOnlineUsers(prev => { const s = new Set(prev); s.delete(userId); return s; }));
|
socket.on('user:offline', ({ userId }) => setOnlineUsers(prev => { const s = new Set(prev); s.delete(userId); return s; }));
|
||||||
|
|
||||||
|
// Session displaced: another login on the same device type has kicked this session
|
||||||
|
socket.on('session:displaced', () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
|
||||||
|
});
|
||||||
|
|
||||||
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected
|
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
|||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [savedMembers, setSavedMembers] = useState(new Set());
|
const [savedMembers, setSavedMembers] = useState(new Set());
|
||||||
const [members, setMembers] = useState(new Set());
|
const [members, setMembers] = useState(new Set());
|
||||||
|
const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -78,8 +79,12 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
|||||||
const { members: mems } = await api.getUserGroup(g.id);
|
const { members: mems } = await api.getUserGroup(g.id);
|
||||||
const ids = new Set(mems.map(m => m.id));
|
const ids = new Set(mems.map(m => m.id));
|
||||||
setSelected(g); setEditName(g.name); setMembers(ids); setSavedMembers(ids);
|
setSelected(g); setEditName(g.name); setMembers(ids); setSavedMembers(ids);
|
||||||
|
setFullMembers(mems);
|
||||||
|
};
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set());
|
||||||
|
setShowDelete(false); setFullMembers([]);
|
||||||
};
|
};
|
||||||
const clearSelection = () => { setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set()); setShowDelete(false); };
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editName.trim()) return toast('Name required', 'error');
|
if (!editName.trim()) return toast('Name required', 'error');
|
||||||
@@ -90,7 +95,7 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
|||||||
toast('Group updated', 'success');
|
toast('Group updated', 'success');
|
||||||
const { members: fresh } = await api.getUserGroup(selected.id);
|
const { members: fresh } = await api.getUserGroup(selected.id);
|
||||||
const freshIds = new Set(fresh.map(m => m.id));
|
const freshIds = new Set(fresh.map(m => m.id));
|
||||||
setSavedMembers(freshIds); setMembers(freshIds);
|
setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh);
|
||||||
setSelected(prev => ({ ...prev, name: editName.trim() }));
|
setSelected(prev => ({ ...prev, name: editName.trim() }));
|
||||||
} else {
|
} else {
|
||||||
await api.createUserGroup({ name: editName.trim(), memberIds: [...members] });
|
await api.createUserGroup({ name: editName.trim(), memberIds: [...members] });
|
||||||
@@ -111,6 +116,19 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
|||||||
|
|
||||||
const canDelete = selected && savedMembers.size === 0;
|
const canDelete = selected && savedMembers.size === 0;
|
||||||
const isCreating = !selected;
|
const isCreating = !selected;
|
||||||
|
const deletedMembers = fullMembers.filter(m => m.status === 'deleted');
|
||||||
|
|
||||||
|
const forceRemoveMember = async (m) => {
|
||||||
|
if (!confirm(`Force-remove deleted user "${m.name}" from this group?`)) return;
|
||||||
|
try {
|
||||||
|
await api.removeUserGroupMember(selected.id, m.id);
|
||||||
|
toast(`${m.name} removed`, 'success');
|
||||||
|
const { members: fresh } = await api.getUserGroup(selected.id);
|
||||||
|
const freshIds = new Set(fresh.map(x => x.id));
|
||||||
|
setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh);
|
||||||
|
load(); onRefresh();
|
||||||
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display:'flex', flexDirection: isMobile ? 'column' : 'row', gap:0, height:'100%', minHeight:0, overflow: isMobile ? 'auto' : 'hidden' }}>
|
<div style={{ display:'flex', flexDirection: isMobile ? 'column' : 'row', gap:0, height:'100%', minHeight:0, overflow: isMobile ? 'auto' : 'hidden' }}>
|
||||||
@@ -146,6 +164,25 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
|
|||||||
<div style={{ marginTop:6 }}><UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} onIF={onIF} onIB={onIB} /></div>
|
<div style={{ marginTop:6 }}><UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} onIF={onIF} onIB={onIB} /></div>
|
||||||
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{members.size} selected</p>
|
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{members.size} selected</p>
|
||||||
</div>
|
</div>
|
||||||
|
{deletedMembers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="settings-section-label" style={{ color:'var(--error)' }}>
|
||||||
|
Orphaned Members (deleted users)
|
||||||
|
</label>
|
||||||
|
<div style={{ marginTop:6, border:'1px solid var(--border)', borderRadius:'var(--radius)', overflow:'hidden' }}>
|
||||||
|
{deletedMembers.map(m => (
|
||||||
|
<div key={m.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', borderBottom:'1px solid var(--border)' }}>
|
||||||
|
<span style={{ flex:1, fontSize:13, color:'var(--text-tertiary)', textDecoration:'line-through' }}>{m.name}</span>
|
||||||
|
<span style={{ fontSize:11, color:'var(--error)', marginRight:8 }}>Deleted</span>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => forceRemoveMember(m)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize:11, color:'var(--text-tertiary)', marginTop:4 }}>
|
||||||
|
These users were deleted but remain as group members. Remove them to allow this group to be deleted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display:'flex', gap:8, alignItems:'center', flexWrap:'wrap' }}>
|
<div style={{ display:'flex', gap:8, alignItems:'center', flexWrap:'wrap' }}>
|
||||||
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving?'Saving…':isCreating?'Create Group':'Save Changes'}</button>
|
<button className="btn btn-primary btn-sm" onClick={handleSave} disabled={saving}>{saving?'Saving…':isCreating?'Create Group':'Save Changes'}</button>
|
||||||
{!isCreating && <button className="btn btn-secondary btn-sm" onClick={clearSelection}>Cancel</button>}
|
{!isCreating && <button className="btn btn-secondary btn-sm" onClick={clearSelection}>Cancel</button>}
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export const api = {
|
|||||||
deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`),
|
deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`),
|
||||||
// U2U Restrictions
|
// U2U Restrictions
|
||||||
getGroupRestrictions: (id) => req('GET', `/usergroups/${id}/restrictions`),
|
getGroupRestrictions: (id) => req('GET', `/usergroups/${id}/restrictions`),
|
||||||
|
removeUserGroupMember: (groupId, userId) => req('DELETE', `/usergroups/${groupId}/members/${userId}`),
|
||||||
setGroupRestrictions: (id, blockedGroupIds) => req('PUT', `/usergroups/${id}/restrictions`, { blockedGroupIds }),
|
setGroupRestrictions: (id, blockedGroupIds) => req('PUT', `/usergroups/${id}/restrictions`, { blockedGroupIds }),
|
||||||
// Multi-group DMs
|
// Multi-group DMs
|
||||||
getMultiGroupDms: () => req('GET', '/usergroups/multigroup'),
|
getMultiGroupDms: () => req('GET', '/usergroups/multigroup'),
|
||||||
@@ -149,6 +150,7 @@ export const api = {
|
|||||||
deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`),
|
deleteMultiGroupDm: (id) => req('DELETE', `/usergroups/multigroup/${id}`),
|
||||||
// U2U Restrictions
|
// U2U Restrictions
|
||||||
getGroupRestrictions: (id) => req('GET', `/usergroups/${id}/restrictions`),
|
getGroupRestrictions: (id) => req('GET', `/usergroups/${id}/restrictions`),
|
||||||
|
removeUserGroupMember: (groupId, userId) => req('DELETE', `/usergroups/${groupId}/members/${userId}`),
|
||||||
setGroupRestrictions: (id, blockedGroupIds) => req('PUT', `/usergroups/${id}/restrictions`, { blockedGroupIds }),
|
setGroupRestrictions: (id, blockedGroupIds) => req('PUT', `/usergroups/${id}/restrictions`, { blockedGroupIds }),
|
||||||
uploadLogo: (file) => {
|
uploadLogo: (file) => {
|
||||||
const form = new FormData(); form.append('logo', file);
|
const form = new FormData(); form.append('logo', file);
|
||||||
|
|||||||
Reference in New Issue
Block a user