diff --git a/client/components/home/HomePage.js b/client/components/home/HomePage.js index 599c999..9766706 100644 --- a/client/components/home/HomePage.js +++ b/client/components/home/HomePage.js @@ -121,10 +121,20 @@ Template.home.onCreated(function () { template.gridOptions = createGridOptions(template.shouldShowClockTimes()); + // Helper to get teams where user is leader or admin + template.getAdminOrLeaderTeams = () => { + return Teams.find({ + $or: [ + { leader: Meteor.userId() }, + { admins: Meteor.userId() } + ] + }).fetch(); + }; + // Subscribe to data this.autorun(() => { - const leaderTeams = Teams.find({ leader: Meteor.userId() }).fetch(); - const teamIds = leaderTeams.map(t => t._id); + const adminOrLeaderTeams = template.getAdminOrLeaderTeams(); + const teamIds = adminOrLeaderTeams.map(t => t._id); if (teamIds.length) { this.subscribe('clockEventsForTeams', teamIds); @@ -153,16 +163,16 @@ Template.home.onCreated(function () { template.computeTeamMemberSummary = () => { const startDateStr = template.startDate.get(); const endDateStr = template.endDate.get(); - const leaderTeams = Teams.find({ leader: Meteor.userId() }).fetch(); + const adminOrLeaderTeams = template.getAdminOrLeaderTeams(); - if (!leaderTeams.length) return []; + if (!adminOrLeaderTeams.length) return []; const startDate = new Date(startDateStr + 'T00:00:00'); const endDate = new Date(endDateStr + 'T23:59:59'); - const teamIds = leaderTeams.map(t => t._id); + const teamIds = adminOrLeaderTeams.map(t => t._id); const allMembers = Array.from(new Set( - leaderTeams.flatMap(t => [...(t.members || []), ...(t.admins || []), t.leader].filter(id => id)) + adminOrLeaderTeams.flatMap(t => [...(t.members || []), ...(t.admins || []), t.leader].filter(id => id)) )); const rows = []; @@ -267,8 +277,13 @@ Template.home.onRendered(function () { // Initialize grid when ready instance.autorun(() => { const teamsReady = !isTeamsLoading.get(); - const hasLeaderTeam = !!Teams.findOne({ leader: Meteor.userId() }); - if (!teamsReady || !hasLeaderTeam) return; + const hasAdminOrLeaderTeam = !!Teams.findOne({ + $or: [ + { leader: Meteor.userId() }, + { admins: Meteor.userId() } + ] + }); + if (!teamsReady || !hasAdminOrLeaderTeam) return; Tracker.afterFlush(() => { const gridEl = instance.find('#teamDashboardGrid'); @@ -296,7 +311,12 @@ Template.home.onRendered(function () { instance.startDate.get(); instance.endDate.get(); instance.selectedPreset.get(); - Teams.find({ leader: Meteor.userId() }).fetch(); + Teams.find({ + $or: [ + { leader: Meteor.userId() }, + { admins: Meteor.userId() } + ] + }).fetch(); ClockEvents.find().fetch(); Tickets.find().fetch(); @@ -316,9 +336,14 @@ Template.home.onDestroyed(function () { }); Template.home.helpers({ - // User role helpers + // User role helpers - check if user is leader or admin isTeamLeader() { - return Teams.findOne({ leader: Meteor.userId() }); + return Teams.findOne({ + $or: [ + { leader: Meteor.userId() }, + { admins: Meteor.userId() } + ] + }); }, isFirstTimeUser() { @@ -379,8 +404,13 @@ Template.home.helpers({ // Legacy helpers for team dashboard allClockEvents() { - const leaderTeams = Teams.find({ leader: Meteor.userId() }).fetch(); - const teamIds = leaderTeams.map(t => t._id); + const adminOrLeaderTeams = Teams.find({ + $or: [ + { leader: Meteor.userId() }, + { admins: Meteor.userId() } + ] + }).fetch(); + const teamIds = adminOrLeaderTeams.map(t => t._id); return ClockEvents.find({ teamId: { $in: teamIds } }, { sort: { startTimestamp: -1 } }).fetch(); }, diff --git a/client/components/teams/TeamsPage.html b/client/components/teams/TeamsPage.html index b1e738d..4571661 100644 --- a/client/components/teams/TeamsPage.html +++ b/client/components/teams/TeamsPage.html @@ -9,9 +9,58 @@

{{selectedTeam.name}}

Collaborators:
-
+
{{#each selectedTeam.members}} -
{{#if this.name}}{{this.name}}{{else}}{{this.email}}{{/if}}
+
+
+ {{#if this.name}}{{this.name}}{{else}}{{this.email}}{{/if}} + {{#if isMemberAdmin id}} + 👑 + {{/if}} +
+
+ {{#if canViewMemberDashboard id}} + + {{/if}} + {{#if canPromoteToAdmin id}} + + {{/if}} + {{#if canDemoteFromAdmin id}} + + {{/if}} + {{#if canRemoveMember id}} + + {{/if}} +
+
{{/each}}
diff --git a/client/components/teams/TeamsPage.js b/client/components/teams/TeamsPage.js index 0240726..49bb46d 100644 --- a/client/components/teams/TeamsPage.js +++ b/client/components/teams/TeamsPage.js @@ -1,5 +1,6 @@ import { Template } from 'meteor/templating'; import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; import { Teams } from '../../../collections.js'; import { getUserTeams } from '../../utils/UserTeamUtils.js'; @@ -57,16 +58,88 @@ Template.teams.helpers({ const userId = Meteor.userId(); return !!(team && Array.isArray(team.admins) && userId && team.admins.includes(userId)); }, + canRemoveMember(memberId) { + const instance = Template.instance(); + const teamId = instance.selectedTeamId.get(); + if (!teamId || !memberId) return false; + + const team = Teams.findOne(teamId); + const userId = Meteor.userId(); + if (!team || !userId) return false; + + const isAdmin = Array.isArray(team.admins) && team.admins.includes(userId); + const isLeader = team.leader === memberId; + + // Admins can remove any member except the team leader + return isAdmin && !isLeader; + }, + isMemberAdmin(memberId) { + const instance = Template.instance(); + const teamId = instance.selectedTeamId.get(); + if (!teamId || !memberId) return false; + + const team = Teams.findOne(teamId); + if (!team) return false; + + return Array.isArray(team.admins) && team.admins.includes(memberId); + }, + canPromoteToAdmin(memberId) { + const instance = Template.instance(); + const teamId = instance.selectedTeamId.get(); + if (!teamId || !memberId) return false; + + const team = Teams.findOne(teamId); + const userId = Meteor.userId(); + if (!team || !userId) return false; + + const isAdmin = Array.isArray(team.admins) && team.admins.includes(userId); + const isAlreadyAdmin = Array.isArray(team.admins) && team.admins.includes(memberId); + const isLeader = team.leader === memberId; + + // Admins can promote any member who is not already an admin (leader is effectively always admin) + return isAdmin && !isAlreadyAdmin && !isLeader; + }, + canDemoteFromAdmin(memberId) { + const instance = Template.instance(); + const teamId = instance.selectedTeamId.get(); + if (!teamId || !memberId) return false; + + const team = Teams.findOne(teamId); + const userId = Meteor.userId(); + if (!team || !userId) return false; + + const isAdmin = Array.isArray(team.admins) && team.admins.includes(userId); + const isMemberAdmin = Array.isArray(team.admins) && team.admins.includes(memberId); + const isLeader = team.leader === memberId; + + // Admins can demote other admins, but not the leader + return isAdmin && isMemberAdmin && !isLeader; + }, + canViewMemberDashboard(memberId) { + const instance = Template.instance(); + const teamId = instance.selectedTeamId.get(); + if (!teamId || !memberId) return false; + + const team = Teams.findOne(teamId); + const userId = Meteor.userId(); + if (!team || !userId) return false; + + const isAdmin = Array.isArray(team.admins) && team.admins.includes(userId); + const isLeader = team.leader === userId; + + // Admins and leaders can view any team member's dashboard + return isAdmin || isLeader; + }, }); Template.teams.events({ 'click #showCreateTeamForm'(e, t) { t.showCreateTeam.set(true); - t.showJoinTeam && t.showJoinTeam.set(false); + t.showJoinTeam.set(false); }, 'click #showJoinTeamForm'(e, t) { t.showJoinTeam.set(true); - t.showCreateTeam && t.showCreateTeam.set(false); + t.showCreateTeam.set(false); }, 'click #cancelCreateTeam'(e, t) { t.showCreateTeam.set(false); @@ -130,6 +203,66 @@ Template.teams.events({ }); } }, + 'click .remove-member-btn'(e, t) { + e.preventDefault(); + const memberId = e.currentTarget.dataset.memberId; + const teamId = t.selectedTeamId.get(); + if (!teamId || !memberId) return; + + const users = t.selectedTeamUsers.get() || []; + const user = users.find(u => u.id === memberId); + const displayName = user?.name || user?.email || 'this member'; + + if (!confirm(`Remove ${displayName} from this team?`)) { + return; + } + + Meteor.call('removeTeamMember', teamId, memberId, (err) => { + if (err) { + alert('Error removing member: ' + (err.reason || err.message)); + } + }); + }, + 'click .promote-admin-btn'(e, t) { + e.preventDefault(); + const memberId = e.currentTarget.dataset.memberId; + const teamId = t.selectedTeamId.get(); + if (!teamId || !memberId) return; + + const users = t.selectedTeamUsers.get() || []; + const user = users.find(u => u.id === memberId); + const displayName = user?.name || user?.email || 'this member'; + + if (!confirm(`Make ${displayName} a co-admin? They will have all admin rights.`)) { + return; + } + + Meteor.call('promoteToAdmin', teamId, memberId, (err) => { + if (err) { + alert('Error promoting to admin: ' + (err.reason || err.message)); + } + }); + }, + 'click .demote-admin-btn'(e, t) { + e.preventDefault(); + const memberId = e.currentTarget.dataset.memberId; + const teamId = t.selectedTeamId.get(); + if (!teamId || !memberId) return; + + const users = t.selectedTeamUsers.get() || []; + const user = users.find(u => u.id === memberId); + const displayName = user?.name || user?.email || 'this member'; + + if (!confirm(`Remove admin rights from ${displayName}? They will remain a team member.`)) { + return; + } + + Meteor.call('demoteFromAdmin', teamId, memberId, (err) => { + if (err) { + alert('Error removing admin rights: ' + (err.reason || err.message)); + } + }); + }, 'click .edit-team-btn'(e, t) { e.preventDefault(); const teamId = e.currentTarget.dataset.id; @@ -157,4 +290,11 @@ Template.teams.events({ }); } }, + 'click .view-dashboard-btn'(e, t) { + e.preventDefault(); + const memberId = e.currentTarget.dataset.memberId; + if (memberId) { + FlowRouter.go(`/timesheet/${memberId}`); + } + }, }); \ No newline at end of file diff --git a/server/methods/teams.js b/server/methods/teams.js index 2073c86..b90c4ae 100644 --- a/server/methods/teams.js +++ b/server/methods/teams.js @@ -78,6 +78,97 @@ export const teamMethods = { } await Teams.updateAsync(teamId, { $set: { name: trimmed } }); }, + async removeTeamMember(teamId, memberId) { + check(teamId, String); + check(memberId, String); + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + const team = await Teams.findOneAsync(teamId); + if (!team) { + throw new Meteor.Error('not-found', 'Team not found'); + } + + const isAdmin = Array.isArray(team.admins) && team.admins.includes(this.userId); + if (!isAdmin) { + throw new Meteor.Error('forbidden', 'Only admins can remove team members'); + } + + if (!Array.isArray(team.members) || !team.members.includes(memberId)) { + throw new Meteor.Error('bad-request', 'User is not a member of this team'); + } + + if (team.leader === memberId) { + throw new Meteor.Error('forbidden', 'You cannot remove the team leader'); + } + + const update = { + $pull: { members: memberId }, + }; + + if (Array.isArray(team.admins) && team.admins.includes(memberId)) { + update.$pull.admins = memberId; + } + + await Teams.updateAsync(teamId, update); + }, + async promoteToAdmin(teamId, memberId) { + check(teamId, String); + check(memberId, String); + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + const team = await Teams.findOneAsync(teamId); + if (!team) { + throw new Meteor.Error('not-found', 'Team not found'); + } + + const isAdmin = Array.isArray(team.admins) && team.admins.includes(this.userId); + if (!isAdmin) { + throw new Meteor.Error('forbidden', 'Only admins can promote members to admin'); + } + + if (!Array.isArray(team.members) || !team.members.includes(memberId)) { + throw new Meteor.Error('bad-request', 'User is not a member of this team'); + } + + if (Array.isArray(team.admins) && team.admins.includes(memberId)) { + throw new Meteor.Error('bad-request', 'User is already an admin'); + } + + // Add to admins array + await Teams.updateAsync(teamId, { $push: { admins: memberId } }); + }, + async demoteFromAdmin(teamId, memberId) { + check(teamId, String); + check(memberId, String); + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + const team = await Teams.findOneAsync(teamId); + if (!team) { + throw new Meteor.Error('not-found', 'Team not found'); + } + + const isAdmin = Array.isArray(team.admins) && team.admins.includes(this.userId); + if (!isAdmin) { + throw new Meteor.Error('forbidden', 'Only admins can demote other admins'); + } + + if (team.leader === memberId) { + throw new Meteor.Error('forbidden', 'You cannot demote the team leader'); + } + + if (!Array.isArray(team.admins) || !team.admins.includes(memberId)) { + throw new Meteor.Error('bad-request', 'User is not an admin'); + } + + // Remove from admins array (but keep as member) + await Teams.updateAsync(teamId, { $pull: { admins: memberId } }); + }, async deleteTeam(teamId) { check(teamId, String); if (!this.userId) {