Skip to content

v9.97

Choose a tag to compare

@github-actions github-actions released this 17 Jul 01:49

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 needs snap run wekan.migrate typed 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-control now launches attachment-repair in 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 on fs with 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.migrate clears it, failures retry on the next
    start without ever blocking WeKan from starting), and a manual
    snap run wekan.repair-attachments command is registered too. The importer itself now
    stamps the migration marker with filesVersion (currently 2) and, when it finds a marker
    with an older filesVersion — or FILES_ONLY=true in 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_migration marker stores
    the WeKan version and datetime of the previous successful re-check, so while the version is
    unchanged a boot costs ONE findOne — a full re-check is mandatory only after a new WeKan
    release, or WEKAN_FORCE_SCHEMA_UPGRADE=true; opt out with WEKAN_SKIP_SCHEMA_UPGRADE=true),
    non-blocking (runs in the background, WeKan serves immediately), with a live migration
    dashboard at /schema-upgrade-status
    on 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
    updateMany batches instead of per-document round trips — thousands of cards never mean
    thousands of queries). Steps, each verified against git show v8.00:server/migrations.js
    and each idempotent: archived-flag-backfill (docs missing archived never match the
    archived: false view queries — whole boards/lists/cards were invisible in the Swimlanes
    and Lists views), swimlane-structure (every board gets a visible swimlane, every list/card
    a swimlaneId, cards with a dangling listId are 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 embedded checklist.items[] extracted to the
    ChecklistItems collection — the text was in the database but never shown),
    customfields-boardIds (pre-v2.49 scalar boardIdboardIds array — old custom field
    definitions and their card values were orphaned), board-allows-defaults (the ~33
    defaultValue-true allows* flags backfilled — a missing flag rendered as false and HID
    existing descriptions/checklists/comments/attachments), board-members-isactive (members
    without isActive were denied board access), board-permission-lowercase ('PUBLIC' boards
    had silently become member-only), and fs-path-heal (filesystem attachments/avatars whose
    recorded path predates the current WRITABLE_PATH layout — v6.10-18 uploads/<coll>,
    v6.19-v8.4x WRITABLE_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 unordered insertMany round trip (falling back to per-document
    replaceOne upserts 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 one findOne per 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=redirect was 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 passed loginStyle: 'popup', and Meteor's OAuth._loginStyle gives the caller's
    option precedence — so the admin's redirect setting silently lost on every login (and the
    Meteor-internal loginStyle option even leaked into the provider's authorization URL). The
    client now honors a configured loginStyle: '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: isOidcRedirectionEnabled inspected 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.remove hook now prunes boards.members/watchers,
    cards.members/assignees/watchers, lists.watchers and 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 with display: 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-lists lane 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, new models/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) and tests/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): deriving max, max1, … from the preferred handle
    matched exact case only (an existing Max did not stop a new max) 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): inviting cats@foo.com creates user "cats"; inviting
    cats@facebook.com then crashed into Meteor's raw 403 Username already exists. The
    invitee's username now probes cats, cats1, cats2, … to the first free variant, and
    exhaustion raises the translated error-username-taken instead 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, new models/lib/importedCardDates.js): the importer derived
    createdAt only from a createCard activity (absent in Sandstorm/pruned exports — dates
    silently reset to import time) and never imported receivedAt/endAt at 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: NaN to every card
    (#2494, imports/reactiveCache.js): the
    client-side noCache card lookup returned a PROMISE since the Meteor 3 port, so the max-sort
    read was undefined and 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._id of 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, new models/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, new models/lib/apiLogout.js): every POST /users/login minted
    another ~90-day resume token with no way to invalidate any of them. New POST /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, new imports/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): addSubtaskCard inserted EVERY subtask card with the
    constant sort: -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 and Cannot 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,
    new models/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 on meta.cardId with 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 no position of 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-fixed with 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):
    with LDAP_GROUP_FILTER_ENABLE=true, only members of the single
    LDAP_GROUP_FILTER_GROUP_NAME group could log in — an admin who was only in
    LDAP_SYNC_ADMIN_GROUPS could 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, new models/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-all with flex-wrap: wrap (wrapping between links, never inside a word)
    and white-space: nowrap on 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, new models/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-side Users.update that server permissions silently deny.
    editUser/createUser now 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 admin editUser method. 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, new userIdFilter.js): getUserById crashed or built the
    invalid filter (|(=user)) when LDAP_UNIQUE_IDENTIFIER_FIELD was unset/empty (it never consulted
    LDAP_USER_SEARCH_FIELD, where the stored id actually comes from), and the username sync passed $set as
    query OPTIONS to findOneAsync — logging "Syncing user username" while writing nothing. New shared
    buildUserIdFilter() ORs across both configured fields, idAttribute is 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, new models/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
    getAllBoardsTileData method (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,
    new models/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, new models/lib/boardInvites.js): the decline flow called quitBoard (deactivate)
    then acceptInvite, which unconditionally REACTIVATED membership — it also let any removed member re-add
    themselves. quitBoard now clears the pending invitation (and works for stale-invite-only users);
    acceptInvite only 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, new server/lib/ldapPasswordLoginGuard.js,
    server/authentication.js): a validateLoginAttempt hook now rejects password-service logins for
    authenticationMethod: 'ldap' users while LDAP is enabled — respecting the LDAP_LOGIN_FALLBACK=true
    feature, never touching other services or session resumes, and opt-out-able with
    LDAP_MIGRATION_ALLOW_PASSWORD_LOGIN=true so 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, new packages/wekan-ldap/server/encryptionSetting.js,
    ldap.js, docs/Login/LDAP.md): only the undocumented ssl/tls values did anything, and any other value
    (including true, which JSON-parses to a boolean) meant silent plaintext. Now true→LDAPS,
    starttls→STARTTLS, legacy ssl/tls keep 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_USERS merge path replaced the whole profile with 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 kept profile, serviceData
    and userinfo as 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_DATA group/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 at copies.<bucket>.key
    (copies.attachments.key / copies.avatars.key) — but the importers only looked at
    original.gridFsFileId, gridFsFileId and copies.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 their meta.cardId/boardId, which CollectionFS keeps at the record's TOP level — an
    attachment without meta.cardId shows on no card). Either way the migration reported success
    with zero attachments visible. A new shared resolveCfsGridFsId() resolves the id from all
    four layouts (original.gridFsFileId, gridFsFileId, copies.<bucket>.key,
    copies.gridfs.key, then any copies.*.key), the modern importer now drives extraction
    from cfs_gridfs.<bucket>.files itself
    (so a missing/empty cfs.<bucket>.filerecord
    collection no longer skips everything — binaries without a filerecord are still extracted to
    disk), the mongo3 importer copies the top-level boardId/cardId/listId/swimlaneId/
    userId into meta, 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 own getFileStrategy serves a version from
    GridFS when its storage flag says 'gridfs' or it carries a
    versions.*.meta.gridFsFileId reference — those reference-only records worked fine on
    MongoDB, but the migration's file phase only matched versions.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>.files by its
    metadata.fileId back-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 from process.env.WRITABLE_PATH in the browser, where
    process.env never 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-only getAttachmentStoragePaths method, whose Snap-aware computation
    (shared, dependency-free models/lib/attachmentStoragePath.js — WRITABLE_PATH already ends
    in /files on Snap, /files is appended elsewhere) is also used for the settings document's
    default filesystem path, which pointed at /data/attachments instead of
    /data/files/attachments on Docker. Unit tests with negative cases:
    tests/attachmentStoragePath.test.cjs. Thanks to mueschel and xet7.

  • Snap: snap run wekan.database ferretdb looked 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/FerretDB internal/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.