v9.97
v9.97 2026-07-17 WeKan ® release
This release adds the following new features:
-
Snap: SELF-HEALING attachments — the fixes below apply themselves automatically on
upgrade, no commands needed (#6473,
snap-src/bin/attachment-repair(new),snap-src/bin/wekan-control,
snap-src/bin/migration-control,snap-src/bin/wekan-force-migrate,
releases/migrate-mongodb-to-ferretdb.mjs,snap-src/bin/migrate-mongo3-to-ferretdb.mjs,
snapcraft.yaml,snapcraft-core26.yaml). The snap auto-refreshes on ~15k servers, so a fix
that needssnap run wekan.migratetyped by hand does not reach most of them — and a full
re-migration would be WRONG anyway, because it rebuilds FerretDB from the frozen MongoDB
source and would lose boards/cards users created on FerretDB since migrating. Instead, on
every start on FerretDB,wekan-controlnow launchesattachment-repairin the
background: it starts a temporary source mongod (7.x or the bundled 3.2 reader — the
MongoDB data was never modified, so everything is recoverable) and runs the migration
importer in a new incremental FILES_ONLY mode that checks what is already migrated
and migrates only what is missing: text collections are never touched, every attachment/
avatar whose target record is onfswith the file actually on disk is verified and
skipped, records deleted by users since the migration are never resurrected, and only the
missing binaries/records are extracted — so on healthy servers the repair is a fast no-op
and on #6473-affected servers the attachments simply appear, live, while WeKan runs. It
runs once (marker$SNAP_COMMON/.attachments-files-v2-done; a fresh successful
migration pre-writes it,snap run wekan.migrateclears it, failures retry on the next
start without ever blocking WeKan from starting), and a manual
snap run wekan.repair-attachmentscommand is registered too. The importer itself now
stamps the migration marker withfilesVersion(currently 2) and, when it finds a marker
with an olderfilesVersion— orFILES_ONLY=truein the environment — automatically
switches to this incremental repair, so Docker/source installs get the same self-healing
by simply re-running the same importer command they migrated with. Verified end-to-end
against two live FerretDB instances with the real importer: a broken-migration target
(missing CollectionFS record, gridFsFileId-only record, marker without filesVersion) was
repaired — record re-created with its card linkage, binary extracted, record repointed —
while a board renamed on FerretDB after the migration stayed untouched, and the second run
exited immediately as already-migrated. Behavioral tests (positive + negative) drive the
real bash script with stubbed snap tooling:tests/attachmentRepair.test.cjs. Thanks to
mueschel and xet7. -
All platforms: startup schema upgrade — WeKan now CHECKS on start that data from EVERY old
WeKan version has been migrated to the newest database structure, and migrates only what is
missing (#6473 follow-up,
#1959, #1971,
server/lib/schemaUpgradeSteps.js(new),server/startupSchemaUpgrade.js(new),
server/migrations/ensureValidSwimlaneIds.js,server/imports.js). WeKan v0.9–v8.00 ran
startup migrations; v8.01 disabled them (large databases meant long downtime) in favour of
read-time compatibility — which covers the swimlane era but not everything, so text data
from old versions could sit invisible in the database. The new startup upgrade reinstates
the safety net without the downtime: version-gated (the_wekan_migrationmarker stores
the WeKan version and datetime of the previous successful re-check, so while the version is
unchanged a boot costs ONEfindOne— a full re-check is mandatory only after a new WeKan
release, orWEKAN_FORCE_SCHEMA_UPGRADE=true; opt out withWEKAN_SKIP_SCHEMA_UPGRADE=true),
non-blocking (runs in the background, WeKan serves immediately), with a live migration
dashboard at/schema-upgrade-statuson every platform that shows the Admin Panel product
name when one is set (not "WeKan") plus per-step progress, and fast on big databases
(bounded existence probes,distinct()set-joins instead of card scans, and server-side
updateManybatches instead of per-document round trips — thousands of cards never mean
thousands of queries). Steps, each verified againstgit show v8.00:server/migrations.js
and each idempotent:archived-flag-backfill(docs missingarchivednever match the
archived: falseview queries — whole boards/lists/cards were invisible in the Swimlanes
and Lists views),swimlane-structure(every board gets a visible swimlane, every list/card
aswimlaneId, cards with a danglinglistIdare rescued to a visible list — and, fixing
#1959 and #1971, unarchived cards whose swimlane was DELETED, ARCHIVED or belongs to another
board are reassigned to the board's first visible swimlane, so everything is visible in both
the Swimlanes view and the Lists view; the card-insert hook now also validates
client-supplied swimlaneIds so new cards can never land under a deleted/archived swimlane
again),checklist-items-embedded(pre-v0.79 embeddedchecklist.items[]extracted to the
ChecklistItemscollection — the text was in the database but never shown),
customfields-boardIds(pre-v2.49 scalarboardId→boardIdsarray — old custom field
definitions and their card values were orphaned),board-allows-defaults(the ~33
defaultValue-trueallows*flags backfilled — a missing flag rendered as false and HID
existing descriptions/checklists/comments/attachments),board-members-isactive(members
withoutisActivewere denied board access),board-permission-lowercase('PUBLIC' boards
had silently become member-only), andfs-path-heal(filesystem attachments/avatars whose
recorded path predates the current WRITABLE_PATH layout — v6.10-18uploads/<coll>,
v6.19-v8.4xWRITABLE_PATH/<coll>, CFS→ostrio temp files — are located and repointed/copied
into the current layout). A step that fails or leaves unresolved work never blocks WeKan
from starting and keeps the version un-stamped so the next boot re-checks. 36+ unit tests
with negative cases (tests/schemaUpgradeSteps.test.cjs), plus verified end-to-end against
a live FerretDB (SQLite) with old-shape seed data: all 8 steps migrate correctly and the
second boot is gated to a no-op. Thanks to mueschel and xet7. -
Migration speed: batched writes and preloaded lookups instead of per-document round trips
(5-hour migrations reported;releases/migrate-mongodb-to-ferretdb.mjs). The text phase now
copies each batch with ONE unorderedinsertManyround trip (falling back to per-document
replaceOneupserts only for batches that hit duplicates on resumed/re-run migrations), and
the attachment/avatar phases preload the target's metadata records once per bucket instead
of onefindOneper file (bounded: collections over 100k records fall back to per-file
lookups). The startup schema upgrade uses the same philosophy (distinct()/updateMany).
Thanks to mueschel and xet7.
and fixes the following bugs:
-
OAuth2/OIDC:
OAUTH2_LOGIN_STYLE=redirectwas ignored — a popup always opened
(#5695,packages/wekan-oidc/oidc_client.js,
server/authentication.js,server/models/settings.js,client/components/main/layouts.js).
The setting was stored correctly in the OIDC service configuration, but the login button
always passedloginStyle: 'popup', and Meteor'sOAuth._loginStylegives the caller's
option precedence — so the admin's redirect setting silently lost on every login (and the
Meteor-internalloginStyleoption even leaked into the provider's authorization URL). The
client now honors a configuredloginStyle: 'redirect'over the button's generic popup
default (explicit caller choices still win; Safari-private-mode popup fallback kept) and no
longer leaks the option to the provider. Also repaired the existing
OIDC_REDIRECTION_ENABLED=true"go straight to the provider" feature, which was doubly
broken since the Meteor 3 port:isOidcRedirectionEnabledinspected a Promise (always
false), and the client handler assigned an undeclared variable (strict-mode ReferenceError).
Behavioral tests drive the real client code in a VM against Meteor's loginStyle precedence:
tests/oauth2LoginStyle.test.cjs(12 tests, positive + negative). Thanks to ArturRuta and
xet7. -
Deleting a user left "ghost users" on boards and cards
(#1289,models/users.js, new
models/lib/userDeletionCleanup.js): every deletion path (admin method, self-service
delete,DELETE /api/users/:userId) removed only the user document, leaving dangling
references — empty-avatar board members that could not be removed, stale card
members/assignees/watchers, orphaned avatar files — reproducible for 8 years. A server-side
Users.after.removehook now prunesboards.members/watchers,
cards.members/assignees/watchers,lists.watchersand the user's avatar files on every
deletion path; activities and comments are deliberately kept for history (their rendering
is already null-guarded). Tests:tests/userDeletionCleanup.test.cjs(6). Thanks to
chotaire and xet7. -
Swimlanes jumped up and down when starting/ending a card drag
(#2877,client/components/boards/boardBody.css):
drag start hid every list's "+ Add Card" composer link withdisplay: none, collapsing its
row — lists shrank, auto-height swimlanes shrank, and every swimlane below jumped up ~23px
(and back down on drop), shifting the drop target under the cursor mid-drag (root cause
proven by frame-diffing the issue's own GIF). The composer now hides with
visibility: hidden, keeping its layout box, so nothing moves; collapsed multi-selection
cards stay collapsed intentionally (they preview the post-drop list). Tests:
tests/swimlaneDragJump.test.cjs(8). Thanks to xet7. -
Dragging a card toward an off-screen list never auto-scrolled the board
(#443,client/components/lists/list.js, new
imports/lib/boardAutoScroll.js): the horizontal auto-scroll targeted.board-canvas,
which only overflows vertically since the swimlane layout — its scrollLeftMax was always 0,
so the guard never fired and users had to drop on an intermediate list and scroll by hand.
Edge-proximity auto-scroll now drives the.js-listslane actually under the pointer
(clamped, overshoot-safe), with vertical scrolling kept on the canvas. Tests:
tests/boardAutoScroll.test.cjs(15, incl. a proof the old no-overflow target could never
scroll). Thanks to anhenghuang, AlexanderS and xet7. -
Board Rules: the Card Title Filter did nothing on several triggers — and rules created
via the REST API never fired at all (#2345,
#2674,
client/components/rules/triggers/boardTriggers.js,.jade,server/rulesHelper.js,
server/models/rules.js, newmodels/lib/ruleCardTitleFilter.js,docs/API/Rules.md,
docs/API/REST-API.md,api.py): the generic moved/archive trigger builders never saved
the filter (and a trigger doc MISSING the field can never satisfy the matcher's$in, so
those rules fired for nothing); a set filter never showed in the rule details; archive
activities carry no card title so their filters compared against undefined; and REST-created
rules skipped the wildcard defaulting entirely — the exact "remove user when moved away"
rule from #2674 silently never ran. Filters are now stored (empty →*), shown in the rule
description, matched with the title resolved from the card when the activity lacks it,
legacy field-less triggers keep matching, the API normalizes missing matching fields to
wildcards and validates types, and the rule actions no longer crash on unresolvable
usernames or member-less cards. The Rules REST API is now documented (docs/API/Rules.md)
and listed in api.py's help with the #2674 two-rule example. Tests:
tests/rulesCardTitleFilter.test.cjs(12) andtests/rulesApiTriggerNormalize.test.cjs
(19). Thanks to InfoSec812, sfahrenholz and xet7. -
Sandstorm: username uniqueness probe was case-sensitive and raced concurrent logins
(#574,sandstorm.js, new
models/lib/sandstormUsername.js): derivingmax,max1, … from the preferred handle
matched exact case only (an existingMaxdid not stop a newmax) and the check-then-set
window let a concurrent insert claim the name first, aborting the hook with E11000. The
probe is now an anchored, escaped, case-insensitive regex and the claim retries the next
number on a duplicate-key loss. Tests:tests/sandstormUsername.test.cjs(11). Thanks to
mitar and xet7. -
Inviting a second user whose email shares a local part failed with a bare "403"
(#619,server/models/users.js, new
models/lib/inviteeUsername.js): invitingcats@foo.comcreates user "cats"; inviting
cats@facebook.comthen crashed into Meteor's raw403 Username already exists. The
invitee's username now probescats,cats1,cats2, … to the first free variant, and
exhaustion raises the translatederror-username-takeninstead of a number. Tests:
tests/inviteeUsername.test.cjs(11, incl. the literal reported scenario). Thanks to
lemoer and xet7. -
Date pickers ignored the configured default time and stored 12:00
(#1502,client/lib/datepicker.js, new
imports/lib/datePickerTime.js): the due-date picker configures a 17:00 default (and
now() for received/start/end), but an inverted guard applied it only when the card ALREADY
had a date — exactly when it is unnecessary — so empty time fields fell back to a
hard-coded 12:00 on save. The default now pre-fills empty pickers and backs the submit
fallback; existing dates keep their own time. The issue's original AM/PM parse mismatch was
already resolved by the native date/time inputs (79b9482). Tests:
tests/datePickerDefaultTime.test.cjs(10). Thanks to Vlasterx, saschafoerster,
suncobran and xet7. -
Dragging a card while someone added a card to the target list dropped it into the WRONG
swimlane (#2769, new
client/lib/cardDragGeometry.js,client/components/lists/listBody.js): jQuery UI sortable
snapshots container geometry at drag start; a mid-drag DOM insertion (another user's new
card, or the drag's own composer auto-close) shifted every swimlane below while the cached
rectangles stayed put — the drop landed in the neighbouring swimlane with no visible
placeholder. A MutationObserver now refreshes the active drag's geometry on real mid-drag
layout changes (sortable's own churn filtered out). Tests:
tests/cardDragGeometry.test.cjs(13). Thanks to hever and xet7. -
Board import lost card dates (#1992,
models/wekanCreator.js, newmodels/lib/importedCardDates.js): the importer derived
createdAtonly from acreateCardactivity (absent in Sandstorm/pruned exports — dates
silently reset to import time) and never importedreceivedAt/endAtat all. All five date
fields now restore with sane fallbacks (activity → the card's own exported date → import
time), and the card creator falls back to the exported userId. The missing-cards half of the
report was already fixed by 68e0032. Tests:tests/importedCardDates.test.cjs(13).
Thanks to xet7. -
Archiving a swimlane made its cards disappear — and restore brought back an empty
swimlane (#2292,models/swimlanes.js, new
models/lib/swimlaneArchive.js): only the swimlane document was flagged; its unarchived
cards became invisible everywhere (board views render unarchived swimlanes, Archive lists
archived docs). Archiving a swimlane now archives its cards (mirroring lists), and restore
brings back exactly the cards archived WITH it — individually archived cards stay archived.
Tests:tests/archiveSwimlaneCards.test.cjs(11). Thanks to Cactusbone and xet7. -
"Move/Copy selection to board" wrote
sort: NaNto every card
(#2494,imports/reactiveCache.js): the
client-sidenoCachecard lookup returned a PROMISE since the Meteor 3 port, so the max-sort
read wasundefinedand every moved card got NaN — cards appeared and disappeared and could
not be reordered. The uncached client path is synchronous minimongo again. Tests:
tests/reactiveCacheNoCacheCard.test.cjs(8, incl. a proof the pre-fix routing yields NaN).
Thanks to Vermeille and xet7. -
Subtask "View it" did nothing when the subtask lives on another board
(#1853,
client/components/cards/subtaskViewHelpers.js,subtasks.js): the original crash
(board._idof undefined) had become a silent no-op guard — when the deposit board is not
in minimongo the button just did nothing. Navigation now falls back to the subtask's own
boardId (the route loads the board), and truly broken subtasks warn instead of dying.
Tests:tests/subtaskViewNavigation.test.cjs(11). Thanks to Vanclief and xet7. -
Labels/members could not be dragged onto cards added after the board rendered
(#1554,client/components/lists/list.js):
the droppable-initializing autorun lost its reactive dependency in 7673c77 (2023), so it
ran once per list render and later-added minicards silently rejected sidebar drags until the
board was re-entered ("works after search-and-back"). Dependency restored via the
ReactiveCache. Tests:tests/labelDragDroppable.test.cjs(3). Thanks to Miffe and xet7. -
"Add filtered cards to selection" swept cards from OTHER boards into bulk actions
(#2306,client/lib/filter.js,
client/lib/multiSelection.js, newmodels/lib/boardScopedSelection.js): the filter
selector carried no boardId, and minimongo legitimately holds foreign-board cards (linked
boards, dialogs, notifications) — a bulk archive could silently mutate other boards. The
selection and its bulk-action selector are now board-scoped, and foreign ids are rejected at
insertion. Tests:tests/boardScopedSelection.test.cjs(17, incl. the exact reported
repro). Thanks to IcedQuinn and xet7. -
REST API: login tokens could never be revoked (#1437,
server/apiAuthRoutes.js, newmodels/lib/apiLogout.js): everyPOST /users/loginminted
another ~90-day resume token with no way to invalidate any of them. NewPOST /users/logout
revokes the presented token (or all of the user's tokens with{"all": true}), always scoped
to the authenticated user. Tests:tests/apiLogout.test.cjs(12). Thanks to
ppouliot and xet7. -
Editing one checklist item and clicking another left BOTH edit forms open — and
submitting overwrote the new item's title with the previous item's text
(#2418,client/lib/inlinedform.js, new
client/lib/inlinedFormManager.js): since a 2021 change, the "close the previously opened
inline form" call was a silent no-op (the escape action is disabled for click execution),
and the submit handlers grab the template's FIRST textarea — with two forms open the wrong
form's text was saved. Subtasks reproduced the full bug; checklists' workaround corrupted
the open-form tracker so Escape closed the whole card pane. A small state manager restores
the single-open-form invariant (opening a form closes the previous one, without closing
popups — preserving the 2021 intent). Tests:tests/inlinedFormSingleOpen.test.cjs
(9, incl. the exact reported repro chain). Thanks to Beebo89 and xet7. -
Advanced Filter never matched date custom fields (#2989,
client/lib/filter.js, newimports/lib/advancedFilter.js): the tokenizer treated every/
as a regex delimiter even inside quotes, so'Date de fin' == '06/04/2020'broke tokenizing
and the filter silently did nothing; and date custom fields store Date OBJECTS while the
selectors compared strings/parseInt —==/</>could never match and!=matched
everything. Dates typed in the user's date format (day-first respected) now build half-open
Date-range selectors (==means "that day"), with the legacy behavior untouched for
non-date fields. Tests:tests/advancedFilterDate.test.cjs(14, incl. a proof the old
selector shape never matched a stored Date). Thanks to k1ng440 and xet7. -
Cards could not be reordered by drag in lists full of subtask cards — with silent
data loss on multi-selection drops (#3826,
server/models/cards.js,client/components/lists/list.js, new
models/lib/cardSortRepair.js):addSubtaskCardinserted EVERY subtask card with the
constantsort: -1, so such lists contained only tied sorts; dropping between two equal
sorts computes a zero increment, the move modifier came out empty and the card snapped
back — and a multi-selection drop wrote the SAME sort to every selected card, permanently
destroying their order. Subtask cards now append with a unique sort, and the drop handler
detects degenerate (tied/inverted) gaps and repairs the siblings' sorts to a strict order
before recomputing the drop index. Tests:tests/subtaskCardReorder.test.cjs(14).
Thanks to jayki and xet7. -
Cross-board subtask full path disappeared after refresh
(#3453,server/publications/boards.js, new
server/lib/subtaskAncestors.js): the board publication shipped only the DIRECT parent
cards, while the full-path label walks the whole ancestor chain client-side — after F5 the
grandparents were missing from minimongo and the path truncated/vanished. The publication
now walks and publishes the full ancestor chain (batched per level, cycle-safe, tolerant of
deleted ancestors). Tests:tests/subtaskAncestors.test.cjs(11). Thanks to
MPeti1 and xet7. -
Linked cards: phantom empty custom-field rows and a template TypeError on every render
(from #3748,models/cards.js, new
models/lib/customFieldsWD.js): a linked card keeps the ORIGINAL board's custom-field
snapshot; unresolvable definitions rendered as empty{}placeholders — a phantom row per
entry andCannot read properties of undefined (reading 'type')from the card details
template (also reachable on normal cards with deleted definitions). Unmatched entries are
now skipped. The rest of #3748 is by design: linked cards are pointers that mirror the
original; label/custom-field ids are board-scoped, and name-based inheritance is the COPY
feature. Tests:tests/customFieldsWD.test.cjs(9). Thanks to peterbecich and xet7. -
Archive sidebar: Restore/Delete links floated ambiguously between two archived cards
(#3199,
client/components/sidebar/sidebarArchives.jade,sidebar.css): each card and its links
were loose siblings with near-equal spacing above and below, so the links seemed to belong
to the card underneath. Each archived card is now grouped with its own links in one
container with a clear separator gap below (RTL-safe logical properties, theme-neutral).
Tests:tests/archiveLinkGrouping.test.cjs(9). Thanks to fxkr and xet7. -
Attachments uploaded inside card comments: listing in the card's Attachments section is
now guaranteed and regression-locked (#3843,
newmodels/lib/attachmentMeta.js,client/lib/utils.js): the rich comment editor already
uploads into the same Attachments collection with the same card meta as the Attachments
popup, so they DO list — but nothing pinned that invariant and the meta was built in two
places. One shared, null-safe builder now feeds both paths, with tests pinning that gallery
queries key onmeta.cardIdwith no source filter (and that board backgrounds stay
excluded). Tests:tests/commentAttachmentsList.test.cjs(12). Thanks to
jghaanstra and xet7. -
Maximized card rendered at the wrong place after scrolling in Swimlanes view
(#4822,client/components/cards/cardDetails.css):
the legacy maximized-pane CSS had nopositionof its own, so the pane stayed an in-flow
item inside the scrolled board canvas (off-screen after scrolling down); the desktop-mode
floating-window rules also out-specified every maximize geometry rule, and inline drag
offsets survived maximizing. The maximized pane is now viewport-fixedwith explicit insets
that beat both the floating-window rules and stale drag offsets (drag position is restored
on minimize), RTL-safe via logical properties. A cascade-resolver regression test pins the
behavior against the real stylesheet and fails on the pre-fix CSS:
tests/maximizedCardPosition.test.cjs(11 tests). Thanks to pravdomil and xet7. -
LDAP group filter locked out admins and rejected multiple groups
(#4036,packages/wekan-ldap/server/ldap.js):
withLDAP_GROUP_FILTER_ENABLE=true, only members of the single
LDAP_GROUP_FILTER_GROUP_NAMEgroup could log in — an admin who was only in
LDAP_SYNC_ADMIN_GROUPScould not log in at all, and a comma-separated group list produced
the literal filter(cn=A,B)that matches nothing. The filter now ORs across every
comma-separated group name and, when admin sync is enabled, also admits the admin-sync
groups. Thanks to zeisss-mercedes and xet7. -
Board invitation emails could carry a dead invitation code — and signup then failed with
"The invitation code doesn't exist" (#4043,
server/models/settings.js,server/models/users.js, newmodels/lib/invitationCodeEmail.js):
re-inviting an unregistered user re-sent the SAME stale code (invalidated by an earlier
OAuth2 signup or deleted account) instead of regenerating it; a failed SMTP send deleted a
previously delivered, still-valid code; codes were mailed without checking they exist and
are valid; the invitee address was only lowercased client-side; and the signup hook deleted
the code BEFORE the account insert was committed, so a failed insert burned the code for
every retry. Re-invites now regenerate stale codes (still-valid ones are kept so earlier
emails keep working), sends fail loudly on unusable codes, rollback only removes codes the
failed send itself created, and consumption happens only after a successful signup. Tests:
tests/invitationCodeEmail.test.cjs(14). Thanks to jkoenig134 and xet7. -
Japanese/Chinese UI: the add-card button and footer links wrapped mid-word
(#4023,client/components/forms/forms.css):
CJK text has no spaces, so the narrow add-card composer footer broke 追加 / リンク / 検索 /
テンプレート between any two characters. The composer/edit footers now use
word-break: keep-allwithflex-wrap: wrap(wrapping between links, never inside a word)
andwhite-space: nowrapon the button and each link group; Latin wrapping is unchanged and
the negative tests pin that no global word-break was introduced. Tests:
tests/cjkLabelWrap.test.cjs(9). Thanks to yuki-snow1823, Sylvain2703 and xet7. -
Users added to a team AFTER the team was assigned to a board never became board members —
and the Admin Panel bulk team add/remove silently did nothing
(#4593,server/models/users.js,
client/components/settings/peopleBody.js, newmodels/lib/teamBoardMemberSync.js):
assigning a team to a board snapshotted its then-current members, so later joiners could see
the board via publications but every authority gate (hasMember, card/list mutations,
attachment downloads, export) denied them; and the Admin Panel "Add/Remove team to selected
users" used a direct client-sideUsers.updatethat server permissions silently deny.
editUser/createUsernow add new team members to all boards their teams are assigned to
(never touching existing member entries, skipping template boards), and the bulk actions go
through the admineditUsermethod. Tests:tests/teamBoardMemberSync.test.cjs(13).
Thanks to szymonsztuka and xet7. -
LDAP background sync only worked once per user (#4654,
packages/wekan-ldap/server/ldap.js,sync.js, newuserIdFilter.js):getUserByIdcrashed or built the
invalid filter(|(=user))whenLDAP_UNIQUE_IDENTIFIER_FIELDwas unset/empty (it never consulted
LDAP_USER_SEARCH_FIELD, where the stored id actually comes from), and the username sync passed$setas
query OPTIONS tofindOneAsync— logging "Syncing user username" while writing nothing. New shared
buildUserIdFilter()ORs across both configured fields,idAttributeis persisted on new LDAP users, and
the username sync actually updates. Tests:tests/ldapUserIdFilter.test.cjs(11). Thanks to fabianrbz and xet7. -
All Boards page: per-list card counts and member avatars never showed (#5174,
#4825, newmodels/lib/boardTileData.js,
server/publications/boards.js,client/components/boards/boardsList.js,.jade): the helpers were stubbed
to[]to stop the #4214 reactive "icons dance", and the gating flags were never published. New non-reactive
getAllBoardsTileDatamethod (one boards query, one lists query, one grouped card count) fetched once per
page visit; per-board "Show card count per list"/"Show Board members avatars" settings enforced strictly both
ways. Tests:tests/boardTileData.test.cjs(17). Thanks to mueschel, Miuler and xet7. -
Lists rendered with different widths by default (#5659,
newmodels/lib/listWidth.js,client/components/lists/list.js,listHeader.js,models/users.js): the
default width was duplicated in four resolution paths that disagreed (270 vs 272), so lists on the same
(public) board could differ with no customization. Single source of truth (272), all paths normalize
out-of-range values the same way; customized widths still win. Tests:tests/listWidthDefaults.test.cjs(12).
Thanks to butteredCat-2021 and xet7. -
Declining a board invitation kept the board active in the member's overview (#4730,
server/models/boards.js, newmodels/lib/boardInvites.js): the decline flow calledquitBoard(deactivate)
thenacceptInvite, which unconditionally REACTIVATED membership — it also let any removed member re-add
themselves.quitBoardnow clears the pending invitation (and works for stale-invite-only users);
acceptInviteonly activates when an invitation actually exists. Tests:tests/boardInvites.test.cjs(11).
Thanks to Griiimm and xet7. -
After migrating a user from password to LDAP, the old local password still logged in
(#4419, newserver/lib/ldapPasswordLoginGuard.js,
server/authentication.js): avalidateLoginAttempthook now rejects password-service logins for
authenticationMethod: 'ldap'users while LDAP is enabled — respecting theLDAP_LOGIN_FALLBACK=true
feature, never touching other services or session resumes, and opt-out-able with
LDAP_MIGRATION_ALLOW_PASSWORD_LOGIN=trueso no deployment is hard-locked. Tests:
tests/ldapPasswordLoginGuard.test.cjs(12). Thanks to preciousamorc and xet7. -
LDAP_ENCRYPTION=true(the documented value) silently connected WITHOUT encryption
(#4158, newpackages/wekan-ldap/server/encryptionSetting.js,
ldap.js,docs/Login/LDAP.md): only the undocumentedssl/tlsvalues did anything, and any other value
(includingtrue, which JSON-parses to a boolean) meant silent plaintext. Nowtrue→LDAPS,
starttls→STARTTLS, legacyssl/tlskeep their historical meanings with a deprecation notice, and unknown
values log a clear warning listing the accepted ones. Tests:tests/ldapEncryptionSetting.test.cjs(22).
Thanks to farwayer and xet7. -
OIDC login onto an existing account wiped the profile — avatars and templates disappeared
(#4560,server/models/users.js): the
OAUTH2_MERGE_EXISTING_USERSmerge path replaced the wholeprofilewith the OIDC-derived one, losing
avatarUrl,templatesBoardId(+ template swimlanes), language and preferences. The merge now preserves the
stored profile and only fills gaps/updates the asserted fullname; the fail-closed linking rules
(GHSA-mp7g-hj5q-gxhq) are untouched. Tests:tests/oidcProfileMerge.test.cjs(7, fails on pre-fix code).
Thanks to LeoLu-eng and xet7. -
OAuth2/OIDC: concurrent logins could contaminate each other's user data — users saw stale
or missing emails/username/teams, and the database could disagree with the UI
(#4897,packages/wekan-oidc/oidc_server.js,
packages/wekan-oidc/loginHandler.js). The OIDC server flow keptprofile,serviceData
anduserinfoas MODULE-SCOPE variables shared by every login of every user: fields the
current login did not overwrite leaked from the previous user's login (refreshToken,
whitelisted id-token claims, branch-dependent email), and because the handler awaits the
token/userinfo requests, two interleaved logins wrote into the SAME objects — a login could
complete carrying another user's id/email/username, updating the wrong user document. The
PROPAGATE_OIDC_DATAgroup/attribute path additionally ran on implicit GLOBALS
(teamArray,isAdmin,user_email, …) with awaits between assignment and use, so
concurrent logins could write one user's email/teams/admin flag onto another user's document
— real database corruption, matching the "web interface shows different data vs mongodb"
report. All login state is now per-login locals, the login handler compares actual values
(the old username/fullname comparisons compared a string to an object, always true), and a
regression test proves isolation under concurrent logins and fails against the pre-fix code:
tests/oidcLoginStateIsolation.test.cjs(11 tests, positive + negative). Thanks to
gerardo-junior and xet7. -
Snap/Docker: after the MongoDB → FerretDB migration ALL CollectionFS-era attachments were
missing (#6473,
releases/migrate-mongodb-to-ferretdb.mjs,snap-src/bin/migrate-mongo3-to-ferretdb.mjs,
snap-src/bin/migrate-gridfs-to-fs.mjs). Real CollectionFS (old WeKan's
FS.Store.GridFS('attachments')) stores each file's GridFS id atcopies.<bucket>.key
(copies.attachments.key/copies.avatars.key) — but the importers only looked at
original.gridFsFileId,gridFsFileIdandcopies.gridfs.key, none of which exist in that
layout. So the modern importer "skipped" every file silently and the MongoDB 3.x importer
extracted the binaries but never created an attachment record (and the records it did create
lost theirmeta.cardId/boardId, which CollectionFS keeps at the record's TOP level — an
attachment withoutmeta.cardIdshows on no card). Either way the migration reported success
with zero attachments visible. A new sharedresolveCfsGridFsId()resolves the id from all
four layouts (original.gridFsFileId,gridFsFileId,copies.<bucket>.key,
copies.gridfs.key, then anycopies.*.key), the modern importer now drives extraction
fromcfs_gridfs.<bucket>.filesitself (so a missing/emptycfs.<bucket>.filerecord
collection no longer skips everything — binaries without a filerecord are still extracted to
disk), the mongo3 importer copies the top-levelboardId/cardId/listId/swimlaneId/
userIdintometa, and filerecords whose binary cannot be located are reported as
errors on the migration dashboard instead of being silently dropped. If you already
migrated and attachments are missing, run the migration again:snap run wekan.migrate
(the source MongoDB data was never modified). Behavioral positive/negative tests:
tests/migrationAttachmentExtraction.test.cjs. Thanks to mueschel and xet7. -
Snap/Docker: Meteor-Files attachments stored in GridFS without a
storage: 'gridfs'flag
were left pointing at a GridFS that no longer exists after migration
(#6473,releases/migrate-mongodb-to-ferretdb.mjs,
snap-src/bin/migrate-gridfs-to-fs.mjs). WeKan's owngetFileStrategyserves a version from
GridFS when its storage flag says'gridfs'or it carries a
versions.*.meta.gridFsFileIdreference — those reference-only records worked fine on
MongoDB, but the migration's file phase only matchedversions.original.storage: 'gridfs',
so their binaries were never extracted and the record kept pointing into the void (404 after
the switch). The record scan now matches both forms (and every version, not just
original), and a second, bucket-driven sweep walks<bucket>.filesby its
metadata.fileIdback-reference (the way WeKan writes GridFS uploads), recovering binaries
even when the record's flags say nothing about GridFS. Any GridFS-flagged version whose
binary genuinely cannot be located is reported on the dashboard. Thanks to mueschel and xet7. -
Admin Panel > Attachments showed "/data" as the Filesystem Storage path on every
platform (#6473,
client/components/settings/attachments.js,client/components/settings/settingBody.js,
server/models/attachmentStorageSettings.js,models/lib/attachmentStoragePath.js). The
Blaze helpers computed the path fromprocess.env.WRITABLE_PATHin the browser, where
process.envnever has it, so the page always fell back to "/data" — a path that does not
exist on a Snap install (the real path is/var/snap/wekan/common/files/attachments),
sending admins hunting for a directory that was never there. The client now asks the server
via a new admin-onlygetAttachmentStoragePathsmethod, whose Snap-aware computation
(shared, dependency-freemodels/lib/attachmentStoragePath.js— WRITABLE_PATH already ends
in/fileson Snap,/filesis appended elsewhere) is also used for the settings document's
default filesystem path, which pointed at/data/attachmentsinstead of
/data/files/attachmentson Docker. Unit tests with negative cases:
tests/attachmentStoragePath.test.cjs. Thanks to mueschel and xet7. -
Snap:
snap run wekan.database ferretdblooked like it re-ran the migration — it only
switches databases (#6473,
snap-src/bin/wekan-database). Running it while already on FerretDB printed "WeKan now uses
FerretDB (SQLite)." and users reasonably read that as "migration done" while their
attachments stayed missing. It now says when nothing was switched, states that the command
does NOT migrate data, and names the command that does:snap run wekan.migrate. Thanks to
mueschel and xet7. -
FerretDB (SQLite) rejected documents with literal dotted field names, silently dropping
them during migration (#6473,
wekan/FerretDBinternal/types/document_validation.go).
MongoDB has accepted documents with literal.in field names since 3.6, and data migrated
from a real MongoDB can legitimately contain them — but FerretDB v1's document validation
rejected every such document ("invalid key: … (key must not contain '.' sign)"), and since
per-item migration errors are deliberately non-fatal (#6466), those documents simply went
missing. Fixed in the bundled wekan/FerretDB fork: dotted keys are stored and round-tripped
literally with MongoDB's own semantics (query/update paths still treat.as a path
separator), while the other key rules ($prefix, duplicates, UTF-8) still reject. Verified
end-to-end against a live FerretDB (SQLite): insert, nested$set, round-trip, and the
negative cases. Thanks to mueschel and xet7.
Thanks to above GitHub users for their contributions and translators for their translations.