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) {