Add User board access roles to Admin Panel / People / Roles.

A new global "Roles" tab in Admin Panel / People lets a site admin choose which
board roles are allowed to invite users to a board ("Allow Invite to Board").
Each board role has its own toggle — Board Admin, Normal, Worker, Comment only,
No comments, Only Assigned Normal, Only Assigned Comment, Read Only and Only
Assigned Read — plus an "All Board Members" master toggle that selects them all.
The policy is enforced server-side in the `inviteUserToBoard` and `searchUsers`
methods, and the add-member button in the board sidebar is shown only to roles
the policy allows. Global Admin Panel users (site admins) always have all rights
and cannot be restricted here; this is kept clearly distinct in code from the
per-board "Board Admin" role. Secure default: only Board Admin and Normal may invite.

Thanks to xet7.
This commit is contained in:
Lauri Ojansivu committed 2026-05-31 02:22:31 +03:00
1 parent f9b790668d
commit c956ab5a4e
15 files changed
+313 -6

No files matched your search

+1
View File
@@ -142,6 +142,7 @@ Template.board.helpers({
Template.boardBody.onCreated(function () {
Meteor.subscribe('tableVisibilityModeSettings');
Meteor.subscribe('inviteToBoardRolesSettings');
this.showOverlay = new ReactiveVar(false);
this.draggingActive = new ReactiveVar(false);
this._isDragging = false;
+24
View File
@@ -354,3 +354,27 @@ table td:first-child {
height: 100%;
width: 100%;
}
/* Roles tab. The .main-body scrolls horizontally and is wider than the visible
area (the side-menu takes ~250px), so by default a long description would run
off the right edge and only be fully readable at the smallest (mobile) width.
Pin the heading/description to the visible left edge and wrap them within the
viewport so they stay visible at every width. */
.roles-settings {
padding: 0 10px;
}
.roles-settings h3,
.roles-settings h4,
.roles-settings .roles-description {
position: sticky;
left: 0;
/* Cap to ~60 characters, and never wider than the viewport, so the text wraps
into the visible left area at every width (desktop, mid and mobile) instead
of running off the horizontally-scrolling main-body. */
max-width: min(60ch, 88vw);
white-space: normal;
overflow-wrap: break-word;
}
.roles-settings .roles-description {
margin-bottom: 1rem;
}
@@ -63,6 +63,11 @@ template(name="people")
i.fa.fa-lock
unless isMiniScreen
| {{_ 'accounts-lockout-locked-users'}}
else if rolesSetting.get
span
i.fa.fa-key
unless isMiniScreen
| {{_ 'roles'}}
.content-body
.side-menu
@@ -84,6 +89,10 @@ template(name="people")
span.emoji-icon.text-red
i.fa.fa-lock
| {{_ 'accounts-lockout-locked-users'}}
li
a.js-roles-menu(data-id="roles-setting")
i.fa.fa-key
| {{_ 'roles'}}
.main-body
if loading.get
+spinner
@@ -95,6 +104,8 @@ template(name="people")
+peopleGeneral(peopleList=peopleList peopleCurrentPage=peopleCurrentPage peopleTotalPages=peopleTotalPages hasPeoplePrevPage=hasPeoplePrevPage hasPeopleNextPage=hasPeopleNextPage)
else if lockedUsersSetting.get
+lockedUsersGeneral
else if rolesSetting.get
+rolesGeneral
template(name="orgGeneral")
@@ -719,3 +730,22 @@ template(name="lockedUsersGeneral")
input.wekan-form-control#unknown-failure-window(type="number", min="1", max="60", placeholder="15" value="{{unknownFailureWindow}}")
button.js-lockout-save.primary {{_ 'save'}}
template(name="rolesGeneral")
.roles-settings
h3 {{_ 'roles'}}
p.roles-description {{_ 'roles-info'}}
h4 {{_ 'allow-invite-to-board'}}
ul#roles-setting.setting-detail
li
a.flex.js-toggle-all-roles
.materialCheckBox(class="{{#if allRolesAllowed}}is-checked{{/if}}")
span {{_ 'all-board-members'}}
each roleOptions
li
a.flex.js-toggle-role(data-role="{{key}}")
.materialCheckBox(class="{{#if allowed}}is-checked{{/if}}")
span {{_ label}}
button.js-roles-save.primary {{_ 'save'}}
+70
View File
@@ -6,6 +6,10 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Org from '/models/org';
import Team from '/models/team';
import Users from '/models/users';
import InviteToBoardRolesSettings, {
INVITE_TO_BOARD_ROLES,
INVITE_TO_BOARD_ROLES_ID,
} from '/models/inviteToBoardRolesSettings';
const orgsPerPage = 25;
const teamsPerPage = 25;
@@ -22,6 +26,8 @@ Template.people.onCreated(function () {
this.teamSetting = new ReactiveVar(false);
this.peopleSetting = new ReactiveVar(false);
this.lockedUsersSetting = new ReactiveVar(false);
this.rolesSetting = new ReactiveVar(false);
this.subscribe('inviteToBoardRolesSettings');
this.findOrgsOptions = new ReactiveVar({});
this.findTeamsOptions = new ReactiveVar({});
this.findUsersOptions = new ReactiveVar({});
@@ -191,6 +197,7 @@ Template.people.onCreated(function () {
this.teamSetting.set('team-setting' === targetID);
this.peopleSetting.set('people-setting' === targetID);
this.lockedUsersSetting.set('locked-users-setting' === targetID);
this.rolesSetting.set('roles-setting' === targetID);
// When switching to locked users tab, refresh the locked users list
if ('locked-users-setting' === targetID) {
@@ -263,6 +270,9 @@ Template.people.helpers({
lockedUsersSetting() {
return Template.instance().lockedUsersSetting;
},
rolesSetting() {
return Template.instance().rolesSetting;
},
orgList() {
const tpl = Template.instance();
const page = tpl.orgPage.get();
@@ -521,6 +531,66 @@ Template.people.events({
'click a.js-locked-users-menu'(event, tpl) {
tpl.switchMenu(event);
},
'click a.js-roles-menu'(event, tpl) {
tpl.switchMenu(event);
},
});
Template.rolesGeneral.onCreated(function () {
// Working copy of the allowed-roles set; null until the published doc loads.
this.workingRoles = new ReactiveVar(null);
this.autorun(() => {
if (this.workingRoles.get() === null) {
const doc = InviteToBoardRolesSettings.findOne(INVITE_TO_BOARD_ROLES_ID);
if (doc) {
this.workingRoles.set((doc.allowedRoles || []).slice());
}
}
});
});
Template.rolesGeneral.helpers({
roleOptions() {
const working = Template.instance().workingRoles.get() || [];
// The role key doubles as the i18n key. 'board-admin' renders as
// "Board Admin", deliberately distinct from the global Admin Panel admin.
return INVITE_TO_BOARD_ROLES.map((key) => ({
key,
label: key,
allowed: working.includes(key),
}));
},
allRolesAllowed() {
const working = Template.instance().workingRoles.get() || [];
return INVITE_TO_BOARD_ROLES.every((key) => working.includes(key));
},
});
Template.rolesGeneral.events({
'click a.js-toggle-role'(event, tpl) {
event.preventDefault();
const role = $(event.currentTarget).data('role');
const working = (tpl.workingRoles.get() || []).slice();
const idx = working.indexOf(role);
if (idx >= 0) {
working.splice(idx, 1);
} else {
working.push(role);
}
tpl.workingRoles.set(working);
},
'click a.js-toggle-all-roles'(event, tpl) {
event.preventDefault();
const working = tpl.workingRoles.get() || [];
const allOn = INVITE_TO_BOARD_ROLES.every((key) => working.includes(key));
tpl.workingRoles.set(allOn ? [] : INVITE_TO_BOARD_ROLES.slice());
},
'click .js-roles-save'(event, tpl) {
event.preventDefault();
InviteToBoardRolesSettings.update(INVITE_TO_BOARD_ROLES_ID, {
$set: { allowedRoles: tpl.workingRoles.get() || [] },
});
},
});
Template.orgRow.helpers({
+1 -1
View File
@@ -82,7 +82,7 @@ template(name="membersWidget")
if currentUser.isBoardMember
a.member.add-member.sandstorm-powerbox-request-identity(title="{{_ 'add-members'}}")
i.fa.fa-plus
else if currentUser.isBoardAdmin
else if currentUser.canInviteToBoard
a.member.add-member.js-manage-board-members(title="{{_ 'add-members'}}")
i.fa.fa-plus
.clearfix
+6
View File
@@ -447,6 +447,7 @@
"error-board-doesNotExist": "This board does not exist",
"error-board-notAdmin": "You need to be admin of this board to do that",
"error-board-notAMember": "You need to be a member of this board to do that",
"error-notAllowed": "Your board role is not allowed to do that",
"error-json-malformed": "Your text is not valid JSON",
"error-json-schema": "Your JSON data does not include the proper information in the correct format",
"error-csv-schema": "Your CSV(Comma Separated Values)/TSV (Tab Separated Values) does not include the proper information in the correct format ",
@@ -1078,6 +1079,11 @@
"remove-all-read": "Remove all read",
"allow-rename": "Allow Rename",
"allowRenamePopup-title": "Allow Rename",
"roles": "Roles",
"roles-info": "Choose which board roles are allowed to perform each action. Admin Panel users (global admins) always have all rights and cannot be restricted here.",
"allow-invite-to-board": "Allow Invite to Board",
"all-board-members": "All Board Members",
"board-admin": "Board Admin",
"start-day-of-week": "Set day of the week start",
"monday": "Monday",
"tuesday": "Tuesday",
+1
View File
@@ -20,6 +20,7 @@ import '/models/customFields';
import '/models/impersonatedUsers';
import '/models/integrations';
import '/models/invitationCodes';
import '/models/inviteToBoardRolesSettings';
import '/models/lists';
import '/models/lockoutSettings';
import '/models/org';
+19
View File
@@ -1242,6 +1242,25 @@ Boards.helpers({
});
},
// Returns the single BOARD-level role key of an active board member (see
// INVITE_TO_BOARD_ROLES). Returns null if the user is not an active member.
// Note: 'board-admin' is the board administrator role and is distinct from
// the global site-admin flag `user.isAdmin`. A member with no restriction
// flag set is a plain 'normal' member.
memberRole(memberId) {
const member = findWhere(this.members, { userId: memberId, isActive: true });
if (!member) return null;
if (member.isAdmin) return 'board-admin';
if (member.isWorker) return 'worker';
if (member.isCommentOnly) return 'comment-only';
if (member.isNoComments) return 'no-comments';
if (member.isNormalAssignedOnly) return 'normal-assigned-only';
if (member.isCommentAssignedOnly) return 'comment-assigned-only';
if (member.isReadOnly) return 'read-only';
if (member.isReadAssignedOnly) return 'read-assigned-only';
return 'normal';
},
hasAnyAllowsDate() {
const ret = this.allowsReceivedDate || this.allowsStartDate || this.allowsDueDate || this.allowsEndDate;
return ret;
+87
View File
@@ -0,0 +1,87 @@
import { Mongo } from 'meteor/mongo';
const { SimpleSchema } = require('/imports/simpleSchema');
// All board-member role keys that a global admin may grant the "invite to
// board" capability to. Order is the display order in the Admin Panel / People
// / Roles tab. NOTE: these are BOARD-level roles, deliberately kept distinct
// from the global site-admin flag `user.isAdmin`. 'board-admin' is the board
// administrator role; 'normal' is a plain member with no restriction flags.
export const INVITE_TO_BOARD_ROLES = [
'board-admin',
'normal',
'worker',
'comment-only',
'no-comments',
'normal-assigned-only',
'comment-assigned-only',
'read-only',
'read-assigned-only',
];
// The single settings document id.
export const INVITE_TO_BOARD_ROLES_ID = 'inviteToBoardRoles';
// Secure default: only board admins and plain normal members may invite.
export const INVITE_TO_BOARD_ROLES_DEFAULT = ['board-admin', 'normal'];
const InviteToBoardRolesSettings = new Mongo.Collection('inviteToBoardRolesSettings');
InviteToBoardRolesSettings.attachSchema(
new SimpleSchema({
_id: {
type: String,
},
allowedRoles: {
type: Array,
optional: true,
},
'allowedRoles.$': {
type: String,
allowedValues: INVITE_TO_BOARD_ROLES,
},
sort: {
type: Number,
optional: true,
},
createdAt: {
type: Date,
optional: true,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert) {
return new Date();
} else if (this.isUpsert) {
return { $setOnInsert: new Date() };
} else {
this.unset();
}
},
},
modifiedAt: {
type: Date,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
return new Date();
} else {
this.unset();
}
},
},
}),
);
InviteToBoardRolesSettings.helpers({
isRoleAllowed(role) {
return (this.allowedRoles || []).includes(role);
},
});
// Returns the configured set of allowed roles, falling back to the secure
// default if the settings document does not exist yet.
InviteToBoardRolesSettings.allowedRoles = async function() {
const doc = await InviteToBoardRolesSettings.findOneAsync(INVITE_TO_BOARD_ROLES_ID);
return (doc && doc.allowedRoles) || INVITE_TO_BOARD_ROLES_DEFAULT;
};
export default InviteToBoardRolesSettings;
+26
View File
@@ -1,6 +1,10 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache, ReactiveMiniMongoIndex } from '/imports/reactiveCache';
import Boards from '/models/boards';
import InviteToBoardRolesSettings, {
INVITE_TO_BOARD_ROLES_ID,
INVITE_TO_BOARD_ROLES_DEFAULT,
} from '/models/inviteToBoardRolesSettings';
// import { Index, MongoDBEngine } from 'meteor/easy:search'; // Temporarily disabled due to compatibility issues
const { SimpleSchema } = require('/imports/simpleSchema');
const Users = Meteor.users;
@@ -837,6 +841,28 @@ if (Meteor.isClient) {
}
return board && board.hasAdmin(this._id);
},
// Whether this user may invite members to the board, mirroring the
// server-side check in the inviteUserToBoard method: global site admins and
// board admins always may; other board roles only if allowed by the
// Admin Panel / People / Roles policy. Used to gate the UI add-member button.
canInviteToBoard(boardId) {
if (this.isAdmin) return true;
let board;
if (boardId) {
board = ReactiveCache.getBoard(boardId);
} else {
const Utils = getUtils();
board = Utils.getCurrentBoard();
}
if (!board) return false;
if (board.hasAdmin(this._id)) return true;
const role = board.memberRole(this._id);
if (!role) return false;
const doc = InviteToBoardRolesSettings.findOne(INVITE_TO_BOARD_ROLES_ID);
const allowed = (doc && doc.allowedRoles) || INVITE_TO_BOARD_ROLES_DEFAULT;
return allowed.includes(role);
},
});
}
+2
View File
@@ -198,6 +198,7 @@ import '/server/publications/cards';
import '/server/publications/cronJobs';
import '/server/publications/cronMigrationStatus';
import '/server/publications/customUI';
import '/server/publications/inviteToBoardRolesSettings';
import '/server/publications/lockoutSettings';
import '/server/publications/migrationProgress';
import '/server/publications/notifications';
@@ -247,6 +248,7 @@ import '/server/permissions/checklists';
import '/server/permissions/customFields';
import '/server/permissions/integrations';
import '/server/permissions/invitationCodes';
import '/server/permissions/inviteToBoardRolesSettings';
import '/server/permissions/lists';
import '/server/permissions/lockoutSettings';
import '/server/permissions/org';
+10
View File
@@ -10,6 +10,10 @@ import PositionHistory from '/models/positionHistory';
import Presences from '/models/presences';
import Rules from '/models/rules';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import InviteToBoardRolesSettings, {
INVITE_TO_BOARD_ROLES_ID,
INVITE_TO_BOARD_ROLES_DEFAULT,
} from '/models/inviteToBoardRolesSettings';
import Triggers from '/models/triggers';
import UnsavedEditCollection from '/models/unsavedEdits';
@@ -130,6 +134,12 @@ Meteor.startup(async () => {
{ $setOnInsert: { booleanValue: false, sort: 0 } },
);
await InviteToBoardRolesSettings._collection.createIndexAsync({ modifiedAt: -1 });
await InviteToBoardRolesSettings.upsertAsync(
{ _id: INVITE_TO_BOARD_ROLES_ID },
{ $setOnInsert: { allowedRoles: INVITE_TO_BOARD_ROLES_DEFAULT, sort: 0 } },
);
await Triggers._collection.createIndexAsync({ modifiedAt: -1 });
await UnsavedEditCollection._collection.createIndexAsync({ modifiedAt: -1 });
+21 -5
View File
@@ -14,6 +14,7 @@ import EmailLocalization from '/server/lib/emailLocalization';
import ImpersonatedUsers from '/models/impersonatedUsers';
import Boards from '/models/boards';
import InvitationCodes from '/models/invitationCodes';
import InviteToBoardRolesSettings from '/models/inviteToBoardRolesSettings';
import Lists from '/models/lists';
import Swimlanes from '/models/swimlanes';
import Users, { allowedSortValues } from '/models/users';
@@ -614,8 +615,19 @@ Meteor.methods({
}
const inviter = await ReactiveCache.getCurrentUser();
const board = await ReactiveCache.getBoard(boardId);
const member = board.members.find(memberItem => memberItem.userId === inviter._id);
if (!member || !member.isActive) throw new Meteor.Error('error-board-notAMember');
// Global site admins (Admin Panel users) always have all rights and bypass
// both the board-membership and the configurable per-role check below.
if (!inviter.isAdmin) {
const member = board.members.find(memberItem => memberItem.userId === inviter._id);
if (!member || !member.isActive) throw new Meteor.Error('error-board-notAMember');
// Enforce the Admin Panel / People / Roles policy: only the configured
// board roles are allowed to invite users to a board.
const allowedRoles = await InviteToBoardRolesSettings.allowedRoles();
if (!allowedRoles.includes(board.memberRole(inviter._id))) {
throw new Meteor.Error('error-notAllowed');
}
}
this.unblock();
@@ -1505,9 +1517,13 @@ Meteor.methods({
const currentUser = await ReactiveCache.getCurrentUser();
const board = await ReactiveCache.getBoard(boardId);
const member = board.members.find(memberItem => memberItem.userId === currentUser._id);
if (!member || !member.isActive) {
throw new Meteor.Error('not-authorized', 'User is not a member of this board');
// Global site admins bypass the board-membership check, mirroring
// inviteUserToBoard.
if (!currentUser.isAdmin) {
const member = board.members.find(memberItem => memberItem.userId === currentUser._id);
if (!member || !member.isActive) {
throw new Meteor.Error('not-authorized', 'User is not a member of this board');
}
}
if (query.length < 2) {
@@ -0,0 +1,10 @@
import InviteToBoardRolesSettings from '/models/inviteToBoardRolesSettings';
// Only global site admins (Admin Panel users) may change which board roles are
// allowed to invite users to a board.
InviteToBoardRolesSettings.allow({
async update(userId) {
const user = await Meteor.users.findOneAsync(userId);
return user && user.isAdmin;
},
});
@@ -0,0 +1,5 @@
import InviteToBoardRolesSettings from '/models/inviteToBoardRolesSettings';
Meteor.publish('inviteToBoardRolesSettings', function() {
return InviteToBoardRolesSettings.find();
});