improve(messages): add group roles (admin/member) with role management UI
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

- New PATCH /api/conversations/<id>/members/<uid> endpoint for role changes
- Owner can promote members to admin and demote back to member
- Admin can add/remove members and edit group name (same as owner except role changes)
- Member list shows role labels (Właściciel/Administrator/Członek)
- Fix: state.currentConversation → state.currentConversationId (panel was empty)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-08 16:42:17 +02:00
parent b626e4b76d
commit 195abb0be4
3 changed files with 182 additions and 80 deletions

View File

@ -471,7 +471,7 @@ def api_conversation_detail(conv_id):
@login_required
@member_required
def api_conversation_edit(conv_id):
"""Edit conversation (name, description). Owner only, groups only."""
"""Edit conversation (name, description). Owner or admin only, groups only."""
db = SessionLocal()
try:
conv = db.query(Conversation).filter_by(id=conv_id).first()
@ -479,8 +479,12 @@ def api_conversation_edit(conv_id):
return jsonify({'error': 'Konwersacja nie istnieje'}), 404
if not conv.is_group:
return jsonify({'error': 'Nie można edytować konwersacji 1:1'}), 400
if conv.owner_id != current_user.id:
return jsonify({'error': 'Tylko właściciel może edytować konwersację'}), 403
caller_membership = db.query(ConversationMember).filter_by(
conversation_id=conv_id, user_id=current_user.id
).first()
if not caller_membership or caller_membership.role not in ('owner', 'admin'):
return jsonify({'error': 'Tylko właściciel lub administrator może edytować'}), 403
data = request.get_json(silent=True)
if not data:
@ -543,7 +547,7 @@ def api_conversation_delete(conv_id):
@login_required
@member_required
def api_conversation_add_member(conv_id):
"""Add a member to a group conversation. Owner only."""
"""Add a member to a group conversation. Owner or admin only."""
db = SessionLocal()
try:
conv = db.query(Conversation).filter_by(id=conv_id).first()
@ -551,8 +555,12 @@ def api_conversation_add_member(conv_id):
return jsonify({'error': 'Konwersacja nie istnieje'}), 404
if not conv.is_group:
return jsonify({'error': 'Nie można dodać osób do konwersacji 1:1'}), 400
if conv.owner_id != current_user.id:
return jsonify({'error': 'Tylko właściciel może dodawać członków'}), 403
caller_membership = db.query(ConversationMember).filter_by(
conversation_id=conv_id, user_id=current_user.id
).first()
if not caller_membership or caller_membership.role not in ('owner', 'admin'):
return jsonify({'error': 'Tylko właściciel lub administrator może dodawać członków'}), 403
data = request.get_json(silent=True)
if not data or 'user_id' not in data:
@ -631,7 +639,7 @@ def api_conversation_add_member(conv_id):
@login_required
@member_required
def api_conversation_remove_member(conv_id, user_id):
"""Remove member from conversation. Owner can remove anyone, member can leave."""
"""Remove member from conversation. Owner/admin can remove others, anyone can leave."""
db = SessionLocal()
try:
conv = db.query(Conversation).filter_by(id=conv_id).first()
@ -639,12 +647,22 @@ def api_conversation_remove_member(conv_id, user_id):
return jsonify({'error': 'Konwersacja nie istnieje'}), 404
# Permission check
is_owner = conv.owner_id == current_user.id
caller_membership = db.query(ConversationMember).filter_by(
conversation_id=conv_id, user_id=current_user.id
).first()
if not caller_membership:
return jsonify({'error': 'Brak dostępu do konwersacji'}), 403
is_admin_or_owner = caller_membership.role in ('owner', 'admin')
is_self = user_id == current_user.id
if not is_owner and not is_self:
if not is_admin_or_owner and not is_self:
return jsonify({'error': 'Brak uprawnień'}), 403
# Cannot remove owner
if user_id == conv.owner_id and not is_self:
return jsonify({'error': 'Nie można usunąć właściciela grupy'}), 403
member = db.query(ConversationMember).filter_by(
conversation_id=conv_id, user_id=user_id
).first()
@ -670,6 +688,53 @@ def api_conversation_remove_member(conv_id, user_id):
db.close()
# ============================================================
# 8b. PATCH /api/conversations/<id>/members/<user_id> — Change role
# ============================================================
@bp.route('/api/conversations/<int:conv_id>/members/<int:user_id>', methods=['PATCH'])
@login_required
@member_required
def api_conversation_change_role(conv_id, user_id):
"""Change member role in group conversation. Owner only."""
db = SessionLocal()
try:
conv = db.query(Conversation).filter_by(id=conv_id).first()
if not conv:
return jsonify({'error': 'Konwersacja nie istnieje'}), 404
if conv.owner_id != current_user.id:
return jsonify({'error': 'Tylko właściciel może zmieniać role'}), 403
if user_id == current_user.id:
return jsonify({'error': 'Nie możesz zmienić swojej własnej roli'}), 400
data = request.get_json(silent=True)
if not data or 'role' not in data:
return jsonify({'error': 'Podaj role'}), 400
new_role = data['role']
if new_role not in ('admin', 'member'):
return jsonify({'error': 'Rola musi być "admin" lub "member"'}), 400
member = db.query(ConversationMember).filter_by(
conversation_id=conv_id, user_id=user_id
).first()
if not member:
return jsonify({'error': 'Użytkownik nie jest członkiem konwersacji'}), 404
member.role = new_role
db.commit()
return jsonify({'ok': True, 'role': new_role})
except Exception as e:
db.rollback()
logger.error(f"Error changing role in conversation {conv_id}: {e}")
return jsonify({'error': 'Błąd serwera'}), 500
finally:
db.close()
# ============================================================
# 9. PATCH /api/conversations/<id>/settings — User settings
# ============================================================

View File

@ -188,7 +188,7 @@
renderItem: function (conv) {
var item = el('div', 'conversation-item');
item.dataset.id = conv.id;
if (conv.id === state.currentConversationId) item.classList.add('active');
if (conv.id === state.currentConversationIdId) item.classList.add('active');
if (conv.unread_count > 0) item.classList.add('unread');
// Avatar
@ -262,7 +262,7 @@
},
selectConversation: function (id) {
state.currentConversationId = id;
state.currentConversationIdId = id;
// Update active class
document.querySelectorAll('.conversation-item').forEach(function (el) {
@ -514,7 +514,7 @@
// Sender name (groups, other people's messages)
if (!isMine && msg.sender) {
var conv = state.conversations.find(function (c) { return c.id === state.currentConversationId; });
var conv = state.conversations.find(function (c) { return c.id === state.currentConversationIdId; });
if (conv && conv.is_group) {
var senderName = el('div', 'message-subject', msg.sender.name);
bubble.appendChild(senderName);
@ -636,7 +636,7 @@
var check = el('span', 'read-status');
// Check if other members have read this message
var details = state.conversationDetails[state.currentConversationId];
var details = state.conversationDetails[state.currentConversationIdId];
var isRead = false;
var readAt = null;
@ -663,7 +663,7 @@
var otherMembers = details.members.filter(function (m) {
return m.user_id !== window.__CURRENT_USER__.id;
});
var conv = state.conversations.find(function (c) { return c.id === state.currentConversationId; });
var conv = state.conversations.find(function (c) { return c.id === state.currentConversationIdId; });
var isGroup = conv && conv.is_group;
if (isGroup) {
@ -761,7 +761,7 @@
if (dominated) return;
state.messages[convId].push(msg);
if (convId !== state.currentConversationId) return;
if (convId !== state.currentConversationIdId) return;
var container = document.getElementById('chatMessages');
if (!container) return;
@ -1011,7 +1011,7 @@
list.style.overflowY = 'auto';
state.conversations.forEach(function (conv) {
if (conv.id === state.currentConversationId) return;
if (conv.id === state.currentConversationIdId) return;
var item = el('div', 'conversation-item');
item.style.cursor = 'pointer';
@ -1064,7 +1064,7 @@
} catch (_) {}
}
// Refresh pins
ChatView.loadConversationDetails(state.currentConversationId);
ChatView.loadConversationDetails(state.currentConversationIdId);
},
startEdit: function (msg) {
@ -1122,7 +1122,7 @@
api('/api/messages/' + msg.id, 'PATCH', { content: newContent })
.then(function (updated) {
// Update in state
var msgs = state.messages[state.currentConversationId] || [];
var msgs = state.messages[state.currentConversationIdId] || [];
var idx = msgs.findIndex(function (m) { return m.id === msg.id; });
if (idx !== -1) msgs[idx] = updated;
state.editingMessageId = null;
@ -1136,7 +1136,7 @@
cancelBtn.addEventListener('click', function () {
state.editingMessageId = null;
ChatView.renderMessages(state.messages[state.currentConversationId] || []);
ChatView.renderMessages(state.messages[state.currentConversationIdId] || []);
ChatView.scrollToMessage(msg.id);
});
@ -1162,7 +1162,7 @@
api('/api/messages/' + msg.id, 'DELETE')
.then(function () {
// Update in state
var msgs = state.messages[state.currentConversationId] || [];
var msgs = state.messages[state.currentConversationIdId] || [];
var idx = msgs.findIndex(function (m) { return m.id === msg.id; });
if (idx !== -1) {
msgs[idx].is_deleted = true;
@ -1207,7 +1207,7 @@
await api('/api/messages/' + messageId + '/reactions', 'POST', { emoji: emoji });
}
// Refresh messages to update reactions display
ChatView.loadMessages(state.currentConversationId);
ChatView.loadMessages(state.currentConversationIdId);
} catch (e) {
// silently ignore
}
@ -1216,7 +1216,7 @@
addFromPicker: async function (messageId, emoji) {
try {
await api('/api/messages/' + messageId + '/reactions', 'POST', { emoji: emoji });
ChatView.loadMessages(state.currentConversationId);
ChatView.loadMessages(state.currentConversationIdId);
} catch (e) {
// silently ignore
}
@ -1363,11 +1363,11 @@
},
sendContent: function (html, text) {
if (!state.currentConversationId) return;
if (!state.currentConversationIdId) return;
// Add to queue with current state snapshot
var tempId = 'temp-' + Date.now() + '-' + Math.random();
Composer._sendQueue.push({
convId: state.currentConversationId,
convId: state.currentConversationIdId,
html: html,
text: text,
replyTo: state.replyToMessage,
@ -1382,7 +1382,7 @@
// Show optimistic message immediately
ChatView.appendMessage({
id: tempId,
conversation_id: state.currentConversationId,
conversation_id: state.currentConversationIdId,
content: html,
sender_id: window.__CURRENT_USER__ ? window.__CURRENT_USER__.id : null,
sender: window.__CURRENT_USER__ || {},
@ -1405,7 +1405,7 @@
// send: called from button click — reads from Quill
send: async function () {
if (!state.currentConversationId || !state.quill) return;
if (!state.currentConversationIdId || !state.quill) return;
var html = state.quill.root.innerHTML;
var text = state.quill.getText().trim();
@ -1414,7 +1414,7 @@
if (!text && !hasImage && !state.attachedFiles.length) return;
if (!text && hasImage) text = '📷';
var convId = state.currentConversationId;
var convId = state.currentConversationIdId;
var savedReplyTo = state.replyToMessage;
var savedFiles = state.attachedFiles.slice();
@ -1532,17 +1532,17 @@
},
sendTyping: function () {
if (!state.currentConversationId) return;
if (!state.currentConversationIdId) return;
if (state.typingTimeout) clearTimeout(state.typingTimeout);
state.typingTimeout = setTimeout(function () {
api('/api/conversations/' + state.currentConversationId + '/typing', 'POST')
api('/api/conversations/' + state.currentConversationIdId + '/typing', 'POST')
.catch(function () {});
}, 300);
// Debounce: only send after 300ms of no typing, then don't send again for 2s
clearTimeout(state.typingTimeout);
if (!state._lastTypingSent || Date.now() - state._lastTypingSent > 2000) {
state._lastTypingSent = Date.now();
api('/api/conversations/' + state.currentConversationId + '/typing', 'POST')
api('/api/conversations/' + state.currentConversationIdId + '/typing', 'POST')
.catch(function () {});
}
},
@ -1616,8 +1616,8 @@
state.sse.addEventListener('message_pinned', function (e) {
try {
var data = JSON.parse(e.data);
if (data.conversation_id === state.currentConversationId) {
ChatView.loadConversationDetails(state.currentConversationId);
if (data.conversation_id === state.currentConversationIdId) {
ChatView.loadConversationDetails(state.currentConversationIdId);
}
} catch (_) {}
});
@ -1625,8 +1625,8 @@
state.sse.addEventListener('message_unpinned', function (e) {
try {
var data = JSON.parse(e.data);
if (data.conversation_id === state.currentConversationId) {
ChatView.loadConversationDetails(state.currentConversationId);
if (data.conversation_id === state.currentConversationIdId) {
ChatView.loadConversationDetails(state.currentConversationIdId);
}
} catch (_) {}
});
@ -1652,7 +1652,7 @@
var convId = msg.conversation_id;
// Append to current view if active
if (convId === state.currentConversationId) {
if (convId === state.currentConversationIdId) {
ChatView.appendMessage(msg);
// Mark read
api('/api/conversations/' + convId + '/read', 'POST').catch(function () {});
@ -1670,7 +1670,7 @@
created_at: msg.created_at,
},
updated_at: msg.created_at,
unread_count: convId === state.currentConversationId
unread_count: convId === state.currentConversationIdId
? 0
: (conv.unread_count || 0) + 1,
});
@ -1685,8 +1685,8 @@
handleMessageRead: function (data) {
// Update read receipts if viewing the same conversation
if (data.conversation_id === state.currentConversationId) {
var details = state.conversationDetails[state.currentConversationId];
if (data.conversation_id === state.currentConversationIdId) {
var details = state.conversationDetails[state.currentConversationIdId];
if (details && details.members) {
var member = details.members.find(function (m) { return m.user_id === data.user_id; });
if (member) {
@ -1694,13 +1694,13 @@
}
}
// Re-render to update check marks
ChatView.renderMessages(state.messages[state.currentConversationId] || []);
ChatView.renderMessages(state.messages[state.currentConversationIdId] || []);
}
},
handleTyping: function (data) {
var convId = data.conversation_id;
if (convId !== state.currentConversationId) return;
if (convId !== state.currentConversationIdId) return;
var typingEl = document.getElementById('typingIndicator');
var typingName = document.getElementById('typingName');
@ -1732,14 +1732,14 @@
handleReaction: function (data) {
var convId = data.conversation_id;
if (convId !== state.currentConversationId) return;
if (convId !== state.currentConversationIdId) return;
// Reload messages to update reactions
ChatView.loadMessages(convId);
},
handleMessageEdited: function (msg) {
var convId = msg.conversation_id;
if (convId !== state.currentConversationId) return;
if (convId !== state.currentConversationIdId) return;
var msgs = state.messages[convId] || [];
var idx = msgs.findIndex(function (m) { return m.id === msg.id; });
if (idx !== -1) {
@ -1750,7 +1750,7 @@
handleMessageDeleted: function (data) {
var convId = data.conversation_id;
if (convId !== state.currentConversationId) return;
if (convId !== state.currentConversationIdId) return;
var msgs = state.messages[convId] || [];
var idx = msgs.findIndex(function (m) { return m.id === data.id; });
if (idx !== -1) {
@ -1762,8 +1762,8 @@
handlePresence: function (data) {
// Update online dot in header if relevant
if (!state.currentConversationId) return;
var details = state.conversationDetails[state.currentConversationId];
if (!state.currentConversationIdId) return;
var details = state.conversationDetails[state.currentConversationIdId];
if (!details || !details.members) return;
var member = details.members.find(function (m) { return m.user_id === data.user_id; });
@ -1793,8 +1793,8 @@
// Poll for new messages every 5 seconds when SSE is unavailable
if (state.pollingInterval) return;
state.pollingInterval = setInterval(function () {
if (!state.currentConversationId) return;
var convId = state.currentConversationId;
if (!state.currentConversationIdId) return;
var convId = state.currentConversationIdId;
var msgs = state.messages[convId];
if (!msgs || !msgs.length) return;
@ -1889,8 +1889,8 @@
startPolling: function () {
state.presenceInterval = setInterval(function () {
if (state.currentConversationId) {
Presence.fetchForConversation(state.currentConversationId);
if (state.currentConversationIdId) {
Presence.fetchForConversation(state.currentConversationIdId);
}
}, 60000);
},
@ -2513,7 +2513,7 @@
},
loadDetails: async function () {
var convId = state.currentConversation;
var convId = state.currentConversationId;
if (!convId) return;
var details = state.conversationDetails[convId];
@ -2528,19 +2528,22 @@
var nameInput = document.getElementById('groupEditName');
if (nameInput) nameInput.value = details.name || '';
// Owner check
var isOwner = details.members.some(function (m) {
return m.user_id === window.__CURRENT_USER__.id && m.role === 'owner';
// Check caller's role
var myMembership = details.members.find(function (m) {
return m.user_id === window.__CURRENT_USER__.id;
});
var myRole = myMembership ? myMembership.role : 'member';
var canManage = myRole === 'owner' || myRole === 'admin';
var isOwner = myRole === 'owner';
// Save name — show only for owner
// Save name — show for owner/admin
var saveBtn = document.getElementById('saveGroupName');
if (saveBtn) saveBtn.style.display = isOwner ? '' : 'none';
if (nameInput) nameInput.readOnly = !isOwner;
if (saveBtn) saveBtn.style.display = canManage ? '' : 'none';
if (nameInput) nameInput.readOnly = !canManage;
// Add member section — owner only
// Add member section — owner/admin
var addSection = document.getElementById('groupAddMemberSection');
if (addSection) addSection.style.display = isOwner ? '' : 'none';
if (addSection) addSection.style.display = canManage ? '' : 'none';
// Members list
var countEl = document.getElementById('groupMemberCount');
@ -2550,6 +2553,8 @@
if (!listEl) return;
listEl.innerHTML = '';
var roleLabels = { owner: 'Właściciel', admin: 'Administrator', member: 'Członek' };
details.members.forEach(function (m) {
var item = el('div', 'group-member-item');
@ -2562,11 +2567,9 @@
var info = el('div', 'group-member-info');
var nameEl = el('div', 'group-member-name', m.name);
info.appendChild(nameEl);
if (m.role === 'owner') {
var roleEl = el('span', 'group-member-role', ' (właściciel)');
var roleEl = el('span', 'group-member-role', ' \u2014 ' + (roleLabels[m.role] || m.role));
nameEl.appendChild(roleEl);
}
info.appendChild(nameEl);
if (m.company_name) {
var companyEl = el('div', 'group-member-role', m.company_name);
info.appendChild(companyEl);
@ -2575,14 +2578,37 @@
item.appendChild(avatar);
item.appendChild(info);
// Actions (owner can remove others, anyone can see)
if (isOwner && m.user_id !== window.__CURRENT_USER__.id) {
// Actions
var isMe = m.user_id === window.__CURRENT_USER__.id;
if (!isMe && m.role !== 'owner') {
var actions = el('div', 'group-member-actions');
// Role toggle — only owner can change roles
if (isOwner) {
if (m.role === 'member') {
var promoteBtn = el('button', 'btn-member-action', 'Nadaj admina');
promoteBtn.addEventListener('click', function () {
GroupManager.changeRole(convId, m.user_id, 'admin');
});
actions.appendChild(promoteBtn);
} else if (m.role === 'admin') {
var demoteBtn = el('button', 'btn-member-action', 'Odbierz admina');
demoteBtn.addEventListener('click', function () {
GroupManager.changeRole(convId, m.user_id, 'member');
});
actions.appendChild(demoteBtn);
}
}
// Remove — owner or admin can remove
if (canManage) {
var removeBtn = el('button', 'btn-member-action danger', 'Usuń');
removeBtn.addEventListener('click', function () {
GroupManager.removeMember(convId, m.user_id, m.name);
});
actions.appendChild(removeBtn);
}
item.appendChild(actions);
}
@ -2591,7 +2617,7 @@
},
saveName: async function () {
var convId = state.currentConversation;
var convId = state.currentConversationId;
var nameInput = document.getElementById('groupEditName');
if (!convId || !nameInput) return;
@ -2632,6 +2658,17 @@
}
},
changeRole: async function (convId, userId, newRole) {
try {
await api('/api/conversations/' + convId + '/members/' + userId, 'PATCH', { role: newRole });
// Refresh
delete state.conversationDetails[convId];
GroupManager.loadDetails();
} catch (e) {
alert('Nie udało się zmienić roli: ' + e.message);
}
},
searchNewMember: async function (query) {
var suggestions = document.getElementById('groupAddMemberSuggestions');
if (!suggestions) return;
@ -2640,7 +2677,7 @@
query = (query || '').trim();
if (query.length < 2) return;
var convId = state.currentConversation;
var convId = state.currentConversationId;
var details = state.conversationDetails[convId];
var existingIds = details ? details.members.map(function (m) { return m.user_id; }) : [];
@ -2870,9 +2907,9 @@
},
loadAndShow: async function () {
if (!state.currentConversationId) return;
if (!state.currentConversationIdId) return;
try {
var data = await api('/api/conversations/' + state.currentConversationId + '/pins');
var data = await api('/api/conversations/' + state.currentConversationIdId + '/pins');
// API returns array directly, not {pins: [...]}
var pins = Array.isArray(data) ? data : (data.pins || []);
@ -2994,16 +3031,16 @@
if (!chatMessages) return;
chatMessages.addEventListener('scroll', function () {
if (chatMessages.scrollTop < 100 && state.currentConversationId) {
var msgs = state.messages[state.currentConversationId] || [];
var hasMore = state.hasMore[state.currentConversationId];
if (chatMessages.scrollTop < 100 && state.currentConversationIdId) {
var msgs = state.messages[state.currentConversationIdId] || [];
var hasMore = state.hasMore[state.currentConversationIdId];
if (hasMore && msgs.length > 0) {
var oldestId = msgs[0].id;
// Prevent multiple loads
state.hasMore[state.currentConversationId] = false;
state.hasMore[state.currentConversationIdId] = false;
var scrollHeightBefore = chatMessages.scrollHeight;
ChatView.loadMessages(state.currentConversationId, oldestId).then(function () {
ChatView.loadMessages(state.currentConversationIdId, oldestId).then(function () {
// Maintain scroll position
var scrollHeightAfter = chatMessages.scrollHeight;
chatMessages.scrollTop = scrollHeightAfter - scrollHeightBefore;
@ -3023,7 +3060,7 @@
backBtn.addEventListener('click', function () {
var container = document.getElementById('conversationsApp');
if (container) container.classList.remove('show-chat');
state.currentConversationId = null;
state.currentConversationIdId = null;
});
}

View File

@ -326,7 +326,7 @@ window.__CSRF_TOKEN__ = '{{ csrf_token() }}';
// Load conversations.js after data is set
(function() {
var s = document.createElement('script');
s.src = '{{ url_for("static", filename="js/conversations.js") }}?v=20';
s.src = '{{ url_for("static", filename="js/conversations.js") }}?v=21';
document.body.appendChild(s);
})();
{% endblock %}