Files
wekan/models/lib/boardMemberMapPlan.js
T
Lauri Ojansivu c0a1a27847 Import: always create virtual users, map later from the sidebar; bound imports
Board import no longer asks for member mapping up front. Every imported member is
brought in as a virtual (placeholder) user carrying its avatar, username and full
name (the existing createPlaceholderUsers path), added to the board inactive with
no permissions. The client import flow is now a single step that imports
immediately with an empty mapping -- no map-members screen, nothing to get stuck
on.

Mapping a virtual member to an existing user is a deliberate, later action done by
a board admin from the board sidebar member-avatar popup ("Map to existing user"),
and CANNOT be used to gain privileges: the new board-scoped method
mapImportedBoardMember only maps a virtual member onto an existing ACTIVE, REAL
member of the SAME board and never changes that member's role (so no new
membership is created and nothing is escalated); authorization and this guarantee
live in the pure planBoardMemberMapping helper. The mapping reassigns the virtual
member's cards/comments/activities ON THAT BOARD to the target and removes the
placeholder from the board.

Automatic hang mitigation so an import can never spin forever: a client watchdog
surfaces an import-timeout and clears the spinner if importBoard does not answer in
WEKAN_IMPORT_TIMEOUT_MS (default 2 min), and the server bounds creator.create with
the same deadline via a new generic withDeadline() wrapper.

Covered by tests/boardMemberMapPlan.test.cjs (no-escalation + auth matrix),
tests/withDeadline.test.cjs (deadline wrapper) and tests/importVirtualMembers.test.cjs
(wiring).

Thanks to xet7 !
2026-07-21 19:43:48 +03:00

66 lines
2.7 KiB
JavaScript

// Pure decision helper for mapping an imported VIRTUAL (placeholder) board member to an
// existing REAL user on the same board, from the board sidebar member-avatar popup.
//
// Security model — mapping can NEVER be used to gain privileges:
// - Only a board admin (or a site admin) may map.
// - The target must be an existing, ACTIVE, real (non-imported) member of THIS board.
// Mapping never adds a new member and never changes the target's role, so the target
// keeps exactly the role they already had — nothing is escalated. To give a real
// user a board role you use the normal add-member / change-permissions flow first
// (which is itself privilege-checked), then map the virtual member onto them.
// - The source must be an imported placeholder member of this board.
//
// Returns { ok:true, newMembers, placeholderId, targetId } (newMembers = members with the
// placeholder entry removed) or { ok:false, error } with a stable error key. Pure and
// dependency-free so it is unit-tested without a database.
function planBoardMemberMapping({
members,
placeholderId,
targetId,
callerId,
placeholderIsImported,
targetIsImported,
callerIsSiteAdmin = false,
} = {}) {
const list = Array.isArray(members) ? members : [];
const find = id => list.find(m => m && m.userId === id);
if (!placeholderId || !targetId || placeholderId === targetId) {
return { ok: false, error: 'bad-map' };
}
// Authorization: board admin of THIS board, or a site admin.
const caller = find(callerId);
const callerIsBoardAdmin = !!(caller && caller.isActive !== false && caller.isAdmin);
if (!callerIsSiteAdmin && !callerIsBoardAdmin) {
return { ok: false, error: 'not-authorized' };
}
// Source must be an imported placeholder that is a member of this board.
if (!placeholderIsImported) {
return { ok: false, error: 'not-a-placeholder' };
}
if (!find(placeholderId)) {
return { ok: false, error: 'placeholder-not-member' };
}
// Target must be a REAL (non-imported), ACTIVE member of this board — so no new
// membership is created and no role is granted by the mapping itself.
if (targetIsImported) {
return { ok: false, error: 'target-not-real' };
}
const targetMember = find(targetId);
if (!targetMember || targetMember.isActive === false) {
return { ok: false, error: 'target-not-board-member' };
}
// Resulting membership: drop the placeholder entry, leave the target's entry (and role)
// exactly as it was. No entry is added, no flag is raised.
const newMembers = list.filter(m => m && m.userId !== placeholderId);
return { ok: true, newMembers, placeholderId, targetId };
}
module.exports = { planBoardMemberMapping };