Fix #6506: restore the import "map members" step; unmapped members become virtual users, not the importer.

Regression from c0a1a2784 (v10.12): it dropped `importMapMembers` from the import
wizard's `steps` array and hardcoded an empty membersMapping in finishImport, so the
map-users screen never rendered and every imported card/member/comment was assigned
to the importing user even when the person existed in WeKan.

Client (client/components/import/import.js):
 - restore `steps = ['importTextarea', 'importMapMembers']` so the (auto-suggesting)
   map-members step shows again;
 - finishImport builds membersMapping from the members the user mapped
   (member.wekanId), instead of sending `{}`. The step stays OPTIONAL — the Skip
   button and the textarea "import without mapping" button still bypass it.

Server — unmapped members become virtual users instead of collapsing onto the importer:
 - models/trelloCreator.js: new createPlaceholderUsers(board), run in create() before
   the board is built, so it covers BOTH the Trello JSON import and the Trello API
   (all-boards background) import. For each member not already mapped it maps to an
   existing WeKan user by username / recorded importUsername, else inserts an inert
   virtual user (loginDisabled, isActive:false, authenticationMethod:'imported')
   reusing the Trello member id, so board membership and authorship keep the original
   person;
 - models/wekanCreator.js: createPlaceholderUsers no longer overwrites an explicit UI
   mapping (skip members already in this.members) so a member mapped to a real account
   is not clobbered by the identity placeholder.

A board admin can still map a virtual member to a real user later from the sidebar
(that feature is unchanged). Tests updated: tests/importVirtualMembers.test.cjs and
tests/importMemberMapping.test.cjs.

Thanks to AmigaAbattoir and xet7 !

Fixes #6506,
This commit is contained in:
Lauri Ojansivu committed 2026-07-22 18:31:32 +03:00
1 parent a1f93c8015
commit 0e0391f6c1
5 files changed
+132 -19

No files matched your search

+17 -8
View File
@@ -173,11 +173,13 @@ function assignBoardToNamedWorkspace(boardId, wsName, parentId = null) {
Template.import.onCreated(function () {
this.error = new ReactiveVar('');
// Import never asks for member mapping up front: every imported member is brought
// in as a virtual (placeholder) user carrying its avatar / username / full name,
// and a board admin maps a virtual member to an existing board user later from the
// sidebar member-avatar popup. So there is a single step; import runs immediately.
this.steps = ['importTextarea'];
// #6506: import shows the "map members" step so imported members can be mapped to
// EXISTING WeKan users (auto-suggested by username). It is OPTIONAL — a Skip button
// (and the textarea "import without mapping" button) bypasses it. Whatever is not
// mapped is brought in as a virtual (placeholder) user carrying its username / full
// name — NOT collapsed onto the importing user — and a board admin can still map a
// virtual member to an existing user later from the sidebar member-avatar popup.
this.steps = ['importTextarea', 'importMapMembers'];
this._currentStepIndex = new ReactiveVar(0);
this.importedData = new ReactiveVar();
this.membersToMap = new ReactiveVar([]);
@@ -315,10 +317,17 @@ Template.import.onCreated(function () {
};
this.finishImport = async () => {
// No import-time member mapping: every imported member becomes a virtual
// (placeholder) user. Mapping a virtual member to an existing board user is done
// later from the board sidebar (privilege-bounded), so pass an empty mapping.
// #6506: build the member mapping from the (optional) map-members step —
// { importedMemberId: existingWekanUserId } for members the user mapped (or that
// auto-matched by username). Members left unmapped, or skipped entirely, are sent
// WITHOUT an entry: the server creator brings them in as virtual (placeholder)
// users instead of collapsing them onto the importing user.
const mappingById = {};
(this.membersToMap.get() || []).forEach(member => {
if (member && member.id && member.wekanId) {
mappingById[member.id] = member.wekanId;
}
});
const importedData = this.importedData.get();
// Trello: import over HTTP (see postTrelloImport) so the realtime DDP
+78
View File
@@ -1026,6 +1026,11 @@ export class TrelloCreator {
const currentBoard = await ReactiveCache.getBoard(currentBoardId);
await currentBoard.archive();
}
// #6506: bring every imported member in as its own user (mapped to an existing
// account or a virtual placeholder) BEFORE creating the board, so board members,
// card authors, comments and activities keep the original person instead of
// collapsing onto the importer.
await this.createPlaceholderUsers(board);
this.parseActions(board.actions);
const boardId = await this.createBoardAndLabels(board);
// Create the default swimlane first so lists can be attached to it (see
@@ -1041,6 +1046,79 @@ export class TrelloCreator {
return boardId;
}
// Pick a free username: keep the original if it is not taken, else suffix it so we
// never rename/steal a different existing account's username.
async _uniquePlaceholderUsername(base) {
const name = (String(base || '').trim()) || 'imported';
let candidate = name;
let n = 0;
// eslint-disable-next-line no-await-in-loop
while (await Meteor.users.findOneAsync({ username: candidate }, { fields: { _id: 1 } })) {
n += 1;
candidate = `${name}-${n}`;
}
return candidate;
}
// #6506: for every Trello member NOT already mapped (by the map-members step or the
// API importer's buildMembersMapping), either map it to an EXISTING WeKan user with
// the same username / recorded importUsername, or create an inert virtual
// (placeholder) user carrying the original username / full name. This keeps imported
// authorship on the original person instead of the importing user, for BOTH the
// Trello JSON import and the Trello API (all-boards) import. Placeholders cannot log
// in (loginDisabled), are inactive until reconciled (isActive:false) and carry no
// secrets. Server-only; best-effort so one bad member never fails the import.
async createPlaceholderUsers(board) {
if (!Meteor.isServer) return;
for (const member of board.members || []) {
if (!member || !member.id) continue;
if (this.members[member.id]) continue; // already mapped to an existing user
if (member.username) {
const existing =
(await ReactiveCache.getUser({ username: member.username })) ||
(await ReactiveCache.getUser({ importUsernames: member.username }));
if (existing) {
this.members[member.id] = existing._id;
continue;
}
}
// Reuse the Trello member id as the WeKan user _id so every reference resolves.
const existingById = await ReactiveCache.getUser(member.id);
if (!existingById) {
const username = await this._uniquePlaceholderUsername(
member.username || member.fullName || `imported-${member.id}`,
);
try {
// .direct bypasses the after.insert hooks (Sandstorm identity sync, the
// registration/invitation gate) that would reject a non-account placeholder.
await Users.direct.insertAsync({
_id: member.id,
username,
profile: {
fullname: member.fullName || '',
initials: String(member.initials || '').slice(0, 4),
},
authenticationMethod: 'imported',
loginDisabled: true,
isActive: false,
importUsernames: member.username ? [member.username] : [],
importedFromBoardId: board.id || null,
importedAt: new Date(),
createdAt: new Date(),
services: {},
});
} catch (e) {
if (process.env.DEBUG === 'true') {
console.warn('trello placeholder user insert:', member.id, e && e.message);
}
}
}
this.members[member.id] = member.id;
}
}
// Keep the original Trello usernames so user mapping can happen later:
// members that were mapped to a WeKan user get the Trello username added to
// that user's importUsernames (so future imports auto-map); members that were
+4
View File
@@ -1317,6 +1317,10 @@ export class WekanCreator {
for (const u of users) {
if (!u || !u._id) continue;
// #6506: a member the map-members step mapped to an EXISTING WeKan user is
// already in this.members (id -> real user id); keep that and do NOT create a
// placeholder or overwrite it with the identity mapping below.
if (this.members[u._id]) continue;
const profile = u.profile || {};
let existing = await ReactiveCache.getUser(u._id);
if (!existing) {
+1 -1
View File
@@ -30,7 +30,7 @@ test('selecting a searched user maps its _id (not the never-set __originalId)',
// The click handler maps the clicked user document by _id.
const clickIdx = js.indexOf("'click .js-select-import'");
assert.ok(clickIdx > -1, 'the select-import click handler exists');
const clickBlock = js.slice(clickIdx, clickIdx + 300);
const clickBlock = js.slice(clickIdx, clickIdx + 600);
assert.ok(/mapSelectedMember|importMapToUser/.test(clickBlock), 'the click maps the selected member');
assert.ok(/Template\.currentData\(\)\._id/.test(js), 'maps the search result by its _id');
});
+32 -10
View File
@@ -1,12 +1,13 @@
'use strict';
// Wiring guards for the "import always creates virtual users, map later" feature:
// - Import never asks for member mapping up front (single step, empty mapping), so
// every imported member is brought in as a virtual (placeholder) user.
// Wiring guards for import member handling:
// - #6506: import shows the OPTIONAL "map members" step so imported members can be
// mapped to existing WeKan users; members left unmapped (or skipped) are brought in
// as virtual (placeholder) users, NOT collapsed onto the importing user.
// - Import is bounded by a hang-mitigation watchdog (client + server) so it can never
// spin forever.
// - A board admin maps a virtual member to an existing real board user LATER from the
// sidebar member-avatar popup, via a board-scoped, no-escalation server method.
// - A board admin can also map a virtual member to an existing real board user LATER
// from the sidebar member-avatar popup, via a board-scoped, no-escalation method.
//
// Run: node tests/importVirtualMembers.test.cjs
@@ -20,13 +21,34 @@ const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
console.log('importVirtualMembers:');
test('import runs immediately with no member-mapping step', () => {
test('#6506: import shows the map-members step and builds the mapping from it', () => {
const js = read('client/components/import/import.js');
assert.ok(/this\.steps = \['importTextarea'\]/.test(js), 'only the textarea step remains (no importMapMembers step)');
// finishImport must not build a mapping from membersToMap — every member is virtual.
assert.ok(/this\.steps = \['importTextarea', 'importMapMembers'\]/.test(js),
'the wizard includes the importMapMembers step again');
// finishImport builds the mapping from the members the user mapped (member.wekanId).
const fi = js.slice(js.indexOf('this.finishImport'));
assert.ok(/const mappingById = \{\};/.test(fi), 'finishImport passes an empty membersMapping');
assert.ok(!/membersMapping\.forEach/.test(fi), 'no per-member wekanId mapping is applied at import');
assert.ok(/this\.membersToMap\.get\(\)/.test(fi) && /member\.wekanId/.test(fi),
'finishImport builds membersMapping from the mapped members');
assert.ok(/mappingById\[member\.id\] = member\.wekanId/.test(fi),
'each mapped member maps its imported id to the chosen existing user');
// The skip button and the textarea "without mapping" button both still bypass it.
assert.ok(/js-import-skip-mapping/.test(js) && /js-import-without-mapping/.test(js),
'skip / import-without-mapping remain available');
});
test('#6506: unmapped Trello members become virtual users (both JSON and API import)', () => {
const tc = read('models/trelloCreator.js');
assert.ok(/async createPlaceholderUsers\(board\)/.test(tc), 'trelloCreator creates placeholder users');
assert.ok(/await this\.createPlaceholderUsers\(board\)/.test(tc), 'create() runs it before building the board');
// Unmapped members map to an existing user by username, else a virtual placeholder.
assert.ok(/if \(this\.members\[member\.id\]\) continue;/.test(tc), 'already-mapped members are kept');
assert.ok(/ReactiveCache\.getUser\(\{ username: member\.username \}\)/.test(tc), 'auto-maps by username');
assert.ok(/loginDisabled: true/.test(tc) && /isActive: false/.test(tc) && /authenticationMethod: 'imported'/.test(tc),
'placeholders are inert imported users');
// wekanCreator keeps an explicit UI mapping instead of overwriting it with a placeholder.
const wc = read('models/wekanCreator.js');
assert.ok(/if \(this\.members\[u\._id\]\) continue;/.test(wc),
'wekanCreator does not clobber an explicit mapping with the identity placeholder');
});
test('import is bounded by a client-side watchdog (no infinite spinner)', () => {